1use crate::domain::types::{AnalysisDiagnostics, AnalysisResult};
20use crate::ports::ParseDiagnostic;
21use serde::Deserialize;
22use std::fs::File;
23use std::io::{BufReader, ErrorKind};
24use std::path::Path;
25
26pub const CURRENT_SCHEMA_VERSION: u32 = 2;
29
30pub const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1, 2];
35
36#[derive(Debug, Deserialize)]
48#[serde(bound = "")]
49struct BaselineEnvelope<P: ParseDiagnostic> {
50 schema_version: u32,
51 #[serde(default)]
52 tool_version: String,
53 #[serde(default)]
54 timestamp: String,
55 result: AnalysisResult,
56 #[serde(default)]
57 diagnostics: Option<AnalysisDiagnostics<P>>,
58}
59
60#[derive(Debug, Clone)]
67pub struct BaselineSnapshot<P: ParseDiagnostic> {
68 pub result: AnalysisResult,
69 pub tool_version: String,
70 pub timestamp: String,
71 pub diagnostics: Option<AnalysisDiagnostics<P>>,
72}
73
74#[derive(Debug, thiserror::Error)]
85#[non_exhaustive]
86pub enum BaselineError {
87 #[error("baseline file not found: {path}")]
88 NotFound { path: String },
89 #[error("baseline file is not readable: {path}: {source}")]
90 Io {
91 path: String,
92 #[source]
93 source: std::io::Error,
94 },
95 #[error("failed to parse baseline JSON ({path}): {source}")]
96 Parse {
97 path: String,
98 #[source]
99 source: serde_json::Error,
100 },
101 #[error(
102 "unsupported baseline schema_version: {found} (this build of crap4rs accepts {supported:?})"
103 )]
104 UnsupportedSchemaVersion {
105 found: u32,
106 supported: &'static [u32],
107 },
108}
109
110pub fn load<P: ParseDiagnostic>(path: &Path) -> Result<BaselineSnapshot<P>, BaselineError> {
122 let path_str = path.display().to_string();
123
124 let file = File::open(path).map_err(|source| match source.kind() {
125 ErrorKind::NotFound => BaselineError::NotFound {
126 path: path_str.clone(),
127 },
128 _ => BaselineError::Io {
129 path: path_str.clone(),
130 source,
131 },
132 })?;
133
134 let envelope: BaselineEnvelope<P> =
135 serde_json::from_reader(BufReader::new(file)).map_err(|source| BaselineError::Parse {
136 path: path_str.clone(),
137 source,
138 })?;
139
140 if !SUPPORTED_SCHEMA_VERSIONS.contains(&envelope.schema_version) {
141 return Err(BaselineError::UnsupportedSchemaVersion {
142 found: envelope.schema_version,
143 supported: SUPPORTED_SCHEMA_VERSIONS,
144 });
145 }
146
147 Ok(BaselineSnapshot {
148 result: envelope.result,
149 tool_version: envelope.tool_version,
150 timestamp: envelope.timestamp,
151 diagnostics: envelope.diagnostics,
152 })
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::test_strategies::DummyParseDiagnostic;
159 use std::io::Write;
160 use tempfile::NamedTempFile;
161
162 type TestSnapshot = BaselineSnapshot<DummyParseDiagnostic>;
167
168 fn load_test(path: &Path) -> Result<TestSnapshot, BaselineError> {
172 load::<DummyParseDiagnostic>(path)
173 }
174
175 fn write_envelope(content: &str) -> NamedTempFile {
176 let mut file = NamedTempFile::new().expect("create temp file");
177 write!(file, "{content}").expect("write temp file");
178 file.flush().expect("flush temp file");
179 file
180 }
181
182 fn minimal_envelope_json() -> &'static str {
183 r#"{
184 "schema_version": 1,
185 "tool_version": "0.2.0",
186 "language": "rust",
187 "timestamp": "2026-04-26T10:00:00Z",
188 "metric": "cognitive",
189 "threshold": 25.0,
190 "diff_ref": null,
191 "result": {
192 "functions": [],
193 "summary": {
194 "total_functions": 0,
195 "total_files": 0,
196 "exceeding_threshold": 0,
197 "average_crap": 0.0,
198 "median_crap": 0.0,
199 "max_crap": null,
200 "worst_function": null,
201 "distribution": {
202 "low": 0,
203 "acceptable": 0,
204 "moderate": 0,
205 "high": 0
206 }
207 },
208 "passed": true
209 }
210 }"#
211 }
212
213 #[test]
214 fn load_minimal_envelope_extracts_result_and_metadata() {
215 let file = write_envelope(minimal_envelope_json());
216 let snapshot = load_test(file.path()).expect("load minimal envelope");
217 assert_eq!(snapshot.tool_version, "0.2.0");
218 assert_eq!(snapshot.timestamp, "2026-04-26T10:00:00Z");
219 assert_eq!(snapshot.result.functions.len(), 0);
220 assert!(snapshot.result.passed);
221 assert!(snapshot.diagnostics.is_none());
222 }
223
224 #[test]
225 fn load_envelope_with_function_round_trips_verdict_fields() {
226 let json = r#"{
227 "schema_version": 1,
228 "tool_version": "0.2.0",
229 "language": "rust",
230 "timestamp": "2026-04-26T10:00:00Z",
231 "metric": "cognitive",
232 "threshold": 25.0,
233 "diff_ref": null,
234 "result": {
235 "functions": [
236 {
237 "scored": {
238 "identity": {
239 "file_path": "src/foo.rs",
240 "qualified_name": "foo::bar",
241 "span": { "start_line": 10, "end_line": 20 }
242 },
243 "complexity": 5,
244 "complexity_metric": "cognitive",
245 "coverage_percent": 75.0,
246 "crap": { "value": 8.0, "risk_level": "acceptable" },
247 "contributors": []
248 },
249 "threshold": 25.0,
250 "exceeds": false
251 }
252 ],
253 "summary": {
254 "total_functions": 1,
255 "total_files": 1,
256 "exceeding_threshold": 0,
257 "average_crap": 8.0,
258 "median_crap": 8.0,
259 "max_crap": { "value": 8.0, "risk_level": "acceptable" },
260 "worst_function": {
261 "file_path": "src/foo.rs",
262 "qualified_name": "foo::bar",
263 "span": { "start_line": 10, "end_line": 20 }
264 },
265 "distribution": { "low": 0, "acceptable": 1, "moderate": 0, "high": 0 }
266 },
267 "passed": true
268 }
269 }"#;
270 let file = write_envelope(json);
271 let snapshot = load_test(file.path()).expect("load function envelope");
272 assert_eq!(snapshot.result.functions.len(), 1);
273 let v = &snapshot.result.functions[0];
274 assert_eq!(v.scored.identity.qualified_name, "foo::bar");
275 assert_eq!(v.scored.identity.file_path, "src/foo.rs");
276 assert_eq!(v.scored.crap.value, 8.0);
277 assert!(!v.exceeds);
278 }
279
280 #[test]
281 fn load_nonexistent_path_returns_not_found() {
282 let result = load_test(Path::new("/tmp/definitely-does-not-exist-xyzzy.json"));
283 match result {
284 Err(BaselineError::NotFound { .. }) => {}
285 other => panic!("expected NotFound, got {other:?}"),
286 }
287 }
288
289 #[test]
290 fn load_malformed_json_returns_parse_error() {
291 let file = write_envelope("{ not valid JSON");
292 let err = load_test(file.path()).unwrap_err();
293 match err {
294 BaselineError::Parse { .. } => {}
295 other => panic!("expected Parse, got {other:?}"),
296 }
297 }
298
299 #[test]
300 fn load_unsupported_schema_version_rejects() {
301 let json = r#"{
304 "schema_version": 99,
305 "result": {
306 "functions": [],
307 "summary": {
308 "total_functions": 0, "total_files": 0, "exceeding_threshold": 0,
309 "average_crap": 0.0, "median_crap": 0.0,
310 "max_crap": null, "worst_function": null,
311 "distribution": { "low": 0, "acceptable": 0, "moderate": 0, "high": 0 }
312 },
313 "passed": true
314 }
315 }"#;
316 let file = write_envelope(json);
317 let err = load_test(file.path()).unwrap_err();
318 match err {
319 BaselineError::UnsupportedSchemaVersion {
320 found: 99,
321 supported,
322 } => {
323 assert_eq!(supported, &[1, 2]);
324 }
325 other => panic!("expected UnsupportedSchemaVersion {{ found: 99, .. }}, got {other:?}"),
326 }
327 }
328
329 #[test]
330 fn load_v2_schema_version_accepted() {
331 let json = r#"{
334 "schema_version": 2,
335 "tool_version": "0.4.0",
336 "result": {
337 "functions": [],
338 "summary": {
339 "total_functions": 0, "total_files": 0, "exceeding_threshold": 0,
340 "average_crap": 0.0, "median_crap": 0.0,
341 "max_crap": null, "worst_function": null,
342 "distribution": { "low": 0, "acceptable": 0, "moderate": 0, "high": 0 }
343 },
344 "passed": true
345 }
346 }"#;
347 let file = write_envelope(json);
348 let snapshot = load_test(file.path()).expect("v2 envelope should load");
349 assert_eq!(snapshot.tool_version, "0.4.0");
350 }
351
352 #[test]
353 fn load_envelope_propagates_diagnostics_when_present() {
354 let json = r#"{
355 "schema_version": 1,
356 "result": {
357 "functions": [],
358 "summary": {
359 "total_functions": 0, "total_files": 0, "exceeding_threshold": 0,
360 "average_crap": 0.0, "median_crap": 0.0,
361 "max_crap": null, "worst_function": null,
362 "distribution": { "low": 0, "acceptable": 0, "moderate": 0, "high": 0 }
363 },
364 "passed": true
365 },
366 "diagnostics": {
367 "parse_diagnostics": [],
368 "files_found": 5,
369 "files_unparseable": 0,
370 "functions_extracted": 12,
371 "functions_matched": 10,
372 "functions_no_coverage": 2,
373 "files_analyzed": 5,
374 "files_zero_coverage": 0
375 }
376 }"#;
377 let file = write_envelope(json);
378 let snapshot = load_test(file.path()).expect("load envelope with diagnostics");
379 let diag = snapshot.diagnostics.expect("diagnostics should be present");
380 assert_eq!(diag.files_found, 5);
381 assert_eq!(diag.functions_matched, 10);
382 }
383
384 #[test]
385 fn load_envelope_with_extra_unknown_fields_is_forward_compatible() {
386 let json = r#"{
389 "schema_version": 1,
390 "tool_version": "0.99.0",
391 "result": {
392 "functions": [],
393 "summary": {
394 "total_functions": 0, "total_files": 0, "exceeding_threshold": 0,
395 "average_crap": 0.0, "median_crap": 0.0,
396 "max_crap": null, "worst_function": null,
397 "distribution": { "low": 0, "acceptable": 0, "moderate": 0, "high": 0 }
398 },
399 "passed": true
400 },
401 "future_field": { "unknown": "shape" }
402 }"#;
403 let file = write_envelope(json);
404 let snapshot = load_test(file.path()).expect("forward-compat load");
405 assert_eq!(snapshot.tool_version, "0.99.0");
406 }
407}