Skip to main content

wowlab_docgen_cli/hosted_rustdoc/
mod.rs

1mod 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/// Inputs for hosted Rustdoc catalog generation.
15#[derive(Debug)]
16pub struct Options {
17    /// Directory produced by `cargo doc`.
18    pub docs: PathBuf,
19    /// JSON export of Sentinel's runtime MCP tool inventory.
20    pub mcp_catalog: PathBuf,
21    /// Git revision represented by the generated documentation.
22    pub revision: Box<str>,
23}
24
25/// Counts of canonical records included in the generated pages.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct Summary {
28    /// Number of generated hosted pages.
29    pub pages: usize,
30    /// Number of documented workspace packages.
31    pub packages: usize,
32    /// Number of configured tidy rules.
33    pub tidy_rules: usize,
34    /// Number of specialization manifests.
35    pub manifests: usize,
36    /// Number of registered MCP tools.
37    pub mcp_tools: usize,
38}
39
40/// Hosted Rustdoc generation failure.
41#[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
125/// Generate the hosted Rustdoc homepage and canonical catalog pages.
126///
127/// # Errors
128///
129/// Returns an error when canonical inputs cannot be loaded, rendered, or persisted.
130pub 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;