Skip to main content

crap_core/adapters/
baseline.rs

1//! Baseline-envelope loader — reads a previously-emitted crap4rs 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 (#107 bumped the current emit version 1 → 2 in 0.4.0; v1
15//! baselines remain loadable because delta matching is identity-keyed,
16//! not 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>` (S2's decomposition); crap4rs
42/// concretizes to `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(
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
110/// Load a crap4rs JSON envelope from disk and return the baseline
111/// snapshot. Streams the file through a `BufReader` rather than
112/// reading the whole envelope into memory — large codebases produce
113/// envelopes in the multi-MB range and there's no reason to allocate
114/// that twice.
115///
116/// Generic over `P: ParseDiagnostic` so each adapter (LCOV, Istanbul,
117/// …) supplies its own concrete diagnostic shape. The crap4rs shim
118/// `crap4rs::adapters::baseline::load` instantiates `P =
119/// LcovParseDiagnostic` so v0.4 callers' import paths stay byte-
120/// identical.
121pub 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    /// Concrete `P` for tests in this module — the loader's behavior is
163    /// `P`-agnostic for the cases we test (none reach into per-variant
164    /// fields), so the dummy stub keeps the assertions byte-identical
165    /// to crap4rs's pre-S3 unit suite.
166    type TestSnapshot = BaselineSnapshot<DummyParseDiagnostic>;
167
168    /// Wrapper that pins `P = DummyParseDiagnostic`. Keeps the original
169    /// test bodies untouched (they were written before `load` was
170    /// generic over `P`).
171    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        // Use a future-unsupported version (99) — both 1 and 2 are
302        // accepted today after the #107 column-convention bump.
303        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        // Post-#107: v2 baselines (1-based contributor columns) load
332        // alongside v1 baselines.
333        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        // We don't deny_unknown_fields — future envelopes may add keys we
387        // don't care about (e.g. delta-on-delta, future view shapes).
388        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}