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 generate;
37pub mod index;
38pub mod lint;
39pub mod platform;
40mod rustdoc;
41
42pub use config::{Config, Platform, Preset, UnknownPlatform};
43pub use error::{Error, Result};
44pub use generate::{Artifact, ArtifactLocation};
45pub use index::{IndexedCrate, IndexedWorkspace};
46pub use lint::{Diagnostic, Level};
47
48/// The output of a single pipeline run: everything a front end needs to
49/// either write artifacts to disk (`cargo aidoc`) or compare them to a
50/// checked-in tree (`cargo aidoc --check`).
51///
52/// Ordering: `artifacts` matches the emission order of
53/// [`generate::render_all`]; `diagnostics` matches the emission order of
54/// [`lint::lint`].
55#[derive(Debug, Clone)]
56pub struct Report {
57 /// Generated files, keyed by relative path.
58 pub artifacts: Vec<Artifact>,
59 /// Lint findings. Empty on a fully clean run.
60 pub diagnostics: Vec<Diagnostic>,
61}
62
63impl Report {
64 /// True if any diagnostic has [`Level::Error`]. Callers use this to
65 /// pick between exit code 0 (clean) and 2 (lint violation).
66 pub fn has_errors(&self) -> bool {
67 self.diagnostics
68 .iter()
69 .any(|d| matches!(d.level, Level::Error))
70 }
71}
72
73/// Run the full index → generate → lint pipeline against the workspace
74/// rooted at `workspace_root`, using `config` for defaults, exclusions
75/// and strict-mode toggling.
76///
77/// Failures during the generate stage are wrapped in [`Error::Generate`]
78/// so callers still receive a short summary of what the index stage did
79/// before the failure. There is no rollback and no partial output.
80pub fn run(workspace_root: &Path, config: &Config) -> Result<Report> {
81 let workspace = IndexedWorkspace::build(workspace_root, config)?;
82 let index_summary = format!("indexed {} crate(s)", workspace.crates.len());
83
84 let mut artifacts =
85 generate::render_all(&workspace, None).map_err(|source| Error::Generate {
86 source: Box::new(source),
87 index_summary,
88 })?;
89
90 platform::apply_overlays(&workspace, &mut artifacts, &config.platforms)?;
91
92 let diagnostics = lint::lint(&workspace, &artifacts, config);
93
94 Ok(Report {
95 artifacts,
96 diagnostics,
97 })
98}
99
100/// Resolve where a single artifact lands, given the two possible base
101/// directories a caller might have configured.
102fn resolve_path(artifact: &Artifact, out_dir: &Path, workspace_root: &Path) -> PathBuf {
103 let base = match artifact.location {
104 ArtifactLocation::OutDir => out_dir,
105 ArtifactLocation::WorkspaceRoot => workspace_root,
106 };
107 base.join(&artifact.path)
108}
109
110/// Write every artifact in `report` to disk. `out_dir` is the base for
111/// artifacts whose location is [`ArtifactLocation::OutDir`]; the
112/// workspace root is the base for
113/// [`ArtifactLocation::WorkspaceRoot`] artifacts (typically platform
114/// manifests such as `context7.json`).
115///
116/// Existing files are overwritten; parent directories are created as
117/// needed.
118pub fn write_report(report: &Report, out_dir: &Path, workspace_root: &Path) -> Result<()> {
119 for artifact in &report.artifacts {
120 let path = resolve_path(artifact, out_dir, workspace_root);
121 if let Some(parent) = path.parent() {
122 std::fs::create_dir_all(parent)?;
123 }
124 std::fs::write(&path, artifact.body.as_bytes())?;
125 }
126 Ok(())
127}
128
129/// Compare every artifact in `report` against its on-disk counterpart
130/// and return the list of paths whose contents differ (or that are
131/// missing on disk). See [`write_report`] for how `out_dir` and
132/// `workspace_root` are used.
133///
134/// This is the check-mode counterpart of [`write_report`]: nothing is
135/// written; callers pick between exit code 0 (empty result) and 2
136/// (non-empty). Read failures other than "file not found" propagate as
137/// errors. The returned paths use each artifact's original
138/// (base-relative) form so callers can echo them without recomputing.
139pub fn diff_report(report: &Report, out_dir: &Path, workspace_root: &Path) -> Result<Vec<String>> {
140 let mut diffs = Vec::new();
141 for artifact in &report.artifacts {
142 let path = resolve_path(artifact, out_dir, workspace_root);
143 match std::fs::read(&path) {
144 Ok(bytes) => {
145 if bytes != artifact.body.as_bytes() {
146 diffs.push(artifact.path.clone());
147 }
148 }
149 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
150 diffs.push(artifact.path.clone());
151 }
152 Err(err) => return Err(err.into()),
153 }
154 }
155 Ok(diffs)
156}