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 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}