wowlab_docgen_cli/hosted_rustdoc/
mod.rs1mod catalog;
2mod render;
3
4use serde::Deserialize;
5use wowlab_fs::{
6 artifact::GeneratedTextFile,
7 file,
8 path::{Path, PathBuf},
9 walk,
10};
11
12use crate::{context::collectors::tidy_rules, infra::cargo_metadata};
13
14#[derive(Debug)]
16pub struct Options {
17 pub docs: PathBuf,
19 pub mcp_catalog: PathBuf,
21 pub revision: Box<str>,
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct Summary {
28 pub pages: usize,
30 pub packages: usize,
32 pub tidy_rules: usize,
34 pub manifests: usize,
36 pub mcp_tools: usize,
38}
39
40#[derive(Debug, thiserror::Error)]
42#[error(transparent)]
43pub struct Error(#[from] ErrorKind);
44
45#[derive(Debug, thiserror::Error)]
46enum ErrorKind {
47 #[error(transparent)]
48 CargoMetadata(cargo_metadata::Error),
49 #[error(transparent)]
50 TidyConfig(wowlab_tidy::ConfigError),
51 #[error("failed to read {}: {source}", path.display())]
52 Read {
53 path: PathBuf,
54 #[source]
55 source: wowlab_fs::error::Error,
56 },
57 #[error("failed to parse {}: {source}", path.display())]
58 Json {
59 path: PathBuf,
60 #[source]
61 source: serde_json::Error,
62 },
63 #[error("failed to parse {}: {source}", path.display())]
64 Toml {
65 path: PathBuf,
66 #[source]
67 source: toml::de::Error,
68 },
69 #[error(transparent)]
70 Walk(walk::Error),
71 #[error(transparent)]
72 Persist(wowlab_fs::artifact::Error),
73 #[error("reference Rustdoc page has no usable <head>")]
74 MissingHead,
75 #[error("Cargo metadata does not declare a workspace repository")]
76 MissingRepository,
77 #[error("manifest path has no class and specialization parents: {}", path.display())]
78 InvalidManifestPath { path: PathBuf },
79}
80
81impl From<cargo_metadata::Error> for Error {
82 fn from(source: cargo_metadata::Error) -> Self {
83 ErrorKind::CargoMetadata(source).into()
84 }
85}
86
87impl From<wowlab_tidy::ConfigError> for Error {
88 fn from(source: wowlab_tidy::ConfigError) -> Self {
89 ErrorKind::TidyConfig(source).into()
90 }
91}
92
93impl From<walk::Error> for Error {
94 fn from(source: walk::Error) -> Self {
95 ErrorKind::Walk(source).into()
96 }
97}
98
99impl From<wowlab_fs::artifact::Error> for Error {
100 fn from(source: wowlab_fs::artifact::Error) -> Self {
101 ErrorKind::Persist(source).into()
102 }
103}
104
105#[derive(Debug, Deserialize)]
106struct McpTool {
107 name: String,
108 summary: String,
109 order: u8,
110 tips: Box<[Box<str>]>,
111}
112
113#[derive(Debug)]
114struct EngineManifest {
115 class_name: String,
116 spec_name: String,
117 id: u32,
118 part_count: usize,
119 spell_count: usize,
120 aura_count: usize,
121 auto_attack_count: usize,
122 path: String,
123}
124
125pub fn generate(root: &Path, options: &Options) -> Result<Summary, Error> {
131 let metadata = cargo_metadata::load(root)?;
132 let tidy_rules = tidy_rules::resolved(root)?;
133 let mcp_tools = read_json::<Vec<McpTool>>(&options.mcp_catalog)?;
134 let manifests = read_manifests(root)?;
135 let repository = metadata
136 .workspace_repository()
137 .ok_or(ErrorKind::MissingRepository)?;
138 let catalogs = catalog::build(catalog::Sources {
139 metadata: &metadata,
140 docs: &options.docs,
141 tidy_rules,
142 manifests,
143 mcp_tools,
144 repository,
145 revision: &options.revision,
146 });
147 let reference_path = options.docs.join("wowlab_engine/index.html");
148 let reference_html = read_text(&reference_path)?;
149 let rendered = render::pages(&reference_html, &catalogs.pages)?;
150
151 for (page, html) in catalogs.pages.iter().zip(rendered) {
152 let path = options.docs.join(page.file);
153
154 GeneratedTextFile::new(&path, &html).persist()?;
155 }
156
157 Ok(Summary {
158 pages: catalogs.pages.len(),
159 packages: catalogs.package_count,
160 tidy_rules: catalogs.tidy_rule_count,
161 manifests: catalogs.manifest_count,
162 mcp_tools: catalogs.mcp_tool_count,
163 })
164}
165
166fn read_manifests(root: &Path) -> Result<Vec<EngineManifest>, Error> {
167 let directory = root.join("crates/engine/manifests");
168 let files = walk::repository_files(&directory)?;
169 let mut manifests = files
170 .into_iter()
171 .filter(|path| path.file_name().is_some_and(|name| name == "manifest.toml"))
172 .map(|path| read_manifest(root, &path))
173 .collect::<Result<Vec<_>, _>>()?;
174
175 manifests.sort_by(|left, right| {
176 (&left.class_name, &left.spec_name).cmp(&(&right.class_name, &right.spec_name))
177 });
178
179 Ok(manifests)
180}
181
182fn read_manifest(root: &Path, path: &Path) -> Result<EngineManifest, Error> {
183 let source = read_text(path)?;
184 let manifest =
185 toml::from_str::<catalog::ManifestSource>(&source).map_err(|source| ErrorKind::Toml {
186 path: path.to_path_buf(),
187 source,
188 })?;
189 let spec_directory = path
190 .parent()
191 .ok_or_else(|| ErrorKind::InvalidManifestPath {
192 path: path.to_path_buf(),
193 })?;
194 let class_directory =
195 spec_directory
196 .parent()
197 .ok_or_else(|| ErrorKind::InvalidManifestPath {
198 path: path.to_path_buf(),
199 })?;
200 let spec_name = file_name(spec_directory, path)?;
201 let class_name = file_name(class_directory, path)?;
202 let relative_path = path
203 .strip_prefix(root)
204 .unwrap_or(path)
205 .to_string_lossy()
206 .replace('\\', "/");
207
208 Ok(EngineManifest {
209 class_name,
210 spec_name,
211 id: manifest.spec.id,
212 part_count: manifest.parts.len(),
213 spell_count: manifest.spells.len(),
214 aura_count: manifest.auras.len(),
215 auto_attack_count: manifest.auto_attacks.len(),
216 path: relative_path,
217 })
218}
219
220fn file_name(directory: &Path, manifest_path: &Path) -> Result<String, Error> {
221 Ok(directory
222 .file_name()
223 .map(|name| name.to_string_lossy().into_owned())
224 .ok_or_else(|| ErrorKind::InvalidManifestPath {
225 path: manifest_path.to_path_buf(),
226 })?)
227}
228
229fn read_json<T>(path: &Path) -> Result<T, Error>
230where
231 T: serde::de::DeserializeOwned,
232{
233 let source = read_text(path)?;
234
235 Ok(
236 serde_json::from_str(&source).map_err(|source| ErrorKind::Json {
237 path: path.to_path_buf(),
238 source,
239 })?,
240 )
241}
242
243fn read_text(path: &Path) -> Result<String, Error> {
244 Ok(file::read_text(path).map_err(|source| ErrorKind::Read {
245 path: path.to_path_buf(),
246 source,
247 })?)
248}
249
250#[cfg(test)]
251mod tests;