1use 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
37pub 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
52pub 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 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}