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