Skip to main content

sealmap_corpus/
contract.rs

1//! The 1:1 contract: verify a corpus directory against freshly generated
2//! output, and write a directory into compliance.
3
4use std::collections::BTreeMap;
5use std::fmt;
6use std::fs;
7use std::io;
8use std::path::Path;
9
10use sealmap_model::{ContentHash, SourcePath};
11use serde::Serialize;
12
13use crate::document::{front_matter_hash, is_generated};
14use crate::{Corpus, is_reserved};
15
16/// How a corpus file deviates from the contract.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Drift {
20    /// A source file has no document.
21    Missing,
22    /// A generated document has no source file.
23    Orphaned,
24    /// The source changed since the document was generated.
25    Stale,
26    /// The source is unchanged but the document differs (hand edit, or a new
27    /// generator version / options).
28    Modified,
29}
30
31impl fmt::Display for Drift {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(match self {
34            Self::Missing => "missing",
35            Self::Orphaned => "orphaned",
36            Self::Stale => "stale",
37            Self::Modified => "modified",
38        })
39    }
40}
41
42/// One deviation.
43#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
44pub struct DriftEntry {
45    /// Corpus-relative path.
46    pub path: SourcePath,
47    /// What is wrong.
48    pub drift: Drift,
49}
50
51/// Result of [`verify`] / [`write()`].
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
53pub struct Report {
54    /// Deviations, in path order. For [`write()`], what was fixed.
55    pub entries: Vec<DriftEntry>,
56    /// Number of expected files checked.
57    pub checked: usize,
58}
59
60impl Report {
61    /// `true` when the corpus satisfies the contract.
62    pub fn is_clean(&self) -> bool {
63        self.entries.is_empty()
64    }
65
66    /// Count of entries with the given drift.
67    pub fn count(&self, drift: Drift) -> usize {
68        self.entries.iter().filter(|e| e.drift == drift).count()
69    }
70}
71
72/// Compare `expected` (freshly generated) with `actual` (file path → text as
73/// found on disk or elsewhere).
74///
75/// Only `.md` files that carry the sealmap header, and the reserved `_`
76/// files, are considered part of the corpus; anything else in the directory
77/// is ignored. A `_model.json` that `expected` leaves out (generated with
78/// `emit_model: false`) is orphaned when it is a sealmap model.
79pub fn verify_against(expected: &Corpus, actual: &BTreeMap<SourcePath, String>) -> Report {
80    let mut entries = Vec::new();
81    for (path, want) in &expected.files {
82        match actual.get(path) {
83            None => entries.push(DriftEntry { path: path.clone(), drift: Drift::Missing }),
84            Some(have) if have == want => {}
85            Some(have) => {
86                let drift = if is_reserved(path) {
87                    Drift::Modified
88                } else {
89                    let want_hash = front_matter_hash(want);
90                    match front_matter_hash(have) {
91                        Some(h) if Some(&h) == want_hash.as_ref() => Drift::Modified,
92                        _ => Drift::Stale,
93                    }
94                };
95                entries.push(DriftEntry { path: path.clone(), drift });
96            }
97        }
98    }
99    for (path, text) in actual {
100        let ours = if path.extension() == Some("md") { is_generated(text) } else { is_model_json(path, text) };
101        if !expected.files.contains_key(path) && ours {
102            entries.push(DriftEntry { path: path.clone(), drift: Drift::Orphaned });
103        }
104    }
105    entries.sort();
106    Report { entries, checked: expected.files.len() }
107}
108
109/// `true` for the reserved `_model.json` written by [`generate`](crate::generate):
110/// a serialised `Codebase`, whose first key is `schema_version` (compact or
111/// pretty-printed).
112fn is_model_json(path: &SourcePath, text: &str) -> bool {
113    path.as_str() == "_model.json"
114        && text.strip_prefix('{').is_some_and(|rest| rest.trim_start().starts_with("\"schema_version\":"))
115}
116
117/// Read every file under `dir` (UTF-8 only) keyed by relative path. A
118/// missing directory reads as empty.
119pub fn read_dir_corpus(dir: &Path) -> io::Result<BTreeMap<SourcePath, String>> {
120    let mut out = BTreeMap::new();
121    if dir.exists() {
122        read_rec(dir, dir, &mut out)?;
123    }
124    Ok(out)
125}
126
127fn read_rec(root: &Path, dir: &Path, out: &mut BTreeMap<SourcePath, String>) -> io::Result<()> {
128    let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
129    entries.sort_by_key(|e| e.file_name());
130    for e in entries {
131        let ty = e.file_type()?;
132        if ty.is_dir() {
133            read_rec(root, &e.path(), out)?;
134        } else if ty.is_file() {
135            if let (Ok(text), Ok(rel)) = (fs::read_to_string(e.path()), SourcePath::relative_to(&e.path(), root)) {
136                out.insert(rel, text);
137            }
138        }
139    }
140    Ok(())
141}
142
143/// Verify the corpus in `dir` against `expected`.
144pub fn verify(dir: &Path, expected: &Corpus) -> io::Result<Report> {
145    Ok(verify_against(expected, &read_dir_corpus(dir)?))
146}
147
148/// Bring `dir` into compliance with `expected`: write missing, stale and
149/// modified files, delete orphaned generated documents and a sealmap
150/// `_model.json` that `expected` leaves out (and nothing else).
151/// Unchanged files are not touched, so mtimes stay stable. Returns what was
152/// changed.
153///
154/// `write` never follows a symbolic link below `dir`: if any path it would
155/// write or delete runs through one (a linked file or a linked directory), it
156/// fails with [`io::ErrorKind::InvalidInput`] naming that path, before it
157/// changes anything. `dir` itself may be a link.
158pub fn write(dir: &Path, expected: &Corpus) -> io::Result<Report> {
159    let report = verify(dir, expected)?;
160    for e in &report.entries {
161        refuse_links(dir, &e.path)?;
162    }
163    for e in &report.entries {
164        let path = dir.join(e.path.as_str());
165        match e.drift {
166            Drift::Orphaned => {
167                fs::remove_file(&path)?;
168                remove_empty_parents(dir, &path);
169            }
170            _ => {
171                if let Some(parent) = path.parent() {
172                    fs::create_dir_all(parent)?;
173                }
174                fs::write(&path, &expected.files[&e.path])?;
175            }
176        }
177    }
178    Ok(report)
179}
180
181/// Fail if any existing component of `rel` below `dir` is a symbolic link.
182fn refuse_links(dir: &Path, rel: &SourcePath) -> io::Result<()> {
183    let mut cur = dir.to_path_buf();
184    for part in rel.as_str().split('/') {
185        cur.push(part);
186        match fs::symlink_metadata(&cur) {
187            Ok(m) if m.file_type().is_symlink() => {
188                return Err(io::Error::new(
189                    io::ErrorKind::InvalidInput,
190                    format!("refusing to write {rel}: {} is a symbolic link", cur.display()),
191                ));
192            }
193            Ok(_) => {}
194            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
195            Err(e) => return Err(e),
196        }
197    }
198    Ok(())
199}
200
201fn remove_empty_parents(root: &Path, file: &Path) {
202    let mut cur = file.parent();
203    while let Some(dir) = cur {
204        if dir == root || fs::remove_dir(dir).is_err() {
205            break;
206        }
207        cur = dir.parent();
208    }
209}
210
211/// Hash of a whole corpus (all paths and contents), for cheap equality
212/// checks across machines.
213pub fn corpus_hash(corpus: &Corpus) -> ContentHash {
214    let mut buf = String::new();
215    for (p, t) in &corpus.files {
216        buf.push_str(p.as_str());
217        buf.push('\0');
218        buf.push_str(t);
219        buf.push('\0');
220    }
221    ContentHash::of_bytes(buf.as_bytes())
222}