Skip to main content

crap_core/adapters/
baseline.rs

1//! Baseline-envelope loader — reads a previously-emitted analyzer JSON
2//! envelope from disk and extracts the `result` block, plus the
3//! envelope metadata (tool_version, timestamp) needed for delta
4//! reporting.
5//!
6//! The on-the-wire JSON envelope (see `adapters::reporters::json`)
7//! includes far more than `result`: `view`, `diagnostics`, `delta` (in
8//! the future), etc. The baseline loader ignores everything except
9//! `schema_version`, `result`, `tool_version`, `timestamp`, and
10//! optionally `diagnostics` so that consumers can produce baseline
11//! envelopes that contain extra fields without breaking us.
12//!
13//! Schema version validation: `schema_version` 1 and 2 are both
14//! accepted (the current emit version is 2; v1 baselines remain
15//! loadable because delta matching is identity-keyed, not
16//! column-keyed). Future schema bumps will need an explicit
17//! migration path.
18
19use 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
26/// Currently-emitted envelope schema version. Lockstep with
27/// `adapters::reporters::json::JsonEnvelope::schema_version`.
28pub const CURRENT_SCHEMA_VERSION: u32 = 2;
29
30/// Envelope schema versions accepted by the baseline loader. v1 stays
31/// loadable across the v0.3.x → v0.4.x boundary so users can keep their
32/// committed baseline JSON; the column-convention shift in v2 doesn't
33/// affect delta calculations (identity-keyed matching).
34pub const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1, 2];
35
36/// On-disk shape we read. Mirrors the relevant subset of
37/// `JsonEnvelope`. `serde(default)` on optional fields keeps us
38/// forward-compatible with envelopes that omit fields we don't need.
39///
40/// `P: ParseDiagnostic` carries the adapter-specific parse-diagnostic
41/// type through `AnalysisDiagnostics<P>`. crap4rs concretizes to
42/// `LcovParseDiagnostic` via the v0.4 shim alias.
43/// `serde(bound = "")` suppresses the auto-generated `P: Serialize` /
44/// `P: Deserialize<'de>` bounds — `P: ParseDiagnostic` already provides
45/// `Serialize + DeserializeOwned`, and the auto-bounds conflict with
46/// the owned-deserialize requirement.
47#[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/// What the loader returns to callers. The fields are all the metadata
61/// the delta envelope's `delta.baseline_*` keys ultimately surface.
62///
63/// Generic over `P: ParseDiagnostic` so the loader works for any
64/// adapter that supplies a concrete parse-diagnostic type. The crap4rs
65/// shim concretizes this to `BaselineSnapshot<LcovParseDiagnostic>`.
66#[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/// Errors raised while loading a baseline envelope.
75///
76/// Tag-only — variants carry numeric / string context but no
77/// pre-formatted prose. The CLI translates these into user-facing
78/// stderr messages (keeps the adapter language-neutral for future
79/// `crap-core` extraction).
80///
81/// `#[non_exhaustive]` reserves namespace for future variants (e.g.,
82/// `MissingRequiredField`, `IncompatibleToolVersion`) without forcing a
83/// downstream major-version bump on every adapter error addition.
84#[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
108/// Load an analyzer JSON envelope from disk and return the baseline
109/// snapshot. Streams the file through a `BufReader` rather than
110/// reading the whole envelope into memory — large codebases produce
111/// envelopes in the multi-MB range and there's no reason to allocate
112/// that twice.
113///
114/// Generic over `P: ParseDiagnostic` so each adapter (LCOV, Istanbul,
115/// …) supplies its own concrete diagnostic shape. The crap4rs shim
116/// `crap4rs::adapters::baseline::load` instantiates `P =
117/// LcovParseDiagnostic` so v0.4 callers' import paths stay byte-
118/// identical.
119pub 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    /// Concrete `P` for tests in this module — the loader's behavior is
161    /// `P`-agnostic for the cases we test (none reach into per-variant
162    /// fields), so a dummy stub is sufficient and decouples these
163    /// tests from any specific adapter's diagnostic shape.
164    type TestSnapshot = BaselineSnapshot<DummyParseDiagnostic>;
165
166    /// Wrapper that pins `P = DummyParseDiagnostic`. Keeps the original
167    /// test bodies untouched (they were written before `load` was
168    /// generic over `P`).
169    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        // Use a future-unsupported version (99) — both 1 and 2 are
300        // accepted today.
301        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        // After the migration: v2 baselines (1-based contributor columns) load
330        // alongside v1 baselines.
331        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        // We don't deny_unknown_fields — future envelopes may add keys we
385        // don't care about (e.g. delta-on-delta, future view shapes).
386        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}