Skip to main content

specfold_core/check/
baseline.rs

1//! A committed baseline of known findings, so a project can adopt specfold on an existing pile:
2//! what is already there is accepted, anything new fails, and the counts may only go down.
3//! Used by `spec check` ([check] baseline) and by each guard (its `baseline`); strict paths never
4//! get one.
5
6use 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    /// The commit whose files were measured, for the record. A rebase or a cherry-pick onto a
21    /// newer main renames it, so it is not checked: the counts are keyed by rule and path.
22    pub measured_on: String,
23    /// Error counts keyed `RULE<TAB>path`.
24    pub counts: BTreeMap<String, usize>,
25}
26
27/// The outcome of holding findings against a baseline.
28#[derive(Debug, Default)]
29pub struct Held {
30    /// Findings the baseline does not cover; these decide the exit code.
31    pub findings: Vec<Finding>,
32    /// Errors accepted because the baseline lists them.
33    pub known: usize,
34    /// Keys whose count fell below the baseline: (key, baseline, now).
35    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
50/// Reads `rel` under `root`; None when it does not exist yet.
51pub 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
90/// Measures errors into a baseline. Refuses when a measured file differs from HEAD, because the
91/// baseline names a commit and must describe that commit's bytes.
92pub 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 hold(c: &Corpus, b: &Baseline, findings: Vec<Finding>) -> Held {
128    hold_with(b, findings, &matchers(&c.cfg.raw.check.strict))
129}
130
131pub fn measure(c: &Corpus, findings: &[Finding]) -> Result<Baseline> {
132    let strict = matchers(&c.cfg.raw.check.strict);
133    measure_with(&c.cfg.root, findings, &strict, |p| c.docs.iter().any(|d| d.path == p))
134}
135
136pub fn write(c: &Corpus, b: &Baseline) -> Result<String> {
137    let rel = c.cfg.raw.check.baseline.clone().ok_or_else(|| anyhow!("set [check] baseline in spec.toml first"))?;
138    write_file(&c.cfg.root, &rel, b)?;
139    Ok(rel)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn a_baseline_from_a_commit_a_replay_renamed_still_loads() {
148        let tmp = tempfile::tempdir().unwrap();
149        let b = Baseline {
150            schema: SCHEMA.into(),
151            measured_on: "0000000000000000000000000000000000000000".into(),
152            counts: BTreeMap::from([("L1\ta.md".to_string(), 2)]),
153        };
154        write_file(tmp.path(), "spec-baseline.json", &b).unwrap();
155        let loaded = load_file(tmp.path(), "spec-baseline.json").unwrap().unwrap();
156        assert_eq!(loaded.counts["L1\ta.md"], 2);
157    }
158}