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    pub fn diff(&self, candidate: &Baseline) -> BaselineDiff {
141        let mut regressions = Vec::new();
142        let mut improvements = Vec::new();
143        let mut new_tests = Vec::new();
144        let mut missing_tests = Vec::new();
145
146        // Map baseline entries by (test_id, metric) for quick lookup
147        let mut baseline_map = std::collections::HashMap::new();
148        for entry in &self.entries {
149            baseline_map.insert((entry.test_id.clone(), entry.metric.clone()), entry.score);
150        }
151
152        let mut candidate_seen = std::collections::HashSet::new();
153
154        for entry in &candidate.entries {
155            candidate_seen.insert((entry.test_id.clone(), entry.metric.clone()));
156
157            if let Some(baseline_score) =
158                baseline_map.get(&(entry.test_id.clone(), entry.metric.clone()))
159            {
160                let delta = entry.score - baseline_score;
161                // Floating point comparison with epsilon?
162                // For now, exact logic, but maybe ignore tiny deltas.
163                if delta < -0.000001 {
164                    regressions.push(Regression {
165                        test_id: entry.test_id.clone(),
166                        metric: entry.metric.clone(),
167                        baseline_score: *baseline_score,
168                        candidate_score: entry.score,
169                        delta,
170                    });
171                } else if delta > 0.000001 {
172                    improvements.push(Improvement {
173                        test_id: entry.test_id.clone(),
174                        metric: entry.metric.clone(),
175                        baseline_score: *baseline_score,
176                        candidate_score: entry.score,
177                        delta,
178                    });
179                }
180            } else {
181                new_tests.push(format!("{} (metric: {})", entry.test_id, entry.metric));
182            }
183        }
184
185        // Identify missing
186        for (test_id, metric) in baseline_map.keys() {
187            if !candidate_seen.contains(&(test_id.clone(), metric.clone())) {
188                missing_tests.push(format!("{} (metric: {})", test_id, metric));
189            }
190        }
191
192        // Sort results for stability
193        regressions.sort_by(|a, b| a.test_id.cmp(&b.test_id).then(a.metric.cmp(&b.metric)));
194        improvements.sort_by(|a, b| a.test_id.cmp(&b.test_id).then(a.metric.cmp(&b.metric)));
195        new_tests.sort();
196        missing_tests.sort();
197
198        BaselineDiff {
199            regressions,
200            improvements,
201            new_tests,
202            missing_tests,
203        }
204    }
205
206    pub fn from_coverage_report(
207        report: &crate::coverage::CoverageReport,
208        suite: String,
209        config_fingerprint: String,
210        git_info: Option<GitInfo>,
211    ) -> Self {
212        let entries = vec![
213            BaselineEntry {
214                test_id: "coverage".to_string(),
215                metric: "overall".to_string(),
216                score: report.overall_coverage_pct,
217                meta: None,
218            },
219            BaselineEntry {
220                test_id: "coverage".to_string(),
221                metric: "tool".to_string(),
222                score: report.tool_coverage.coverage_pct,
223                meta: None,
224            },
225            BaselineEntry {
226                test_id: "coverage".to_string(),
227                metric: "rule".to_string(),
228                score: report.rule_coverage.coverage_pct,
229                meta: None,
230            },
231        ];
232
233        Self {
234            schema_version: 1,
235            suite,
236            assay_version: env!("CARGO_PKG_VERSION").to_string(),
237            created_at: chrono::Utc::now().to_rfc3339(),
238            config_fingerprint,
239            git_info,
240            entries,
241        }
242    }
243}
244
245// Fingerprint logic could go here or in util
246pub fn compute_config_fingerprint(config_path: &Path) -> String {
247    // For MVP, just hash the config file content if it exists.
248    // In future, canonicalize logic.
249    if let Ok(content) = std::fs::read(config_path) {
250        let digest = md5::compute(content);
251        format!("md5:{:x}", digest)
252    } else {
253        "md5:unknown".to_string()
254    }
255}