Skip to main content

docgen/
main.rs

1//! Command-line renderer for generator-owned workspace documentation.
2
3#[global_allocator]
4static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
5
6use std::process::ExitCode;
7
8use clap::{Args as ClapArgs, Parser, Subcommand};
9use rayon::prelude::*;
10use wowlab_common::{cli, output};
11use wowlab_docgen_cli::{
12    RenderCtx, WorkspaceFile, WorkspaceIndex,
13    context::collectors::tidy_rules,
14    hosted_rustdoc,
15    infra::{cache::Cache, dep_graph, metadata, prettier, template, walk},
16};
17use wowlab_fs::{
18    artifact::{GeneratedTextFile, Status},
19    checksum::{self, TreeSnapshot},
20    file,
21    path::{Path, PathBuf},
22};
23
24#[derive(Parser)]
25#[command(
26    name = "docgen",
27    about = "Generate markdown docs from .md.in templates"
28)]
29struct Args {
30    /// Only process templates matching these path fragments.
31    #[arg(long, short = 'F')]
32    filter: Vec<Box<str>>,
33
34    /// Preview what would change without writing files.
35    #[arg(long)]
36    dry_run: bool,
37
38    /// Check that all generated docs are up-to-date (exits non-zero if any would change).
39    #[arg(long)]
40    check: bool,
41
42    /// Suppress output except for errors.
43    #[arg(long, short)]
44    quiet: bool,
45
46    #[command(subcommand)]
47    command: Option<Command>,
48}
49
50#[derive(Subcommand)]
51enum Command {
52    /// Generate the hosted Rustdoc homepage and workspace catalogs.
53    Rustdoc(RustdocArgs),
54}
55
56#[derive(ClapArgs)]
57struct RustdocArgs {
58    /// Directory produced by `cargo doc`.
59    #[arg(long)]
60    docs: PathBuf,
61
62    /// JSON export of Sentinel's runtime MCP tool inventory.
63    #[arg(long)]
64    mcp_catalog: PathBuf,
65
66    /// Git revision represented by the generated documentation.
67    #[arg(long)]
68    revision: Option<Box<str>>,
69}
70
71#[derive(Debug, thiserror::Error)]
72enum ProcessError {
73    #[error("{0}")]
74    Template(String),
75    #[error("{0}")]
76    Formatter(String),
77    #[error(transparent)]
78    Artifact(#[from] wowlab_fs::artifact::Error),
79}
80
81#[derive(Debug, thiserror::Error)]
82#[error("workspace discovery failed: {0}")]
83struct WorkspaceLoadError(Box<str>);
84
85#[derive(Debug, thiserror::Error)]
86#[error("{}: failed to read: {source}", path.display())]
87struct WorkspaceFileLoadError {
88    path: PathBuf,
89    #[source]
90    source: wowlab_fs::error::Error,
91}
92
93struct WorkspaceDiscovery {
94    paths: Vec<PathBuf>,
95    snapshot: Option<TreeSnapshot>,
96}
97
98struct ProcessedTemplate<'a> {
99    path: &'a Path,
100    diagnostics: Vec<metadata::Diagnostic>,
101    result: Result<bool, ProcessError>,
102}
103
104fn main() -> ExitCode {
105    let mut args = Args::parse();
106
107    if args.check {
108        args.dry_run = true;
109        args.quiet = true;
110    }
111
112    let app = cli::boot(
113        "docgen",
114        env!("CARGO_PKG_VERSION"),
115        args.quiet,
116        "DOCGEN_ROOT",
117    );
118
119    if let Some(Command::Rustdoc(rustdoc)) = args.command {
120        return generate_hosted_rustdoc(&app.root, rustdoc, app.quiet);
121    }
122
123    let discovery = match discover_workspace(&app.root) {
124        Ok(discovery) => discovery,
125        Err(error) => {
126            output::error(&error.to_string());
127
128            return ExitCode::FAILURE;
129        }
130    };
131    let discovered_templates = template_paths(&app.root, &discovery.paths, &args.filter);
132
133    if discovered_templates.is_empty() {
134        if !app.quiet {
135            output::warning("no .md.in templates found");
136        }
137
138        return ExitCode::SUCCESS;
139    }
140
141    let formatter = match prettier::Formatter::load(&app.root, &discovery.paths) {
142        Ok(formatter) => formatter,
143        Err(error) => {
144            output::error(&error.to_string());
145
146            return ExitCode::FAILURE;
147        }
148    };
149    let cache = Cache::load(
150        &app.root,
151        checksum::current_executable().ok(),
152        formatter.identity(),
153    );
154
155    if cache.is_current(discovery.snapshot.as_ref(), &args.filter, args.check) {
156        return ExitCode::SUCCESS;
157    }
158
159    let workspace = match load_workspace_paths(&app.root, &discovery.paths) {
160        Ok(workspace) => workspace,
161        Err(error) => {
162            output::error(&error.to_string());
163
164            return ExitCode::FAILURE;
165        }
166    };
167    let templates = workspace.templates(&args.filter);
168
169    if !app.quiet {
170        output::detail(&format!("found {} template(s)", templates.len()));
171        output::blank();
172    }
173
174    let results: Vec<_> = templates
175        .par_iter()
176        .map(|tpl_path| {
177            let diagnostics = if args.check {
178                let dir = tpl_path.parent().expect("template must have parent");
179                let rel_dir = dir
180                    .strip_prefix(&app.root)
181                    .map(|p| p.to_string_lossy().to_string())
182                    .unwrap_or_default();
183                let meta = metadata::load(&workspace, dir);
184
185                metadata::validate(meta, &rel_dir)
186            } else {
187                Vec::new()
188            };
189            let result = process_one(tpl_path, &workspace, &formatter, &args, &cache);
190
191            ProcessedTemplate {
192                path: tpl_path,
193                diagnostics,
194                result,
195            }
196        })
197        .collect();
198
199    finish_run(&app.root, &args, app.quiet, &cache, &templates, results)
200}
201
202fn generate_hosted_rustdoc(root: &Path, args: RustdocArgs, quiet: bool) -> ExitCode {
203    let revision = args
204        .revision
205        .or_else(|| std::env::var("GITHUB_SHA").ok().map(String::into_boxed_str))
206        .unwrap_or_else(|| "main".into());
207    let options = hosted_rustdoc::Options {
208        docs: args.docs,
209        mcp_catalog: args.mcp_catalog,
210        revision,
211    };
212
213    match hosted_rustdoc::generate(root, &options) {
214        Ok(summary) => {
215            if !quiet {
216                output::success(&format!(
217                    "generated {} hosted Rustdoc pages ({} crates, {} tidy rules, {} manifests, {} MCP tools)",
218                    summary.pages,
219                    summary.packages,
220                    summary.tidy_rules,
221                    summary.manifests,
222                    summary.mcp_tools,
223                ));
224            }
225
226            ExitCode::SUCCESS
227        }
228        Err(error) => {
229            output::error(&error.to_string());
230
231            ExitCode::FAILURE
232        }
233    }
234}
235
236fn finish_run(
237    root: &Path,
238    args: &Args,
239    quiet: bool,
240    cache: &Cache,
241    templates: &[PathBuf],
242    results: Vec<ProcessedTemplate<'_>>,
243) -> ExitCode {
244    let mut changed = 0;
245    let mut errors = 0;
246    let mut meta_issues = 0;
247    let mut changed_files: Vec<Box<str>> = Vec::new();
248
249    for processed in results {
250        for diagnostic in &processed.diagnostics {
251            output::error(diagnostic.message());
252        }
253
254        meta_issues += processed.diagnostics.len();
255
256        match processed.result {
257            Ok(true) => {
258                changed += 1;
259
260                if args.check {
261                    let out = walk::output_path(processed.path);
262                    let rel = rel_path(&out, root);
263
264                    changed_files.push(rel.into());
265                }
266
267                report_processed(processed.path, root, args, quiet, true);
268            }
269            Ok(false) => report_processed(processed.path, root, args, quiet, false),
270            Err(error) => {
271                errors += 1;
272                output::error(&error.to_string());
273            }
274        }
275    }
276
277    if !quiet {
278        output::blank();
279        let verb = if args.dry_run {
280            "would update"
281        } else {
282            "updated"
283        };
284        let unchanged = templates.len() - changed - errors;
285
286        output::detail(&format!(
287            "{changed} {verb}, {unchanged} unchanged, {errors} error(s)"
288        ));
289    }
290
291    let exit = if args.check && meta_issues > 0 {
292        output::error(&format!(
293            "{meta_issues} metadata issue(s) — add missing name/description fields"
294        ));
295
296        ExitCode::FAILURE
297    } else if errors > 0 {
298        ExitCode::FAILURE
299    } else if args.check && changed > 0 {
300        changed_files.sort();
301
302        for f in &changed_files {
303            output::warning(&format!("out of date: {f}"));
304        }
305
306        output::error(&format!(
307            "{changed} file(s) out of date — run `cargo docgen` to fix"
308        ));
309
310        ExitCode::FAILURE
311    } else {
312        ExitCode::SUCCESS
313    };
314
315    if exit == ExitCode::SUCCESS && (!args.dry_run || changed == 0) {
316        let output_paths = templates
317            .iter()
318            .map(|template| walk::output_path(template))
319            .collect::<Vec<_>>();
320
321        if let Some(snapshot) = workspace_snapshot(root) {
322            cache.record_success(&snapshot, &args.filter, &output_paths, args.check);
323        }
324    }
325
326    cache.persist();
327
328    exit
329}
330
331fn process_one(
332    tpl_path: &Path,
333    workspace: &WorkspaceIndex,
334    prettier: &prettier::Formatter,
335    args: &Args,
336    cache: &Cache,
337) -> Result<bool, ProcessError> {
338    let root = workspace.root();
339    let dir = tpl_path.parent().expect("template must have parent");
340    let rel_dir = dir
341        .strip_prefix(root)
342        .map(|p| p.to_string_lossy().to_string())
343        .unwrap_or_default();
344    let stem = walk::output_stem(tpl_path);
345    let meta = metadata::load(workspace, dir);
346
347    let ctx = RenderCtx {
348        workspace: workspace.clone(),
349        root,
350        dir,
351        rel_dir: &rel_dir,
352        metadata: meta,
353        output_stem: &stem,
354    };
355
356    let mut document = template::prepare(tpl_path, &ctx).map_err(|error| {
357        ProcessError::Template(format!("{}: {error}", rel_path(tpl_path, root)))
358    })?;
359    let rendered = document.contents;
360    let formatted_contents =
361        if let Some(cached_contents) = cache.reuse_formatted(&document.output_path, &rendered) {
362            cached_contents
363        } else {
364            let fresh_contents =
365                prettier
366                    .format(&rendered, &document.output_path)
367                    .map_err(|error| {
368                        ProcessError::Formatter(format!("{}: {error}", rel_path(tpl_path, root)))
369                    })?;
370
371            cache.record_formatted(&document.output_path, &rendered, &fresh_contents);
372
373            fresh_contents
374        };
375
376    document.contents = formatted_contents;
377    let artifact = GeneratedTextFile::new(&document.output_path, &document.contents);
378    let changed = artifact.status()? != Status::Current;
379
380    if changed && !args.dry_run {
381        artifact.persist()?;
382    }
383
384    Ok(changed)
385}
386
387fn report_processed(tpl_path: &Path, root: &Path, args: &Args, quiet: bool, updated: bool) {
388    if quiet {
389        return;
390    }
391
392    let out_path = walk::output_path(tpl_path);
393    let out_rel = rel_path(&out_path, root);
394
395    if updated {
396        if args.dry_run {
397            output::warning(&format!("would update {out_rel}"));
398        } else {
399            output::success(&format!("updated {out_rel}"));
400        }
401    } else {
402        output::detail(&format!("unchanged {out_rel}"));
403    }
404}
405
406fn discover_workspace(root: &Path) -> Result<WorkspaceDiscovery, WorkspaceLoadError> {
407    let mut failures = Vec::new();
408    let (paths, walk_failures) = wowlab_fs::walk::workspace_source_files(root).into_parts();
409
410    failures.extend(
411        walk_failures
412            .into_iter()
413            .map(|error| format!("walk error: {error}")),
414    );
415
416    if !failures.is_empty() {
417        failures.sort_unstable();
418        failures.dedup();
419
420        return Err(WorkspaceLoadError(failures.join("; ").into_boxed_str()));
421    }
422
423    let snapshot = TreeSnapshot::capture(root, &paths).ok();
424
425    Ok(WorkspaceDiscovery { paths, snapshot })
426}
427
428fn load_workspace_paths(
429    root: &Path,
430    paths: &[PathBuf],
431) -> Result<WorkspaceIndex, WorkspaceLoadError> {
432    let mut entries = Vec::new();
433    let mut failures = Vec::new();
434
435    for path in paths {
436        let file = match load_workspace_file(path.clone(), root) {
437            Ok(file) => file,
438            Err(error) => {
439                failures.push(error.to_string());
440                continue;
441            }
442        };
443
444        entries.push(file);
445    }
446
447    if !failures.is_empty() {
448        failures.sort_unstable();
449        failures.dedup();
450
451        return Err(WorkspaceLoadError(failures.join("; ").into_boxed_str()));
452    }
453
454    Ok(WorkspaceIndex::new(root.to_path_buf(), entries)
455        .with_dependency_graph(dep_graph::load(root))
456        .with_tidy_rules(tidy_rules::load(root)))
457}
458
459#[cfg(test)]
460fn load_workspace(root: &Path) -> Result<WorkspaceIndex, WorkspaceLoadError> {
461    let discovery = discover_workspace(root)?;
462
463    load_workspace_paths(root, &discovery.paths)
464}
465
466fn workspace_snapshot(root: &Path) -> Option<TreeSnapshot> {
467    discover_workspace(root).ok()?.snapshot
468}
469
470fn template_paths(root: &Path, paths: &[PathBuf], filters: &[Box<str>]) -> Vec<PathBuf> {
471    paths
472        .iter()
473        .filter(|path| {
474            path.file_name()
475                .is_some_and(|name| name.to_string_lossy().ends_with(".md.in"))
476        })
477        .filter(|path| {
478            filters.is_empty()
479                || path.strip_prefix(root).is_ok_and(|relative| {
480                    let relative = relative.to_string_lossy();
481
482                    filters
483                        .iter()
484                        .any(|filter| relative.contains(filter.as_ref()))
485                })
486        })
487        .cloned()
488        .collect()
489}
490
491fn load_workspace_file(
492    path: PathBuf,
493    root: &Path,
494) -> Result<WorkspaceFile, WorkspaceFileLoadError> {
495    let bytes = file::read_bytes(&path).map_err(|source| WorkspaceFileLoadError {
496        path: path.strip_prefix(root).unwrap_or(&path).to_path_buf(),
497        source,
498    })?;
499    let contents = String::from_utf8(bytes).ok().map(String::into_boxed_str);
500
501    Ok(WorkspaceFile { path, contents })
502}
503
504fn rel_path(path: &Path, root: &Path) -> String {
505    path.strip_prefix(root)
506        .unwrap_or(path)
507        .to_string_lossy()
508        .to_string()
509}
510
511#[cfg(test)]
512mod tests {
513    use googletest::prelude::*;
514    use wowlab_fs::temporary::Directory;
515
516    use super::*;
517
518    #[gtest]
519    fn binary_files_remain_indexed_without_text_contents() -> Result<()> {
520        let directory = Directory::new().or_fail()?;
521        let path = directory.path().join("image.png");
522
523        file::write_bytes(&path, [0xff]).or_fail()?;
524        let workspace = load_workspace(directory.path()).or_fail()?;
525
526        verify_true!(workspace.contains(&path))?;
527
528        verify_eq!(workspace.contents(&path), None)
529    }
530
531    #[gtest]
532    fn workspace_file_read_errors_are_contextual() -> Result<()> {
533        let directory = Directory::new().or_fail()?;
534        let path = directory.path().join("missing.md.in");
535
536        let error = load_workspace_file(path, directory.path())
537            .unwrap_err()
538            .to_string();
539
540        verify_that!(
541            error.as_str(),
542            starts_with("missing.md.in: failed to read:")
543        )
544    }
545
546    #[gtest]
547    fn workspace_load_fails_when_root_cannot_be_walked() -> Result<()> {
548        let directory = Directory::new().or_fail()?;
549        let missing = directory.path().join("missing");
550
551        let error = load_workspace(&missing).unwrap_err().to_string();
552
553        verify_that!(
554            error.as_str(),
555            contains_substring("workspace discovery failed")
556        )?;
557
558        verify_that!(error.as_str(), contains_substring("walk error"))
559    }
560}