Skip to main content

judge/
pattern_baseline.rs

1//! Baseline snapshots and diffs for [`crate::rules::pattern::PatternCandidate`]s
2//! (see todo.md §16 "Pattern-Kandidaten separat baselinen").
3//!
4//! Deliberately a separate, parallel mechanism from [`crate::baseline`], not
5//! a reuse of its types: `PatternCandidate` structurally carries no
6//! `Severity`/`EvidenceClass`/`Origin`/verdict (see `pattern.rs`'s module
7//! docs — "nothing in this module is wired into ... a baseline verdict"), so
8//! there is nothing here to gate a CI verdict on. This module only answers
9//! "which pattern candidates are new since last time, and which disappeared"
10//! — advisory information, never a pass/fail outcome.
11//!
12//! Unlike [`crate::baseline::Delta`], there is no `code_introduced` /
13//! `rule_introduced` split here. That split exists for `Finding` baselines
14//! because a rule-logic change silently reclassifying old code as a new
15//! finding could otherwise fail a delta verdict for a change the diff never
16//! touched (todo.md §5 "Regelversions-Schutz") — the stakes are a false CI
17//! failure. Pattern candidates never gate anything, so a detector-logic
18//! change relabeling a candidate as "new" has no CI-breaking consequence: at
19//! worst it's a heuristic suggestion resurfacing. A flat new/resolved/
20//! unchanged split is honest and sufficient here.
21
22use std::collections::HashSet;
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26
27use crate::rules::pattern::{PatternCandidate, PatternCandidateId, RustPattern};
28
29pub const SCHEMA_VERSION: u32 = 1;
30
31/// The minimal record kept per candidate in a pattern baseline file — enough
32/// to tell whether a current candidate was already known.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BaselinePatternCandidate {
35    pub id: PatternCandidateId,
36    pub pattern: RustPattern,
37    pub krate: String,
38}
39
40/// A saved snapshot of pattern candidates, deliberately simpler than
41/// [`crate::baseline::Baseline`]: no `rule_revisions`, `total_loc`, or
42/// `score_context` — those exist for `Finding`'s gating/scoring concerns,
43/// which don't apply to advisory pattern candidates.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PatternBaseline {
46    pub schema_version: u32,
47    /// judge version active when this baseline was saved.
48    pub judge_version: String,
49    pub candidates: Vec<BaselinePatternCandidate>,
50}
51
52impl PatternBaseline {
53    pub fn new(candidates: &[PatternCandidate]) -> Self {
54        Self {
55            schema_version: SCHEMA_VERSION,
56            judge_version: env!("CARGO_PKG_VERSION").to_string(),
57            candidates: candidates
58                .iter()
59                .map(|candidate| BaselinePatternCandidate {
60                    id: candidate.id.clone(),
61                    pattern: candidate.pattern,
62                    krate: candidate.scope.krate.clone(),
63                })
64                .collect(),
65        }
66    }
67}
68
69#[derive(Debug)]
70pub enum PatternBaselineError {
71    Io(PathBuf, std::io::Error),
72    Serialize(serde_json::Error),
73    Deserialize(PathBuf, serde_json::Error),
74    /// The baseline declares a `schema_version` this judge has no support
75    /// for — typically one written by a newer judge.
76    UnsupportedSchemaVersion {
77        path: PathBuf,
78        found: Option<u64>,
79    },
80}
81
82impl std::fmt::Display for PatternBaselineError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        let error = match self {
85            Self::Io(path, err) => crate::baseline::StoreErrorRef::Io(path, err),
86            Self::Serialize(err) => crate::baseline::StoreErrorRef::Serialize(err),
87            Self::Deserialize(path, err) => crate::baseline::StoreErrorRef::Deserialize(path, err),
88            Self::UnsupportedSchemaVersion { path, found } => {
89                crate::baseline::StoreErrorRef::UnsupportedSchemaVersion {
90                    path,
91                    found: *found,
92                }
93            }
94        };
95        crate::baseline::fmt_store_error(
96            f,
97            "pattern baseline",
98            SCHEMA_VERSION,
99            "cargo judge patterns --save-pattern-baseline",
100            error,
101        )
102    }
103}
104
105impl std::error::Error for PatternBaselineError {}
106
107/// Writes `baseline` to `path` as pretty-printed JSON, creating parent
108/// directories (e.g. `.judge/`) as needed.
109pub fn save(path: &Path, baseline: &PatternBaseline) -> Result<(), PatternBaselineError> {
110    crate::baseline::write_json_pretty(
111        path,
112        baseline,
113        PatternBaselineError::Io,
114        PatternBaselineError::Serialize,
115    )
116}
117
118pub fn load(path: &Path) -> Result<PatternBaseline, PatternBaselineError> {
119    let value = crate::baseline::read_json_value(
120        path,
121        PatternBaselineError::Io,
122        PatternBaselineError::Deserialize,
123    )?;
124    let found = crate::baseline::schema_version_of(&value);
125    match found {
126        Some(version) if version == u64::from(SCHEMA_VERSION) => {
127            crate::baseline::deserialize_value(path, value, PatternBaselineError::Deserialize)
128        }
129        _ => Err(PatternBaselineError::UnsupportedSchemaVersion {
130            path: path.to_path_buf(),
131            found,
132        }),
133    }
134}
135
136/// The result of comparing a fresh set of pattern candidates against a
137/// [`PatternBaseline`] — a flat three-way split (see this module's docs for
138/// why there is no `code_introduced`/`rule_introduced` distinction here).
139#[derive(Debug, Clone, Serialize)]
140pub struct PatternDelta {
141    /// Candidates not present in the baseline.
142    pub new: Vec<PatternCandidate>,
143    /// Baseline candidates that no longer appear in the current run.
144    pub resolved: Vec<BaselinePatternCandidate>,
145    /// Candidates present in both the baseline and the current run.
146    pub unchanged_count: usize,
147}
148
149/// Compares `current` pattern candidates against `baseline`, classifying
150/// every candidate not already in the baseline as `new`.
151pub fn diff_patterns(current: &[PatternCandidate], baseline: &PatternBaseline) -> PatternDelta {
152    let known_ids: HashSet<&str> = baseline
153        .candidates
154        .iter()
155        .map(|candidate| candidate.id.as_str())
156        .collect();
157
158    let mut new = Vec::new();
159    let mut unchanged_count = 0;
160    for candidate in current {
161        if known_ids.contains(candidate.id.as_str()) {
162            unchanged_count += 1;
163        } else {
164            new.push(candidate.clone());
165        }
166    }
167
168    let current_ids: HashSet<&str> = current
169        .iter()
170        .map(|candidate| candidate.id.as_str())
171        .collect();
172    let resolved = baseline
173        .candidates
174        .iter()
175        .filter(|candidate| !current_ids.contains(candidate.id.as_str()))
176        .cloned()
177        .collect();
178
179    PatternDelta {
180        new,
181        resolved,
182        unchanged_count,
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::rules::pattern::{CodeScope, CorroboratedEvidence, Evidence};
190
191    /// `PatternCandidateId::compute` is private to `pattern.rs` (its inputs
192    /// are meaningful only to the aggregation rules that call it), so tests
193    /// here build one the same way `save`/`load` round-trip it: through its
194    /// `#[serde(transparent)]` string representation.
195    fn candidate_id(raw: &str) -> PatternCandidateId {
196        serde_json::from_value(serde_json::json!(raw)).unwrap()
197    }
198
199    fn candidate(id: &str, pattern: RustPattern, krate: &str) -> PatternCandidate {
200        PatternCandidate {
201            id: candidate_id(id),
202            pattern,
203            scope: CodeScope {
204                krate: krate.to_string(),
205                modules: Vec::new(),
206            },
207            evidence: CorroboratedEvidence {
208                primary: Evidence {
209                    description: "primary".to_string(),
210                    locations: Vec::new(),
211                },
212                independent: Evidence {
213                    description: "independent".to_string(),
214                    locations: Vec::new(),
215                },
216                additional: Vec::new(),
217            },
218            preconditions: Vec::new(),
219            contraindications: Vec::new(),
220            migration: Vec::new(),
221            related_findings: Vec::new(),
222        }
223    }
224
225    fn baseline_with(candidates: &[PatternCandidate]) -> PatternBaseline {
226        PatternBaseline::new(candidates)
227    }
228
229    #[test]
230    fn save_and_load_round_trips() {
231        let dir = crate::test_util::TempDir::new("pattern-baseline-round-trip");
232        let path = dir.join(".judge/baseline-patterns.json");
233        let baseline = baseline_with(&[candidate("a", RustPattern::Builder, "fixture")]);
234
235        save(&path, &baseline).unwrap();
236        let loaded = load(&path).unwrap();
237
238        assert_eq!(loaded.judge_version, baseline.judge_version);
239        assert_eq!(loaded.candidates.len(), 1);
240        assert_eq!(loaded.candidates[0].id.as_str(), "a");
241        assert_eq!(loaded.candidates[0].pattern, RustPattern::Builder);
242        assert_eq!(loaded.candidates[0].krate, "fixture");
243    }
244
245    #[test]
246    fn baseline_with_future_schema_version_is_rejected() {
247        let dir = crate::test_util::TempDir::new("pattern-baseline-future-schema");
248        let path = dir.join("baseline.json");
249        std::fs::write(
250            &path,
251            r#"{
252                "schema_version": 99,
253                "judge_version": "9.9.9",
254                "candidates": []
255            }"#,
256        )
257        .unwrap();
258
259        let err = load(&path).unwrap_err();
260
261        assert!(matches!(
262            err,
263            PatternBaselineError::UnsupportedSchemaVersion {
264                found: Some(99),
265                ..
266            }
267        ));
268    }
269
270    #[test]
271    fn known_candidate_is_unchanged_not_new() {
272        let baseline = baseline_with(&[candidate("a", RustPattern::Builder, "fixture")]);
273        let current = [candidate("a", RustPattern::Builder, "fixture")];
274
275        let delta = diff_patterns(&current, &baseline);
276
277        assert_eq!(delta.unchanged_count, 1);
278        assert!(delta.new.is_empty());
279        assert!(delta.resolved.is_empty());
280    }
281
282    #[test]
283    fn candidate_only_in_current_is_new() {
284        let baseline = baseline_with(&[]);
285        let current = [candidate("a", RustPattern::Builder, "fixture")];
286
287        let delta = diff_patterns(&current, &baseline);
288
289        assert_eq!(delta.new.len(), 1);
290        assert_eq!(delta.new[0].id.as_str(), "a");
291        assert_eq!(delta.unchanged_count, 0);
292    }
293
294    #[test]
295    fn candidate_only_in_baseline_is_resolved() {
296        let baseline = baseline_with(&[candidate("gone", RustPattern::Builder, "fixture")]);
297
298        let delta = diff_patterns(&[], &baseline);
299
300        assert_eq!(delta.resolved.len(), 1);
301        assert_eq!(delta.resolved[0].id.as_str(), "gone");
302    }
303}