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;