Skip to main content

spec_drift/
baseline.rs

1//! `--baseline` support.
2//!
3//! A baseline is a JSON array of divergences (same schema the JSON reporter
4//! emits). Loading a baseline filters the active run to retain only *new*
5//! divergences, letting `spec-drift` be adopted on a legacy repo without a
6//! flag-day cleanup.
7//!
8//! Identity is `(rule, file, line, stated)` — deliberately loose on the `risk`
9//! and `reality` fields so small prose edits in the analyzer don't invalidate
10//! the baseline.
11
12use crate::domain::{Divergence, RuleId};
13use crate::error::SpecDriftError;
14use serde::{Deserialize, Serialize};
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct Identity {
20    pub rule: RuleId,
21    pub file: PathBuf,
22    pub line: u32,
23    pub stated: String,
24}
25
26impl From<&Divergence> for Identity {
27    fn from(d: &Divergence) -> Self {
28        Self {
29            rule: d.rule,
30            file: d.location.file.clone(),
31            line: d.location.line,
32            stated: d.stated.clone(),
33        }
34    }
35}
36
37/// Load a baseline JSON file into a set of identities. A missing file is an
38/// error — the user asked us to enforce a baseline, so silently pretending it
39/// existed would hide drift.
40pub fn load(path: &Path) -> Result<HashSet<Identity>, SpecDriftError> {
41    let raw = std::fs::read_to_string(path).map_err(|e| SpecDriftError::Io {
42        path: path.to_path_buf(),
43        source: e,
44    })?;
45    let divs: Vec<Divergence> = serde_json::from_str(&raw).map_err(|e| SpecDriftError::Baseline {
46        path: path.to_path_buf(),
47        message: format!("failed to parse baseline JSON: {e}"),
48    })?;
49    Ok(divs.iter().map(Identity::from).collect())
50}
51
52/// Drop divergences already present in the baseline. The returned Vec
53/// preserves the input order of "new" divergences.
54pub fn subtract(divs: Vec<Divergence>, baseline: &HashSet<Identity>) -> Vec<Divergence> {
55    divs.into_iter()
56        .filter(|d| !baseline.contains(&Identity::from(d)))
57        .collect()
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::domain::{Location, Severity};
64
65    fn div(rule: RuleId, file: &str, line: u32, stated: &str) -> Divergence {
66        Divergence {
67            rule,
68            severity: Severity::Critical,
69            location: Location::new(file, line),
70            stated: stated.into(),
71            reality: "r".into(),
72            risk: "k".into(),
73            attribution: None,
74
75        }
76    }
77
78    #[test]
79    fn subtract_drops_known_divergences() {
80        let existing = div(RuleId::SymbolAbsence, "README.md", 10, "`x` exists");
81        let fresh = div(RuleId::SymbolAbsence, "README.md", 20, "`y` exists");
82
83        let mut baseline: HashSet<Identity> = HashSet::new();
84        baseline.insert(Identity::from(&existing));
85
86        let remaining = subtract(vec![existing, fresh.clone()], &baseline);
87        assert_eq!(remaining, vec![fresh]);
88    }
89
90    #[test]
91    fn load_missing_file_errors() {
92        let err = load(std::path::Path::new("/nope/baseline.json")).unwrap_err();
93        // Either Io (the read failed) — but definitely not silently empty.
94        assert!(matches!(err, SpecDriftError::Io { .. }));
95    }
96
97    #[test]
98    fn load_and_subtract_roundtrip() {
99        let tmp = tempfile::tempdir().unwrap();
100        let path = tmp.path().join("baseline.json");
101        let d = div(RuleId::LyingTest, "src/lib.rs", 42, "`t` is negative");
102        std::fs::write(&path, serde_json::to_string(&vec![d.clone()]).unwrap()).unwrap();
103
104        let baseline = load(&path).unwrap();
105        let remaining = subtract(vec![d], &baseline);
106        assert!(remaining.is_empty());
107    }
108}