cargo_docs_md/lib.rs
1//! docs-md library interface for testing and reuse.
2//!
3//! This module exposes the core functionality of docs-md as a library,
4//! allowing integration tests and external tools to use the markdown
5//! generation capabilities programmatically.
6
7#![deny(missing_docs)]
8#![warn(clippy::pedantic)]
9#![warn(clippy::nursery)]
10
11use std::path::PathBuf;
12
13use clap::{Parser, Subcommand, ValueEnum};
14
15pub mod error;
16pub mod generator;
17pub mod linker;
18#[cfg(feature = "trace")]
19pub mod logger;
20pub mod multi_crate;
21pub mod parser;
22#[cfg(feature = "source-parsing")]
23pub mod source;
24pub mod types;
25pub mod utils;
26
27pub use crate::generator::{Generator, MarkdownCapture, RenderConfig, SourceConfig};
28pub use crate::linker::{AnchorUtils, LinkRegistry};
29#[cfg(feature = "trace")]
30use crate::logger::LogLevel;
31pub use crate::multi_crate::{
32 CrateCollection, MultiCrateContext, MultiCrateGenerator, MultiCrateParser, SearchIndex,
33 SearchIndexGenerator, UnifiedLinkRegistry,
34};
35
36/// Output format for the generated markdown documentation.
37///
38/// Controls how module files are organized in the output directory.
39#[derive(Debug, Clone, Copy, Default, ValueEnum)]
40pub enum OutputFormat {
41 /// Flat structure: all files in one directory.
42 ///
43 /// Module hierarchy is encoded in filenames using double underscores.
44 /// Example: `parent__child__grandchild.md`
45 #[default]
46 Flat,
47
48 /// Nested structure: directories mirror module hierarchy.
49 ///
50 /// Each module gets its own directory with an `index.md` file.
51 /// Example: `parent/child/grandchild/index.md`
52 Nested,
53}
54
55/// Cargo wrapper for subcommand invocation.
56///
57/// When invoked as `cargo docs-md`, cargo passes "docs-md" as the first argument.
58/// This wrapper handles that by making `docs-md` a subcommand that contains the real CLI.
59#[derive(Parser, Debug)]
60#[command(name = "cargo", bin_name = "cargo")]
61pub enum Cargo {
62 /// Generate per-module markdown from rustdoc JSON
63 #[command(name = "docs-md")]
64 DocsMd(Cli),
65}
66
67/// Top-level CLI for docs-md.
68#[derive(Parser, Debug)]
69#[command(
70 author,
71 version,
72 about = "Generate per-module markdown from rustdoc JSON",
73 args_conflicts_with_subcommands = true
74)]
75pub struct Cli {
76 #[command(subcommand)]
77 /// Subcommand to run
78 pub command: Option<Command>,
79
80 #[command(flatten)]
81 /// Generation options (used when no subcommand is specified)
82 pub args: GenerateArgs,
83
84 /// Logging verbosity level
85 ///
86 /// Controls the amount of diagnostic output. Use for debugging link
87 /// resolution issues or understanding the generation process.
88 #[cfg(feature = "trace")]
89 #[arg(long, value_enum, default_value = "off")]
90 pub log_level: LogLevel,
91
92 /// Enable logging to a file instead of stderr
93 ///
94 /// When set, logs are written to this file path instead of stderr.
95 /// Useful for capturing debug output without cluttering terminal.
96 #[cfg(feature = "trace")]
97 #[arg(long)]
98 pub log_file: Option<PathBuf>,
99}
100
101/// Available subcommands
102#[derive(Subcommand, Debug)]
103pub enum Command {
104 /// Build rustdoc JSON and generate markdown in one step.
105 ///
106 /// This runs `cargo +nightly doc` with JSON output, then generates
107 /// markdown documentation from the result. Requires nightly toolchain.
108 ///
109 /// Example: `cargo docs-md docs --primary-crate my_crate`
110 Docs(DocsArgs),
111
112 /// Collect dependency sources to a local directory.
113 ///
114 /// Copies source code from `~/.cargo/registry/src/` into a local
115 /// `.source_{timestamp}/` directory for parsing and documentation.
116 ///
117 /// Example: `cargo docs-md collect-sources --include-dev`
118 #[cfg(feature = "source-parsing")]
119 CollectSources(CollectSourcesArgs),
120}
121
122/// Arguments for the `docs` subcommand (build + generate).
123#[derive(Parser, Debug)]
124#[expect(
125 clippy::struct_excessive_bools,
126 reason = "Hm.. Cache lining optimization? Seems unnecessary for a CLI args struct."
127)]
128pub struct DocsArgs {
129 /// Output directory for generated markdown files.
130 ///
131 /// Defaults to `generated_docs/` in the current directory.
132 #[arg(short, long, default_value = "generated_docs")]
133 pub output: PathBuf,
134
135 /// Output format (flat or nested).
136 #[arg(short, long, value_enum, default_value_t = CliOutputFormat::Nested)]
137 pub format: CliOutputFormat,
138
139 /// Primary crate name for preferential link resolution.
140 ///
141 /// If not specified, attempts to detect from Cargo.toml.
142 #[arg(long)]
143 pub primary_crate: Option<String>,
144
145 /// Exclude private (non-public) items from the output.
146 ///
147 /// By default, all items are documented including private ones.
148 /// Enable this to only include public items.
149 #[arg(long, default_value_t = false)]
150 pub exclude_private: bool,
151
152 /// Include blanket trait implementations in the output.
153 #[arg(long, default_value_t = false)]
154 pub include_blanket_impls: bool,
155
156 /// Skip generating mdBook SUMMARY.md file.
157 #[arg(long, default_value_t = false)]
158 pub no_mdbook: bool,
159
160 /// Skip generating `search_index.json` file.
161 #[arg(long, default_value_t = false)]
162 pub no_search_index: bool,
163
164 /// Run cargo clean before building (full rebuild).
165 #[arg(long, default_value_t = false)]
166 pub clean: bool,
167
168 // === RenderConfig toggles ===
169 /// Minimum number of items before generating a table of contents.
170 ///
171 /// Modules with fewer items than this threshold won't have a TOC.
172 /// Default: 10
173 #[arg(long, default_value_t = 10)]
174 pub toc_threshold: usize,
175
176 /// Disable quick reference tables at the top of modules.
177 #[arg(long, default_value_t = false)]
178 pub no_quick_reference: bool,
179
180 /// Disable grouping impl blocks by category (Derive, Conversion, etc.).
181 #[arg(long, default_value_t = false)]
182 pub no_group_impls: bool,
183
184 /// Hide trivial derive implementations (Clone, Copy, Debug, etc.).
185 #[arg(long, default_value_t = false)]
186 pub hide_trivial_derives: bool,
187
188 /// Disable method-level anchors for deep linking.
189 #[arg(long, default_value_t = false)]
190 pub no_method_anchors: bool,
191
192 /// Include source file locations for items.
193 #[arg(long, default_value_t = false)]
194 pub source_locations: bool,
195
196 /// Include full method documentation instead of first-line summaries.
197 ///
198 /// By default, only the first paragraph of method docs is shown in impl blocks.
199 /// Enable this to include the complete documentation for each method.
200 #[arg(long, default_value_t = false)]
201 pub full_method_docs: bool,
202
203 /// Additional arguments to pass to cargo doc.
204 ///
205 /// Example: `docs-md docs -- --all-features`
206 #[arg(last = true)]
207 pub cargo_args: Vec<String>,
208}
209
210/// Arguments for the `collect-sources` subcommand.
211#[cfg(feature = "source-parsing")]
212#[derive(Parser, Debug)]
213pub struct CollectSourcesArgs {
214 /// Output directory for collected sources.
215 ///
216 /// If not specified, creates `.source_{timestamp}/` in the workspace root.
217 #[arg(short, long)]
218 pub output: Option<PathBuf>,
219
220 /// Include dev-dependencies in collection.
221 ///
222 /// By default, only regular dependencies are collected.
223 #[arg(long, default_value_t = false)]
224 pub include_dev: bool,
225
226 /// Dry run - show what would be collected without copying.
227 #[arg(long, default_value_t = false)]
228 pub dry_run: bool,
229
230 /// Path to Cargo.toml (defaults to current directory).
231 #[arg(long)]
232 pub manifest_path: Option<PathBuf>,
233
234 /// Only copy `src/` directory and `Cargo.toml`.
235 ///
236 /// By default, the entire crate directory is copied to ensure all source
237 /// files are available (including `build.rs`, modules outside `src/`, etc.).
238 /// Enable this flag to minimize disk usage at the cost of potentially
239 /// missing some source files.
240 #[arg(long, default_value_t = false)]
241 pub minimal_sources: bool,
242
243 /// Do not add `.source_*` pattern to `.gitignore`.
244 ///
245 /// By default, the tool appends `.source_*` to `.gitignore` to prevent
246 /// accidentally committing collected source files. Enable this flag to
247 /// skip this modification (useful for projects with strict `.gitignore`
248 /// management).
249 #[arg(long, default_value_t = false)]
250 pub no_gitignore: bool,
251}
252
253/// Command-line arguments for direct generation (no subcommand).
254///
255/// The tool accepts input from two mutually exclusive sources:
256/// 1. A local rustdoc JSON file (`--path`)
257/// 2. A directory of rustdoc JSON files (`--dir`)
258#[derive(Parser, Debug, Default)]
259#[expect(
260 clippy::struct_excessive_bools,
261 reason = "Hm.. Cache lining optimization? Seems unnecessary for a CLI args struct."
262)]
263pub struct GenerateArgs {
264 /// Path to a local rustdoc JSON file.
265 ///
266 /// Generate this file with: `cargo doc --output-format json`
267 /// The JSON file will be in `target/doc/{crate_name}.json`
268 ///
269 /// Mutually exclusive with `--dir`.
270 #[arg(
271 short,
272 long,
273 required_unless_present_any = ["dir"],
274 conflicts_with = "dir"
275 )]
276 pub path: Option<PathBuf>,
277
278 /// Directory containing multiple rustdoc JSON files.
279 ///
280 /// Use this for multi-crate documentation generation. The tool will
281 /// scan the directory for all `*.json` files (rustdoc format) and
282 /// generate documentation for each crate with cross-crate linking.
283 ///
284 /// Generate JSON files with:
285 /// `RUSTDOCFLAGS='-Z unstable-options --output-format json' cargo +nightly doc`
286 ///
287 /// Mutually exclusive with `--path`.
288 #[arg(
289 long,
290 required_unless_present_any = ["path"],
291 conflicts_with = "path"
292 )]
293 pub dir: Option<PathBuf>,
294
295 /// Skip generating mdBook SUMMARY.md file.
296 ///
297 /// Only valid with `--dir` for multi-crate documentation.
298 /// By default, a `SUMMARY.md` file is created in the output directory
299 /// that can be used as the entry point for an mdBook documentation site.
300 #[arg(long, requires = "dir", default_value_t = false)]
301 pub no_mdbook: bool,
302
303 /// Skip generating `search_index.json` file.
304 ///
305 /// Only valid with `--dir` for multi-crate documentation.
306 /// By default, a `search_index.json` file is created containing all
307 /// documented items, which can be used with client-side search libraries
308 /// like Fuse.js, Lunr.js, or `FlexSearch`.
309 #[arg(long, requires = "dir", default_value_t = false)]
310 pub no_search_index: bool,
311
312 /// Primary crate name for preferential link resolution.
313 ///
314 /// When specified with `--dir`, links to items in this crate take
315 /// precedence over items with the same name in dependencies.
316 /// This helps resolve ambiguous links like `exit` to the intended
317 /// crate rather than `std::process::exit`.
318 #[arg(long, requires = "dir")]
319 pub primary_crate: Option<String>,
320
321 /// Output directory for generated markdown files.
322 ///
323 /// The directory will be created if it doesn't exist.
324 /// Defaults to `generated_docs/` in the current directory.
325 #[arg(short, long, default_value = "generated_docs")]
326 pub output: PathBuf,
327
328 /// Output format (flat or nested).
329 ///
330 /// - `flat`: All files in one directory
331 /// - `nested`: Directory hierarchy mirroring modules (default)
332 #[arg(short, long, value_enum, default_value_t = CliOutputFormat::Nested)]
333 pub format: CliOutputFormat,
334
335 /// Exclude private (non-public) items from the output.
336 ///
337 /// By default, all items are documented including `pub(crate)`,
338 /// `pub(super)`, and private items. Enable this to only include
339 /// public items.
340 #[arg(long, default_value_t = false)]
341 pub exclude_private: bool,
342
343 /// Include blanket trait implementations in the output.
344 ///
345 /// By default, blanket impls like `From`, `Into`, `TryFrom`, `TryInto`,
346 /// `Any`, `Borrow`, `BorrowMut`, and `ToOwned` are filtered out to reduce
347 /// noise. Enable this to include them in the documentation.
348 #[arg(long, default_value_t = false)]
349 pub include_blanket_impls: bool,
350
351 // === RenderConfig toggles ===
352 /// Minimum number of items before generating a table of contents.
353 ///
354 /// Modules with fewer items than this threshold won't have a TOC.
355 /// Default: 10
356 #[arg(long, default_value_t = 10)]
357 pub toc_threshold: usize,
358
359 /// Disable quick reference tables at the top of modules.
360 #[arg(long, default_value_t = false)]
361 pub no_quick_reference: bool,
362
363 /// Disable grouping impl blocks by category (Derive, Conversion, etc.).
364 #[arg(long, default_value_t = false)]
365 pub no_group_impls: bool,
366
367 /// Hide trivial derive implementations (Clone, Copy, Debug, etc.).
368 #[arg(long, default_value_t = false)]
369 pub hide_trivial_derives: bool,
370
371 /// Disable method-level anchors for deep linking.
372 #[arg(long, default_value_t = false)]
373 pub no_method_anchors: bool,
374
375 /// Include source file locations for items.
376 #[arg(long, default_value_t = false)]
377 pub source_locations: bool,
378
379 /// Include full method documentation instead of first-line summaries.
380 ///
381 /// By default, only the first paragraph of method docs is shown in impl blocks.
382 /// Enable this to include the complete documentation for each method.
383 #[arg(long, default_value_t = false)]
384 pub full_method_docs: bool,
385}
386
387/// Backwards-compatible type alias for existing code.
388pub type Args = GenerateArgs;
389
390/// CLI-compatible output format enum (for clap `ValueEnum` derive).
391#[derive(Clone, Copy, Debug, Default, ValueEnum)]
392pub enum CliOutputFormat {
393 /// Flat structure with double-underscore separators in filenames.
394 #[default]
395 Flat,
396
397 /// Nested directory structure mirroring the module hierarchy.
398 Nested,
399}
400
401impl From<CliOutputFormat> for OutputFormat {
402 fn from(cli: CliOutputFormat) -> Self {
403 match cli {
404 CliOutputFormat::Flat => Self::Flat,
405
406 CliOutputFormat::Nested => Self::Nested,
407 }
408 }
409}
410
411// Bounds check test functions for assembly inspection
412// Run: cargo asm cargo_docs_md::iter_zip
413
414/// Test function: iterator zip (no bounds checks in loop).
415#[inline(never)]
416#[must_use]
417pub fn iter_zip(a: Vec<i64>, b: Vec<i64>) -> i64 {
418 let mut r = 0i64;
419 assert!(a.len() == b.len());
420 for (x, y) in a.iter().zip(b.iter()) {
421 r += x + y;
422 }
423 r
424}
425
426/// Test function: index loop with assert (bounds check elided).
427#[inline(never)]
428#[must_use]
429pub fn index_loop(a: Vec<i64>, b: Vec<i64>) -> i64 {
430 let mut r = 0i64;
431 assert!(a.len() == b.len());
432 for i in 0..a.len() {
433 r += a[i] + b[i];
434 }
435 r
436}
437
438/// Test function: index loop without assert (bounds check present).
439#[inline(never)]
440#[must_use]
441pub fn index_loop_no_assert(a: Vec<i64>, b: Vec<i64>) -> i64 {
442 let mut r = 0i64;
443 for i in 0..a.len() {
444 r += a[i] + b[i];
445 }
446 r
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use clap::Parser;
453
454 /// Test that GenerateArgs correctly parses `--no-mdbook` flag.
455 #[test]
456 fn test_generate_args_no_mdbook_flag() {
457 // Without flag: no_mdbook should be false
458 let args = GenerateArgs::try_parse_from(["test", "--dir", "target/doc"]).unwrap();
459 assert!(!args.no_mdbook, "no_mdbook should default to false");
460
461 // With flag: no_mdbook should be true
462 let args =
463 GenerateArgs::try_parse_from(["test", "--dir", "target/doc", "--no-mdbook"]).unwrap();
464 assert!(args.no_mdbook, "no_mdbook should be true when flag is set");
465 }
466
467 /// Test that GenerateArgs correctly parses `--no-search-index` flag.
468 #[test]
469 fn test_generate_args_no_search_index_flag() {
470 // Without flag: no_search_index should be false
471 let args = GenerateArgs::try_parse_from(["test", "--dir", "target/doc"]).unwrap();
472 assert!(
473 !args.no_search_index,
474 "no_search_index should default to false"
475 );
476
477 // With flag: no_search_index should be true
478 let args =
479 GenerateArgs::try_parse_from(["test", "--dir", "target/doc", "--no-search-index"])
480 .unwrap();
481 assert!(
482 args.no_search_index,
483 "no_search_index should be true when flag is set"
484 );
485 }
486
487 /// Test that DocsArgs correctly parses `--no-mdbook` flag.
488 #[test]
489 fn test_docs_args_no_mdbook_flag() {
490 // Without flag: no_mdbook should be false (mdbook enabled by default)
491 let args = DocsArgs::try_parse_from(["test"]).unwrap();
492 assert!(
493 !args.no_mdbook,
494 "no_mdbook should default to false (mdbook enabled)"
495 );
496
497 // With flag: no_mdbook should be true
498 let args = DocsArgs::try_parse_from(["test", "--no-mdbook"]).unwrap();
499 assert!(args.no_mdbook, "no_mdbook should be true when flag is set");
500 }
501
502 /// Test that DocsArgs correctly parses `--no-search-index` flag.
503 #[test]
504 fn test_docs_args_no_search_index_flag() {
505 // Without flag: no_search_index should be false (search index enabled by default)
506 let args = DocsArgs::try_parse_from(["test"]).unwrap();
507 assert!(
508 !args.no_search_index,
509 "no_search_index should default to false (search index enabled)"
510 );
511
512 // With flag: no_search_index should be true
513 let args = DocsArgs::try_parse_from(["test", "--no-search-index"]).unwrap();
514 assert!(
515 args.no_search_index,
516 "no_search_index should be true when flag is set"
517 );
518 }
519
520 /// Test that DocsArgs and GenerateArgs have consistent defaults for mdbook/search behavior.
521 #[test]
522 fn test_docs_and_generate_args_consistent_defaults() {
523 let docs_args = DocsArgs::try_parse_from(["test"]).unwrap();
524 let generate_args = GenerateArgs::try_parse_from(["test", "--dir", "target/doc"]).unwrap();
525
526 // Both should have the same default behavior: features enabled (no_* = false)
527 assert_eq!(
528 docs_args.no_mdbook, generate_args.no_mdbook,
529 "DocsArgs and GenerateArgs should have same default for no_mdbook"
530 );
531 assert_eq!(
532 docs_args.no_search_index, generate_args.no_search_index,
533 "DocsArgs and GenerateArgs should have same default for no_search_index"
534 );
535 }
536
537 /// Test that CollectSourcesArgs correctly parses `--minimal-sources` flag.
538 #[cfg(feature = "source-parsing")]
539 #[test]
540 fn test_collect_sources_args_minimal_sources_flag() {
541 // Without flag: minimal_sources should be false (full copy)
542 let args = CollectSourcesArgs::try_parse_from(["test"]).unwrap();
543 assert!(
544 !args.minimal_sources,
545 "minimal_sources should default to false (full copy)"
546 );
547
548 // With flag: minimal_sources should be true
549 let args = CollectSourcesArgs::try_parse_from(["test", "--minimal-sources"]).unwrap();
550 assert!(
551 args.minimal_sources,
552 "minimal_sources should be true when flag is set"
553 );
554 }
555
556 /// Test that CollectSourcesArgs correctly parses `--no-gitignore` flag.
557 #[cfg(feature = "source-parsing")]
558 #[test]
559 fn test_collect_sources_args_no_gitignore_flag() {
560 // Without flag: no_gitignore should be false (update gitignore)
561 let args = CollectSourcesArgs::try_parse_from(["test"]).unwrap();
562 assert!(
563 !args.no_gitignore,
564 "no_gitignore should default to false (update gitignore)"
565 );
566
567 // With flag: no_gitignore should be true
568 let args = CollectSourcesArgs::try_parse_from(["test", "--no-gitignore"]).unwrap();
569 assert!(
570 args.no_gitignore,
571 "no_gitignore should be true when flag is set"
572 );
573 }
574
575 /// Test that GenerateArgs requires either --path or --dir.
576 #[test]
577 fn test_generate_args_requires_path_or_dir() {
578 // Neither provided - should fail
579 let result = GenerateArgs::try_parse_from(["test"]);
580 assert!(
581 result.is_err(),
582 "Should require either --path or --dir"
583 );
584
585 // Only --path provided - should succeed
586 let result = GenerateArgs::try_parse_from(["test", "--path", "file.json"]);
587 assert!(result.is_ok(), "Should accept --path alone");
588
589 // Only --dir provided - should succeed
590 let result = GenerateArgs::try_parse_from(["test", "--dir", "target/doc"]);
591 assert!(result.is_ok(), "Should accept --dir alone");
592
593 // Both provided - should fail (mutually exclusive)
594 let result =
595 GenerateArgs::try_parse_from(["test", "--path", "file.json", "--dir", "target/doc"]);
596 assert!(
597 result.is_err(),
598 "--path and --dir should be mutually exclusive"
599 );
600 }
601
602 /// Test that mdbook/search-index flags work correctly with --dir.
603 #[test]
604 fn test_mdbook_search_index_with_dir() {
605 // With --dir, both flags should work
606 let result = GenerateArgs::try_parse_from([
607 "test",
608 "--dir",
609 "target/doc",
610 "--no-mdbook",
611 "--no-search-index",
612 ]);
613 assert!(
614 result.is_ok(),
615 "--no-mdbook and --no-search-index should work with --dir"
616 );
617
618 // Verify the flags are correctly parsed
619 let args = result.unwrap();
620 assert!(args.no_mdbook, "--no-mdbook should be true");
621 assert!(args.no_search_index, "--no-search-index should be true");
622 }
623
624 /// Test GenerateArgs default values for common options.
625 #[test]
626 fn test_generate_args_defaults() {
627 let args = GenerateArgs::try_parse_from(["test", "--dir", "target/doc"]).unwrap();
628
629 assert_eq!(
630 args.output,
631 PathBuf::from("generated_docs"),
632 "output should default to generated_docs"
633 );
634 assert!(
635 matches!(args.format, CliOutputFormat::Nested),
636 "format should default to Nested"
637 );
638 assert!(!args.exclude_private, "exclude_private should default to false");
639 assert!(
640 !args.include_blanket_impls,
641 "include_blanket_impls should default to false"
642 );
643 assert_eq!(args.toc_threshold, 10, "toc_threshold should default to 10");
644 assert!(
645 !args.no_quick_reference,
646 "no_quick_reference should default to false"
647 );
648 assert!(!args.no_group_impls, "no_group_impls should default to false");
649 assert!(
650 !args.hide_trivial_derives,
651 "hide_trivial_derives should default to false"
652 );
653 assert!(
654 !args.no_method_anchors,
655 "no_method_anchors should default to false"
656 );
657 assert!(
658 !args.source_locations,
659 "source_locations should default to false"
660 );
661 assert!(
662 !args.full_method_docs,
663 "full_method_docs should default to false"
664 );
665 }
666}