Skip to main content

aidoc_core/
lib.rs

1//! # aidoc-core
2//!
3//! Core library that turns rustdoc JSON output into LLM-facing
4//! documentation artifacts. This crate holds all of the logic shared by
5//! the two thin front ends in this workspace: the `cargo aidoc` subcommand
6//! (`crates/cargo-aidoc`) and the MCP server (`crates/aidoc-mcp`).
7//!
8//! ## Architecture
9//!
10//! The pipeline is split into three stages:
11//!
12//! 1. **Index** — read rustdoc's JSON output (produced via
13//!    `cargo +nightly rustdoc -- -Z unstable-options --output-format json`)
14//!    and load it into an in-memory representation of the crate's public
15//!    API surface.
16//! 2. **Generate** — project the index into one or more LLM-facing
17//!    artifacts: an `llms.txt` summary, per-crate / per-module narrative
18//!    markdown, `llms-full.txt`, and a deterministic `api/<crate>.json`
19//!    for machine consumption (diffing, CI checks).
20//! 3. **Lint** — check the index against a set of doc-coverage rules
21//!    (crate root has a narrative, public modules are documented, the
22//!    generated `llms-full.txt` fits a soft size cap) and report
23//!    violations.
24//!
25//! Both `cargo-aidoc` (CLI) and `aidoc-mcp` (MCP server) are thin front
26//! ends over this pipeline; neither crate should contain pipeline logic
27//! of its own. The single entry point for both is [`run`], which returns
28//! a [`Report`] of generated artifacts and lint diagnostics.
29
30#![warn(missing_docs)]
31
32use std::path::{Path, PathBuf};
33
34pub mod config;
35pub mod error;
36pub mod error_catalog;
37pub mod generate;
38pub mod index;
39pub mod lint;
40pub mod platform;
41mod rustdoc;
42
43pub use config::{Config, Platform, Preset, UnknownPlatform};
44pub use error::{Error, Result};
45pub use error_catalog::{ErrorEntry, Snippet};
46pub use generate::{Artifact, ArtifactLocation};
47pub use index::{IndexedCrate, IndexedWorkspace};
48pub use lint::{Diagnostic, Level};
49
50// `DiffSummary` and `classify_diffs` are defined below in this file;
51// re-export them for consumers that stick to `aidoc_core::` paths.
52
53/// The output of a single pipeline run: everything a front end needs to
54/// either write artifacts to disk (`cargo aidoc`) or compare them to a
55/// checked-in tree (`cargo aidoc --check`).
56///
57/// Ordering: `artifacts` matches the emission order of
58/// [`generate::render_all`]; `diagnostics` matches the emission order of
59/// [`lint::lint`].
60#[derive(Debug, Clone)]
61pub struct Report {
62    /// Generated files, keyed by relative path.
63    pub artifacts: Vec<Artifact>,
64    /// Lint findings. Empty on a fully clean run.
65    pub diagnostics: Vec<Diagnostic>,
66}
67
68impl Report {
69    /// True if any diagnostic has [`Level::Error`]. Callers use this to
70    /// pick between exit code 0 (clean) and 2 (lint violation).
71    pub fn has_errors(&self) -> bool {
72        self.diagnostics
73            .iter()
74            .any(|d| matches!(d.level, Level::Error))
75    }
76}
77
78/// Run the full index → generate → lint pipeline against the workspace
79/// rooted at `workspace_root`, using `config` for defaults, exclusions
80/// and strict-mode toggling.
81///
82/// Failures during the generate stage are wrapped in [`Error::Generate`]
83/// so callers still receive a short summary of what the index stage did
84/// before the failure. There is no rollback and no partial output.
85pub fn run(workspace_root: &Path, config: &Config) -> Result<Report> {
86    let workspace = IndexedWorkspace::build(workspace_root, config)?;
87    let index_summary = format!("indexed {} crate(s)", workspace.crates.len());
88
89    let mut artifacts =
90        generate::render_all(&workspace, None).map_err(|source| Error::Generate {
91            source: Box::new(source),
92            index_summary: index_summary.clone(),
93        })?;
94
95    if config.emit_error_catalog {
96        let entries = error_catalog::extract(&workspace);
97        generate::render_error_catalog(&entries, &mut artifacts).map_err(|source| {
98            Error::Generate {
99                source: Box::new(source),
100                index_summary,
101            }
102        })?;
103    }
104
105    platform::apply_overlays(&workspace, &mut artifacts, &config.platforms)?;
106
107    let diagnostics = lint::lint(&workspace, &artifacts, config);
108
109    Ok(Report {
110        artifacts,
111        diagnostics,
112    })
113}
114
115/// Resolve where a single artifact lands, given the two possible base
116/// directories a caller might have configured.
117fn resolve_path(artifact: &Artifact, out_dir: &Path, workspace_root: &Path) -> PathBuf {
118    let base = match artifact.location {
119        ArtifactLocation::OutDir => out_dir,
120        ArtifactLocation::WorkspaceRoot => workspace_root,
121    };
122    base.join(&artifact.path)
123}
124
125/// Write every artifact in `report` to disk. `out_dir` is the base for
126/// artifacts whose location is [`ArtifactLocation::OutDir`]; the
127/// workspace root is the base for
128/// [`ArtifactLocation::WorkspaceRoot`] artifacts (typically platform
129/// manifests such as `context7.json`).
130///
131/// Existing files are overwritten; parent directories are created as
132/// needed.
133pub fn write_report(report: &Report, out_dir: &Path, workspace_root: &Path) -> Result<()> {
134    for artifact in &report.artifacts {
135        let path = resolve_path(artifact, out_dir, workspace_root);
136        if let Some(parent) = path.parent() {
137            std::fs::create_dir_all(parent)?;
138        }
139        std::fs::write(&path, artifact.body.as_bytes())?;
140    }
141    Ok(())
142}
143
144/// Detailed classification of every diff in a check-mode run.
145///
146/// `missing` collects the artifact paths that don't exist on disk yet
147/// (the caller would create them on the next `aidoc_gen`). `modified`
148/// collects paths whose on-disk copy differs from the freshly
149/// generated body. Paths that match on disk contribute to neither
150/// list.
151///
152/// This is the shape returned by [`classify_diffs`]; both `cargo aidoc
153/// --check` and the MCP `aidoc_check` tool use it to produce a
154/// consistent, actionable summary message via
155/// [`DiffSummary::summary_message`].
156#[derive(Debug, Clone, Default)]
157pub struct DiffSummary {
158    /// Artifact paths (base-relative) that don't exist on disk.
159    pub missing: Vec<String>,
160    /// Artifact paths (base-relative) whose on-disk bytes differ.
161    pub modified: Vec<String>,
162}
163
164impl DiffSummary {
165    /// True when nothing would change on disk.
166    pub fn is_empty(&self) -> bool {
167        self.missing.is_empty() && self.modified.is_empty()
168    }
169
170    /// Total number of artifacts that would change (missing + modified).
171    pub fn total(&self) -> usize {
172        self.missing.len() + self.modified.len()
173    }
174
175    /// Iterate every diff path in a stable order (all missing first,
176    /// then all modified), matching the order they were discovered.
177    pub fn paths(&self) -> impl Iterator<Item = &String> {
178        self.missing.iter().chain(self.modified.iter())
179    }
180
181    /// Actionable, human-readable summary of the check result. Callers
182    /// pass the total artifact count so the clean-state message can
183    /// state how many artifacts were compared.
184    ///
185    /// The four branches:
186    ///
187    /// 1. Clean — `"aidoc: check clean (N artifact(s))"`
188    /// 2. Only missing — `"aidoc: N artifact(s) not on disk yet — run
189    ///    aidoc_gen to write them"`. Triggers for both full uninit and
190    ///    partial uninit (e.g. `docs/aidoc/` exists but the error
191    ///    catalog subset was never written).
192    /// 3. Only modified — `"aidoc: N artifact(s) content changed —
193    ///    run aidoc_gen to update"`
194    /// 4. Mixed — `"aidoc: N missing + M modified — run aidoc_gen"`
195    pub fn summary_message(&self, total_artifacts: usize) -> String {
196        if self.is_empty() {
197            format!("aidoc: check clean ({total_artifacts} artifact(s))")
198        } else if self.modified.is_empty() {
199            format!(
200                "aidoc: {} artifact(s) not on disk yet — run aidoc_gen to write them",
201                self.missing.len()
202            )
203        } else if self.missing.is_empty() {
204            format!(
205                "aidoc: {} artifact(s) content changed — run aidoc_gen to update",
206                self.modified.len()
207            )
208        } else {
209            format!(
210                "aidoc: {} missing + {} modified — run aidoc_gen",
211                self.missing.len(),
212                self.modified.len()
213            )
214        }
215    }
216}
217
218/// Compare every artifact in `report` against its on-disk copy and
219/// return the categorised diff. See [`write_report`] for how `out_dir`
220/// and `workspace_root` are interpreted.
221///
222/// This is the check-mode counterpart of [`write_report`]: nothing is
223/// written; the caller decides how to react to the returned
224/// [`DiffSummary`]. Read failures other than "file not found"
225/// propagate as errors so no diff is ever silently dropped.
226pub fn classify_diffs(
227    report: &Report,
228    out_dir: &Path,
229    workspace_root: &Path,
230) -> Result<DiffSummary> {
231    let mut summary = DiffSummary::default();
232    for artifact in &report.artifacts {
233        let path = resolve_path(artifact, out_dir, workspace_root);
234        match std::fs::read(&path) {
235            Ok(bytes) => {
236                if bytes != artifact.body.as_bytes() {
237                    summary.modified.push(artifact.path.clone());
238                }
239            }
240            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
241                summary.missing.push(artifact.path.clone());
242            }
243            Err(err) => return Err(err.into()),
244        }
245    }
246    Ok(summary)
247}
248
249/// Backwards-compatible wrapper around [`classify_diffs`]. Returns the
250/// same flat list of paths that earlier versions of aidoc-core
251/// produced; new callers should prefer [`classify_diffs`] so they can
252/// distinguish missing from modified artifacts.
253pub fn diff_report(report: &Report, out_dir: &Path, workspace_root: &Path) -> Result<Vec<String>> {
254    let summary = classify_diffs(report, out_dir, workspace_root)?;
255    Ok(summary.paths().cloned().collect())
256}