Skip to main content

sealmap_corpus/
lib.rs

1//! # sealmap-corpus
2//!
3//! Three jobs: the generated 1:1 Mermaid corpus (below); [`seal`], the
4//! lockfile and checks that keep hand-written diagram topics true to the
5//! code they cite (`sealmap verify`); and [`pack`], bounded review packs of
6//! topics with the code they cite (`sealmap pack`).
7//!
8//! Projects a [`Codebase`] into a **contract-enforced, 1:1 corpus** of dense
9//! Mermaid diagrams plus machine-readable metadata, designed to be read by
10//! LLM agents and merged by an orchestrating agent.
11//!
12//! ## The 1:1 contract
13//!
14//! For every source file `S` in the codebase the corpus contains exactly one
15//! document `S.md`, and nothing else in the corpus claims to describe `S`.
16//! Each document records the BLAKE3 hash of the source it was generated from,
17//! so drift is detectable without re-parsing. [`verify`] checks the contract
18//! against a directory and reports, per file:
19//!
20//! | Status | Meaning |
21//! |---|---|
22//! | [`Drift::Missing`] | source exists, document does not |
23//! | [`Drift::Orphaned`] | document exists, source does not |
24//! | [`Drift::Stale`] | source changed since the document was generated |
25//! | [`Drift::Modified`] | source unchanged but document differs (hand edit, or generator/options changed) |
26//!
27//! [`write()`] brings a directory into compliance (and only ever deletes files
28//! that carry the sealmap header); `sealmap generate --check` runs [`verify`]
29//! and writes nothing. The corpus is a regenerable view, not a gate.
30//!
31//! ## What a document contains
32//!
33//! `src/net/client.rs.md`:
34//!
35//! 1. YAML front matter: source path, module id, source hash, counts, and the
36//!    list of fragment ids in the document.
37//! 2. **Structure**: one `classDiagram` of everything defined in the file
38//!    (types with fields/variants/methods, a `<<module>>` box for free
39//!    functions, constants and submodules) and their relations
40//!    (implements, extends, owns, uses), with stubs for types defined
41//!    elsewhere.
42//! 3. **Sequences**: one `sequenceDiagram` per function or method that makes
43//!    at least [`CorpusOptions::min_calls`] calls, rendering its ordered call
44//!    flow with `alt`/`opt`/`loop`/`par` fragments.
45//!
46//! ## Corpus-level files
47//!
48//! Reserved names start with `_` and never collide with sources:
49//!
50//! * `_index.json`: every document and fragment with its participants and
51//!   the calls it makes, each call linked to the fragment that *expands* it.
52//!   This is the merge map for an orchestrator: to inline `A`'s call into
53//!   `B`, look up the call's `expands` fragment.
54//! * `_model.json`: the full [`Codebase`] (symbols, spans, signatures, docs,
55//!   relations with confidence).
56//! * `_overview.md`: cross-cutting views: crate/repository dependency graph,
57//!   per-crate module graphs, data models (`erDiagram`) and trait maps.
58//! * `_README.md`: how to read all of the above, for agents.
59//!
60//! ## Stable ids
61//!
62//! Every symbol is cited by its `sym:` id (`sym:cargo shop . db/Db#insert().`,
63//! see [`sealmap_model::sym`]) in headings, front matter and the JSON files.
64//! Every node, class, entity and participant id is derived from that id by
65//! an injective encoding (`shop__db___tDb___finsert`, see
66//! `sealmap_mermaid::Ident::from_symbol`), so the same symbol has the same
67//! id in every diagram of every document, no two symbols share one, and
68//! diagrams can be concatenated, merged or diffed by id. [`generate`] asserts
69//! that uniqueness over the whole codebase.
70//!
71//! ## Example
72//!
73//! ```
74//! use sealmap_corpus::{CorpusOptions, generate};
75//! use sealmap_model::SourceSet;
76//! use sealmap_rust::{RustOptions, extract};
77//!
78//! let mut src = SourceSet::new();
79//! src.insert("src/lib.rs", "pub struct A; impl A { pub fn go(&self) { helper(); } }\nfn helper() {}").unwrap();
80//! let model = extract(&src, &RustOptions { name: "demo".into(), ..Default::default() }).codebase;
81//!
82//! let corpus = generate(&model, &CorpusOptions::default());
83//! let doc = corpus.document("src/lib.rs.md").unwrap();
84//! assert!(doc.contains("sequenceDiagram"));
85//! assert!(doc.contains("demo___tA->>demo: helper()"));
86//! assert!(corpus.document("_index.json").is_some());
87//! ```
88
89#![forbid(unsafe_code)]
90#![deny(missing_docs)]
91
92mod contract;
93mod document;
94mod index;
95mod naming;
96mod overview;
97pub mod pack;
98pub mod review;
99pub mod route;
100pub mod seal;
101mod sequence;
102mod structure;
103pub mod tiers;
104
105use std::collections::BTreeMap;
106
107use sealmap_model::{Codebase, SourcePath, Symbol, SymbolId};
108
109pub use contract::{Drift, DriftEntry, Report, corpus_hash, read_dir_corpus, verify, verify_against, write};
110pub use index::{CallRef, DocumentEntry, FragmentEntry, FragmentKind, Index};
111
112/// Version of the corpus layout, front-matter and `_index.json` schema.
113///
114/// | Version | Change |
115/// |---|---|
116/// | 1 | `::` path ids, field `schema` (sealmap 0.1) |
117/// | 2 | `sym:` ids, quoted `module:` front matter, fragment `sig_hash` / `body_hash`, field `schema_version` |
118pub const CORPUS_SCHEMA_VERSION: u32 = 2;
119
120/// Suffix appended to a source path to name its document.
121pub const DOC_SUFFIX: &str = ".md";
122
123/// How external callees appear as sequence-diagram participants.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
125pub enum ExternalLanes {
126    /// One lane per external crate (`tokio`, `serde_json`). Densest.
127    #[default]
128    CrateRoot,
129    /// One lane per external owner (`tokio::fs`, `reqwest::Client`).
130    Owner,
131}
132
133/// Options controlling projection. Every option is part of the output's
134/// identity: changing one changes the documents, which `verify` reports as
135/// [`Drift::Modified`].
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct CorpusOptions {
138    /// Minimum number of calls a function must make to get a sequence
139    /// diagram. Default 1.
140    pub min_calls: usize,
141    /// Cap on messages per sequence diagram; the rest is summarised in a
142    /// note and remains fully listed in `_index.json`. Default 80.
143    pub max_messages: usize,
144    /// Cap on edges per overview flowchart (Mermaid's default limit is 500).
145    /// Heaviest edges are kept. Default 300.
146    pub max_edges: usize,
147    /// Cap on entities per data-model diagram (highest-degree first).
148    /// Default 60.
149    pub max_entities: usize,
150    /// Include private items in structure diagrams. Default true.
151    pub include_private: bool,
152    /// External participant grouping.
153    pub external_lanes: ExternalLanes,
154    /// Also write `_model.json`. Default true.
155    pub emit_model: bool,
156    /// Pretty-print the JSON files (larger, friendlier diffs). Default false:
157    /// compact JSON is roughly half the size.
158    pub pretty_json: bool,
159}
160
161impl Default for CorpusOptions {
162    fn default() -> Self {
163        Self {
164            min_calls: 1,
165            max_messages: 80,
166            max_edges: 300,
167            max_entities: 60,
168            include_private: true,
169            external_lanes: ExternalLanes::CrateRoot,
170            emit_model: true,
171            pretty_json: false,
172        }
173    }
174}
175
176/// A generated corpus: relative output path → file contents, plus the index.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct Corpus {
179    /// All files, keyed by path relative to the corpus root.
180    pub files: BTreeMap<SourcePath, String>,
181    /// The fragment index (also serialised as `_index.json`).
182    pub index: Index,
183}
184
185impl Corpus {
186    /// Contents of a generated file by relative path.
187    pub fn document(&self, path: &str) -> Option<&str> {
188        SourcePath::new(path).ok().and_then(|p| self.files.get(&p)).map(String::as_str)
189    }
190}
191
192/// Generate the full corpus in memory. Pure and deterministic: equal inputs
193/// give byte-identical output.
194pub fn generate(codebase: &Codebase, options: &CorpusOptions) -> Corpus {
195    let mut files = BTreeMap::new();
196    naming::assert_unique_idents(codebase);
197    let mut index = Index::new(codebase);
198    let lookup = Lookup::new(codebase);
199    for file in codebase.files.values() {
200        let (path, text, entry) = document::render(codebase, &lookup, file, options);
201        index.documents.push(entry);
202        files.insert(path, text);
203    }
204    index.link_expansions();
205    let reserved = |name: &str| SourcePath::new(name).expect("reserved names are valid paths");
206    files.insert(reserved("_overview.md"), overview::render(codebase, options));
207    files.insert(reserved("_README.md"), readme());
208    if options.emit_model {
209        files.insert(reserved("_model.json"), to_json(codebase, options.pretty_json));
210    }
211    files.insert(reserved("_index.json"), to_json(&index, options.pretty_json));
212    Corpus { files, index }
213}
214
215/// Per-file and per-parent symbol lists, built once per [`generate`] call.
216///
217/// `Codebase::symbols_in_file` and `Codebase::children` scan every symbol;
218/// calling them per document made projection O(files × symbols).
219pub(crate) struct Lookup<'a> {
220    by_file: BTreeMap<&'a SourcePath, Vec<&'a Symbol>>,
221    children: BTreeMap<&'a SymbolId, Vec<&'a Symbol>>,
222}
223
224impl<'a> Lookup<'a> {
225    fn new(cb: &'a Codebase) -> Self {
226        let mut by_file: BTreeMap<&SourcePath, Vec<&Symbol>> = BTreeMap::new();
227        let mut children: BTreeMap<&SymbolId, Vec<&Symbol>> = BTreeMap::new();
228        // `symbols` iterates in id order, so each list keeps the order the
229        // linear scans produced.
230        for s in cb.symbols.values() {
231            by_file.entry(&s.file).or_default().push(s);
232            if let Some(p) = &s.parent {
233                children.entry(p).or_default().push(s);
234            }
235        }
236        Self { by_file, children }
237    }
238
239    /// Same items and order as `Codebase::symbols_in_file`.
240    pub(crate) fn in_file(&self, path: &SourcePath) -> impl Iterator<Item = &'a Symbol> + '_ {
241        self.by_file.get(path).into_iter().flatten().copied()
242    }
243
244    /// Same items and order as `Codebase::children`.
245    pub(crate) fn children(&self, id: &SymbolId) -> impl Iterator<Item = &'a Symbol> + '_ {
246        self.children.get(id).into_iter().flatten().copied()
247    }
248}
249
250/// Map a source path to its document path (`src/a.rs` → `src/a.rs.md`).
251pub fn document_path(source: &SourcePath) -> SourcePath {
252    source.with_suffix(DOC_SUFFIX)
253}
254
255/// `true` for corpus-level files (`_index.json`, ...).
256pub fn is_reserved(path: &SourcePath) -> bool {
257    path.file_name().starts_with('_') && !path.as_str().contains('/')
258}
259
260fn to_json(value: &impl serde::Serialize, pretty: bool) -> String {
261    let mut s = if pretty { serde_json::to_string_pretty(value) } else { serde_json::to_string(value) }
262        .expect("model types always serialise");
263    s.push('\n');
264    s
265}
266
267fn readme() -> String {
268    include_str!("corpus_readme.md").to_owned()
269}
270
271/// Compiles and runs the README examples as doctests.
272#[cfg(doctest)]
273#[doc = include_str!("../README.md")]
274struct ReadmeDoctests;