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}