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}