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 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 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 serde_json::to_writer_pretty(file, &sorted).context("failed to write baseline JSON")?;
129 Ok(())
130 }
131
132 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 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 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 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 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 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
260pub fn compute_config_fingerprint(config_path: &Path) -> String {
262 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 #[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}