Skip to main content

assay_core/baseline/
mod.rs

1pub mod report;
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fs::File;
6use std::path::Path;
7
8#[derive(Debug, Serialize, Deserialize, Clone)]
9pub struct Baseline {
10    pub schema_version: u32,
11    pub suite: String,
12    pub assay_version: String,
13    pub created_at: String,
14    pub config_fingerprint: String,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub git_info: Option<GitInfo>,
17    pub entries: Vec<BaselineEntry>,
18}
19
20#[derive(Debug, Serialize, Deserialize, Clone)]
21pub struct GitInfo {
22    pub commit: String,
23    pub branch: Option<String>,
24    pub dirty: bool,
25    pub author: Option<String>,
26    pub timestamp: Option<String>,
27}
28
29#[derive(Debug, Serialize, Deserialize, Clone)]
30pub struct BaselineEntry {
31    pub test_id: String,
32    pub metric: String,
33    pub score: f64,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub meta: Option<serde_json::Value>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct BaselineDiff {
40    pub regressions: Vec<Regression>,
41    pub improvements: Vec<Improvement>,
42    pub new_tests: Vec<String>,
43    pub missing_tests: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct Regression {
48    pub test_id: String,
49    pub metric: String,
50    pub baseline_score: f64,
51    pub candidate_score: f64,
52    pub delta: f64,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct Improvement {
57    pub test_id: String,
58    pub metric: String,
59    pub baseline_score: f64,
60    pub candidate_score: f64,
61    pub delta: f64,
62}
63
64impl Baseline {
65    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
66        let path = path.as_ref();
67        let file = File::open(path)
68            .with_context(|| format!("failed to open baseline file: {}", path.display()))?;
69        let baseline: Baseline =
70            serde_json::from_reader(file).context("failed to parse baseline JSON")?;
71
72        if baseline.schema_version != 1 {
73            anyhow::bail!(
74                "config error: unsupported baseline schema version {}",
75                baseline.schema_version
76            );
77        }
78
79        // Note: Suite mismatch and assay version checks are handled in `validate()` to separate structural loading from semantic validation.
80
81        Ok(baseline)
82    }
83
84    pub fn validate(&self, current_suite: &str, current_fingerprint: &str) -> Result<()> {
85        if self.suite != current_suite {
86            anyhow::bail!(
87                "config error: baseline suite mismatch (expected '{}', found '{}')",
88                current_suite,
89                self.suite
90            );
91        }
92
93        let current_ver = env!("CARGO_PKG_VERSION");
94        if self.assay_version != current_ver {
95            eprintln!(
96                "warning: baseline generated with assay v{} (current: v{})",
97                self.assay_version, current_ver
98            );
99        }
100
101        if self.config_fingerprint != current_fingerprint {
102            eprintln!(
103                "warning: config fingerprint mismatch (baseline config differs from current runtime config).\n\
104                 hint: run with --export-baseline to update the baseline if config changes are intentional."
105            );
106        }
107
108        Ok(())
109    }
110
111    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
112        let path = path.as_ref();
113        if let Some(parent) = path.parent() {
114            std::fs::create_dir_all(parent)?;
115        }
116        let file = File::create(path)
117            .with_context(|| format!("failed to create baseline file: {}", path.display()))?;
118
119        // Create a sorted clone for deterministic output
120        let mut sorted = self.clone();
121        sorted.entries.sort_by(|a, b| {
122            a.test_id
123                .cmp(&b.test_id)
124                .then_with(|| a.metric.cmp(&b.metric))
125        });
126
127        // Use pretty print for git diffability
128        serde_json::to_writer_pretty(file, &sorted).context("failed to write baseline JSON")?;
129        Ok(())
130    }
131
132    // Helper to get score for a test+metric
133    pub fn get_score(&self, test_id: &str, metric: &str) -> Option<f64> {
134        self.entries
135            .iter()
136            .find(|e| e.test_id == test_id && e.metric == metric)
137            .map(|e| e.score)
138    }
139
140    /// Whether the baseline run actually evaluated this metric.
141    ///
142    /// `None` means the baseline predates the `exercised` dimension (#1949 layer 2) and says
143    /// nothing either way — which is not the same as saying the metric was not exercised, and is
144    /// why the coverage check treats it as no evidence rather than as a drop.
145    pub fn was_exercised(&self, test_id: &str, metric: &str) -> Option<bool> {
146        self.entries
147            .iter()
148            .find(|e| e.test_id == test_id && e.metric == metric)
149            .and_then(|e| e.meta.as_ref())
150            .and_then(|m| m.get("exercised"))
151            .and_then(|v| v.as_str())
152            .map(|s| s == "exercised")
153    }
154
155    pub fn diff(&self, candidate: &Baseline) -> BaselineDiff {
156        let mut regressions = Vec::new();
157        let mut improvements = Vec::new();
158        let mut new_tests = Vec::new();
159        let mut missing_tests = Vec::new();
160
161        // Map baseline entries by (test_id, metric) for quick lookup
162        let mut baseline_map = std::collections::HashMap::new();
163        for entry in &self.entries {
164            baseline_map.insert((entry.test_id.clone(), entry.metric.clone()), entry.score);
165        }
166
167        let mut candidate_seen = std::collections::HashSet::new();
168
169        for entry in &candidate.entries {
170            candidate_seen.insert((entry.test_id.clone(), entry.metric.clone()));
171
172            if let Some(baseline_score) =
173                baseline_map.get(&(entry.test_id.clone(), entry.metric.clone()))
174            {
175                let delta = entry.score - baseline_score;
176                // Floating point comparison with epsilon?
177                // For now, exact logic, but maybe ignore tiny deltas.
178                if delta < -0.000001 {
179                    regressions.push(Regression {
180                        test_id: entry.test_id.clone(),
181                        metric: entry.metric.clone(),
182                        baseline_score: *baseline_score,
183                        candidate_score: entry.score,
184                        delta,
185                    });
186                } else if delta > 0.000001 {
187                    improvements.push(Improvement {
188                        test_id: entry.test_id.clone(),
189                        metric: entry.metric.clone(),
190                        baseline_score: *baseline_score,
191                        candidate_score: entry.score,
192                        delta,
193                    });
194                }
195            } else {
196                new_tests.push(format!("{} (metric: {})", entry.test_id, entry.metric));
197            }
198        }
199
200        // Identify missing
201        for (test_id, metric) in baseline_map.keys() {
202            if !candidate_seen.contains(&(test_id.clone(), metric.clone())) {
203                missing_tests.push(format!("{} (metric: {})", test_id, metric));
204            }
205        }
206
207        // Sort results for stability
208        regressions.sort_by(|a, b| a.test_id.cmp(&b.test_id).then(a.metric.cmp(&b.metric)));
209        improvements.sort_by(|a, b| a.test_id.cmp(&b.test_id).then(a.metric.cmp(&b.metric)));
210        new_tests.sort();
211        missing_tests.sort();
212
213        BaselineDiff {
214            regressions,
215            improvements,
216            new_tests,
217            missing_tests,
218        }
219    }
220
221    pub fn from_coverage_report(
222        report: &crate::coverage::CoverageReport,
223        suite: String,
224        config_fingerprint: String,
225        git_info: Option<GitInfo>,
226    ) -> Self {
227        let entries = vec![
228            BaselineEntry {
229                test_id: "coverage".to_string(),
230                metric: "overall".to_string(),
231                score: report.overall_coverage_pct,
232                meta: None,
233            },
234            BaselineEntry {
235                test_id: "coverage".to_string(),
236                metric: "tool".to_string(),
237                score: report.tool_coverage.coverage_pct,
238                meta: None,
239            },
240            BaselineEntry {
241                test_id: "coverage".to_string(),
242                metric: "rule".to_string(),
243                score: report.rule_coverage.coverage_pct,
244                meta: None,
245            },
246        ];
247
248        Self {
249            schema_version: 1,
250            suite,
251            assay_version: env!("CARGO_PKG_VERSION").to_string(),
252            created_at: chrono::Utc::now().to_rfc3339(),
253            config_fingerprint,
254            git_info,
255            entries,
256        }
257    }
258}
259
260// Fingerprint logic could go here or in util
261pub fn compute_config_fingerprint(config_path: &Path) -> String {
262    // For MVP, just hash the config file content if it exists.
263    // In future, canonicalize logic.
264    if let Ok(content) = std::fs::read(config_path) {
265        let digest = md5::compute(content);
266        format!("md5:{:x}", digest)
267    } else {
268        "md5:unknown".to_string()
269    }
270}
271
272#[cfg(test)]
273mod was_exercised_tests {
274    use super::*;
275
276    fn baseline_with(meta: Option<serde_json::Value>) -> Baseline {
277        Baseline {
278            schema_version: 1,
279            suite: "s".into(),
280            assay_version: "test".into(),
281            created_at: "2026-08-06T00:00:00Z".into(),
282            config_fingerprint: "fp".into(),
283            git_info: None,
284            entries: vec![BaselineEntry {
285                test_id: "t1".into(),
286                metric: "semantic".into(),
287                score: 0.87,
288                meta,
289            }],
290        }
291    }
292
293    #[test]
294    fn an_exercised_baseline_entry_reads_as_exercised() {
295        let b = baseline_with(Some(serde_json::json!({"exercised": "exercised"})));
296        assert_eq!(b.was_exercised("t1", "semantic"), Some(true));
297    }
298
299    #[test]
300    fn a_not_applicable_baseline_entry_reads_as_not_exercised() {
301        let b = baseline_with(Some(serde_json::json!({"exercised": "not_applicable"})));
302        assert_eq!(b.was_exercised("t1", "semantic"), Some(false));
303    }
304
305    /// A baseline written before the dimension existed says nothing either way.
306    ///
307    /// `None` rather than `Some(false)` is the whole point: treating silence as "was not exercised"
308    /// would make every pre-existing baseline look like it had no coverage, and the coverage check
309    /// would then never fire because there is nothing to have lost.
310    #[test]
311    fn a_baseline_predating_the_dimension_says_nothing() {
312        assert_eq!(baseline_with(None).was_exercised("t1", "semantic"), None);
313        let b = baseline_with(Some(serde_json::json!({"other": "field"})));
314        assert_eq!(b.was_exercised("t1", "semantic"), None);
315    }
316
317    #[test]
318    fn an_absent_entry_says_nothing() {
319        let b = baseline_with(Some(serde_json::json!({"exercised": "exercised"})));
320        assert_eq!(b.was_exercised("t1", "nosuch"), None);
321        assert_eq!(b.was_exercised("nosuch", "semantic"), None);
322    }
323}