1use super::finding::{Finding, Severity};
7use crate::corpus::Corpus;
8use crate::workspace::owners::{PathMatch, matcher};
9use crate::workspace::vcs;
10use anyhow::{Context, Result, anyhow, bail};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::Path;
14
15pub const SCHEMA: &str = "spec.baseline.v1";
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Baseline {
19 pub schema: String,
20 pub measured_on: String,
23 pub counts: BTreeMap<String, usize>,
25}
26
27#[derive(Debug, Default)]
29pub struct Held {
30 pub findings: Vec<Finding>,
32 pub known: usize,
34 pub lowerable: Vec<(String, usize, usize)>,
36}
37
38fn key(f: &Finding) -> String {
39 format!("{}\t{}", f.rule, f.path)
40}
41
42fn counted(f: &Finding, strict: &[PathMatch]) -> bool {
43 f.severity == Severity::Error && !strict.iter().any(|m| m.is_match(&f.path))
44}
45
46pub fn matchers(patterns: &[String]) -> Vec<PathMatch> {
47 patterns.iter().filter_map(|p| matcher(p)).collect()
48}
49
50pub fn load_file(root: &Path, rel: &str) -> Result<Option<Baseline>> {
52 let path = root.join(rel);
53 if !path.exists() {
54 return Ok(None);
55 }
56 let text = std::fs::read_to_string(&path).with_context(|| format!("cannot read {rel}"))?;
57 let b: Baseline = serde_json::from_str(&text).with_context(|| format!("{rel} is not a {SCHEMA} file"))?;
58 if b.schema != SCHEMA {
59 bail!("{rel} has schema {}, expected {SCHEMA}", b.schema);
60 }
61 Ok(Some(b))
62}
63
64pub fn hold_with(b: &Baseline, findings: Vec<Finding>, strict: &[PathMatch]) -> Held {
65 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
66 let mut held = Held::default();
67 for f in findings {
68 if !counted(&f, strict) {
69 held.findings.push(f);
70 continue;
71 }
72 let k = key(&f);
73 let n = seen.entry(k.clone()).or_default();
74 *n += 1;
75 if *n <= b.counts.get(&k).copied().unwrap_or(0) {
76 held.known += 1;
77 } else {
78 held.findings.push(f);
79 }
80 }
81 for (k, &allowed) in &b.counts {
82 let now = seen.get(k).copied().unwrap_or(0);
83 if now < allowed {
84 held.lowerable.push((k.clone(), allowed, now));
85 }
86 }
87 held
88}
89
90pub fn measure_with(
93 root: &Path,
94 findings: &[Finding],
95 strict: &[PathMatch],
96 measured: impl Fn(&str) -> bool,
97) -> Result<Baseline> {
98 let head = vcs::head(root).ok_or_else(|| anyhow!("a baseline needs a git repository with a commit"))?;
99 let changed = vcs::changed_since(root, "HEAD").unwrap_or_default();
100 let dirty: Vec<&str> = changed.iter().map(String::as_str).filter(|p| measured(p)).take(10).collect();
101 if !dirty.is_empty() {
102 bail!("commit these files first, the baseline names HEAD: {}", dirty.join(", "));
103 }
104 let mut counts = BTreeMap::new();
105 for f in findings.iter().filter(|f| counted(f, strict)) {
106 *counts.entry(key(f)).or_default() += 1;
107 }
108 Ok(Baseline { schema: SCHEMA.into(), measured_on: head, counts })
109}
110
111pub fn write_file(root: &Path, rel: &str, b: &Baseline) -> Result<()> {
112 let path = root.join(rel);
113 if let Some(dir) = path.parent() {
114 std::fs::create_dir_all(dir)?;
115 }
116 std::fs::write(&path, serde_json::to_string_pretty(b)? + "\n")?;
117 Ok(())
118}
119
120pub fn load(c: &Corpus) -> Result<Option<Baseline>> {
121 match &c.cfg.raw.check.baseline {
122 Some(rel) => load_file(&c.cfg.root, rel),
123 None => Ok(None),
124 }
125}
126
127pub fn follow_move(c: &Corpus, old: &str, new: &str) -> Result<Option<(String, String)>> {
130 let (Some(rel), Some(mut b)) = (c.cfg.raw.check.baseline.clone(), load(c)?) else { return Ok(None) };
131 let mut moved = false;
132 b.counts = std::mem::take(&mut b.counts).into_iter().fold(BTreeMap::new(), |mut acc, (k, n)| {
133 let k = match k.split_once('\t') {
134 Some((rule, path)) => match moved_path(path, old, new) {
135 Some(p) => {
136 moved = true;
137 format!("{rule}\t{p}")
138 }
139 None => k,
140 },
141 None => k,
142 };
143 *acc.entry(k).or_default() += n;
144 acc
145 });
146 Ok(moved.then(|| serde_json::to_string_pretty(&b).map(|t| (rel, t + "\n"))).transpose()?)
147}
148
149fn moved_path(path: &str, old: &str, new: &str) -> Option<String> {
150 if path == old {
151 return Some(new.to_string());
152 }
153 path.strip_prefix(old).filter(|r| r.starts_with('/')).map(|r| format!("{new}{r}"))
154}
155
156pub fn hold(c: &Corpus, b: &Baseline, findings: Vec<Finding>) -> Held {
157 hold_with(b, findings, &matchers(&c.cfg.raw.check.strict))
158}
159
160pub fn measure(c: &Corpus, findings: &[Finding]) -> Result<Baseline> {
161 let strict = matchers(&c.cfg.raw.check.strict);
162 measure_with(&c.cfg.root, findings, &strict, |p| c.docs.iter().any(|d| d.path == p))
163}
164
165pub fn write(c: &Corpus, b: &Baseline) -> Result<String> {
166 let rel = c.cfg.raw.check.baseline.clone().ok_or_else(|| anyhow!("set [check] baseline in spec.toml first"))?;
167 write_file(&c.cfg.root, &rel, b)?;
168 Ok(rel)
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn a_baseline_from_a_commit_a_replay_renamed_still_loads() {
177 let tmp = tempfile::tempdir().unwrap();
178 let b = Baseline {
179 schema: SCHEMA.into(),
180 measured_on: "0000000000000000000000000000000000000000".into(),
181 counts: BTreeMap::from([("L1\ta.md".to_string(), 2)]),
182 };
183 write_file(tmp.path(), "spec-baseline.json", &b).unwrap();
184 let loaded = load_file(tmp.path(), "spec-baseline.json").unwrap().unwrap();
185 assert_eq!(loaded.counts["L1\ta.md"], 2);
186 assert_eq!(moved_path("a/b.md", "a/b.md", "c/d.md").as_deref(), Some("c/d.md"));
187 assert_eq!(moved_path("a/b/x.md", "a/b", "c/d").as_deref(), Some("c/d/x.md"));
188 assert_eq!(moved_path("a/bc.md", "a/b", "c/d"), None);
189 }
190}