1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BaselinePatternCandidate {
35 pub id: PatternCandidateId,
36 pub pattern: RustPattern,
37 pub krate: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PatternBaseline {
46 pub schema_version: u32,
47 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 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
107pub 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#[derive(Debug, Clone, Serialize)]
140pub struct PatternDelta {
141 pub new: Vec<PatternCandidate>,
143 pub resolved: Vec<BaselinePatternCandidate>,
145 pub unchanged_count: usize,
147}
148
149pub 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 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(¤t, &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(¤t, &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}