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 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 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 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 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 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
245pub fn compute_config_fingerprint(config_path: &Path) -> String {
247 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}