sealmap_corpus/
contract.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Drift {
20 Missing,
22 Orphaned,
24 Stale,
26 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
44pub struct DriftEntry {
45 pub path: SourcePath,
47 pub drift: Drift,
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
53pub struct Report {
54 pub entries: Vec<DriftEntry>,
56 pub checked: usize,
58}
59
60impl Report {
61 pub fn is_clean(&self) -> bool {
63 self.entries.is_empty()
64 }
65
66 pub fn count(&self, drift: Drift) -> usize {
68 self.entries.iter().filter(|e| e.drift == drift).count()
69 }
70}
71
72pub 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
109fn 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
117pub 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
143pub fn verify(dir: &Path, expected: &Corpus) -> io::Result<Report> {
145 Ok(verify_against(expected, &read_dir_corpus(dir)?))
146}
147
148pub 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
181fn 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
211pub 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}