1#[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 #[arg(long, short = 'F')]
32 filter: Vec<Box<str>>,
33
34 #[arg(long)]
36 dry_run: bool,
37
38 #[arg(long)]
40 check: bool,
41
42 #[arg(long, short)]
44 quiet: bool,
45
46 #[command(subcommand)]
47 command: Option<Command>,
48}
49
50#[derive(Subcommand)]
51enum Command {
52 Rustdoc(RustdocArgs),
54}
55
56#[derive(ClapArgs)]
57struct RustdocArgs {
58 #[arg(long)]
60 docs: PathBuf,
61
62 #[arg(long)]
64 mcp_catalog: PathBuf,
65
66 #[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}