1use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12use crate::CaseResult;
13
14#[derive(Serialize, Deserialize, Clone, Debug)]
16pub struct RunReport {
17 pub run_id: String,
19 pub timestamp_epoch: u64,
21 pub commit: String,
23 pub server_version: String,
25 pub host: String,
27 pub total: usize,
29 pub passed: usize,
31 pub failed: usize,
33 pub skipped: usize,
35 pub errored: usize,
37 pub duration_ms: u128,
39 pub cases: Vec<CaseResult>,
41}
42
43impl RunReport {
44 pub fn build(cases: Vec<CaseResult>, commit: String, server_version: String, host: String) -> RunReport {
46 let epoch = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
47 let mut r = RunReport {
48 run_id: format!("{epoch}-{}", short(&commit)),
49 timestamp_epoch: epoch,
50 commit,
51 server_version,
52 host,
53 total: cases.len(),
54 passed: 0,
55 failed: 0,
56 skipped: 0,
57 errored: 0,
58 duration_ms: cases.iter().map(|c| c.duration_ms).sum(),
59 cases,
60 };
61 for c in &r.cases {
62 match c.status {
63 crate::Status::Pass => r.passed += 1,
64 crate::Status::Fail => r.failed += 1,
65 crate::Status::Skip => r.skipped += 1,
66 crate::Status::Error => r.errored += 1,
67 }
68 }
69 r
70 }
71
72 pub fn pass_rate(&self) -> f64 {
74 let ran = (self.total - self.skipped) as f64;
75 if ran <= 0.0 { 0.0 } else { self.passed as f64 / ran }
76 }
77}
78
79fn short(commit: &str) -> String {
80 const SHORT_HASH_LEN: usize = 12;
82 commit.chars().take(SHORT_HASH_LEN).collect()
83}
84
85pub fn record(report: &RunReport, data_dir: &Path) -> std::io::Result<PathBuf> {
87 let runs_dir = data_dir.join("runs");
88 fs::create_dir_all(&runs_dir)?;
89
90 let run_path = runs_dir.join(format!("{}.json", report.run_id));
91 fs::write(&run_path, serde_json::to_vec_pretty(report).expect("serialize run"))?;
92
93 append_summary(report, &data_dir.join("summary.csv"))?;
94 append_history(report, &data_dir.join("history.csv"))?;
95 rebuild_aggregate(&runs_dir, &data_dir.join("results.json"))?;
96 Ok(run_path)
97}
98
99fn append_summary(report: &RunReport, path: &Path) -> std::io::Result<()> {
100 let header = "run_id,timestamp_epoch,commit,server_version,total,passed,failed,skipped,errored,duration_ms,pass_rate\n";
101 let mut f = open_with_header(path, header)?;
102 writeln!(
103 f,
104 "{},{},{},{},{},{},{},{},{},{},{:.4}",
105 report.run_id,
106 report.timestamp_epoch,
107 short(&report.commit),
108 report.server_version,
109 report.total,
110 report.passed,
111 report.failed,
112 report.skipped,
113 report.errored,
114 report.duration_ms,
115 report.pass_rate(),
116 )
117}
118
119fn append_history(report: &RunReport, path: &Path) -> std::io::Result<()> {
120 let header = "run_id,timestamp_epoch,category,id,status,duration_ms\n";
121 let mut f = open_with_header(path, header)?;
122 for c in &report.cases {
123 writeln!(
124 f,
125 "{},{},{},{},{:?},{}",
126 report.run_id,
127 report.timestamp_epoch,
128 c.category,
129 c.id,
130 c.status,
131 c.duration_ms,
132 )?;
133 }
134 Ok(())
135}
136
137fn open_with_header(path: &Path, header: &str) -> std::io::Result<fs::File> {
138 let fresh = !path.exists();
139 let mut f = fs::OpenOptions::new().create(true).append(true).open(path)?;
140 if fresh {
141 f.write_all(header.as_bytes())?;
142 }
143 Ok(f)
144}
145
146#[derive(Serialize)]
147struct RunSummary {
148 run_id: String,
149 timestamp_epoch: u64,
150 commit: String,
151 server_version: String,
152 host: String,
153 total: usize,
154 passed: usize,
155 failed: usize,
156 skipped: usize,
157 errored: usize,
158 duration_ms: u128,
159 pass_rate: f64,
160}
161
162#[derive(Serialize)]
163struct CategorySummary {
164 name: String,
165 pass: usize,
166 fail: usize,
167 skip: usize,
168 error: usize,
169}
170
171#[derive(Serialize)]
172struct Aggregate {
173 generated_epoch: u64,
174 runs: Vec<RunSummary>,
175 latest: Option<RunReport>,
176 categories: Vec<CategorySummary>,
177}
178
179fn rebuild_aggregate(runs_dir: &Path, out: &Path) -> std::io::Result<()> {
181 let mut reports: Vec<RunReport> = Vec::new();
182 for entry in fs::read_dir(runs_dir)? {
183 let path = entry?.path();
184 if path.extension().and_then(|e| e.to_str()) != Some("json") {
185 continue;
186 }
187 if let Ok(bytes) = fs::read(&path)
188 && let Ok(r) = serde_json::from_slice::<RunReport>(&bytes) {
189 reports.push(r);
190 }
191 }
192 reports.sort_by(|a, b| b.timestamp_epoch.cmp(&a.timestamp_epoch));
193
194 let runs = reports
195 .iter()
196 .map(|r| RunSummary {
197 run_id: r.run_id.clone(),
198 timestamp_epoch: r.timestamp_epoch,
199 commit: short(&r.commit),
200 server_version: r.server_version.clone(),
201 host: r.host.clone(),
202 total: r.total,
203 passed: r.passed,
204 failed: r.failed,
205 skipped: r.skipped,
206 errored: r.errored,
207 duration_ms: r.duration_ms,
208 pass_rate: r.pass_rate(),
209 })
210 .collect();
211
212 let latest = reports.first().cloned();
213 let categories = latest.as_ref().map(category_summaries).unwrap_or_default();
214
215 let aggregate = Aggregate {
216 generated_epoch: SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
217 runs,
218 latest,
219 categories,
220 };
221 fs::write(out, serde_json::to_vec_pretty(&aggregate).expect("serialize aggregate"))
222}
223
224fn category_summaries(report: &RunReport) -> Vec<CategorySummary> {
225 let mut order: Vec<String> = Vec::new();
226 let mut map: std::collections::BTreeMap<String, CategorySummary> = std::collections::BTreeMap::new();
227 for c in &report.cases {
228 if !map.contains_key(&c.category) {
229 order.push(c.category.clone());
230 }
231 let e = map.entry(c.category.clone()).or_insert_with(|| CategorySummary {
232 name: c.category.clone(),
233 pass: 0,
234 fail: 0,
235 skip: 0,
236 error: 0,
237 });
238 match c.status {
239 crate::Status::Pass => e.pass += 1,
240 crate::Status::Fail => e.fail += 1,
241 crate::Status::Skip => e.skip += 1,
242 crate::Status::Error => e.error += 1,
243 }
244 }
245 order.into_iter().filter_map(|k| map.remove(&k)).collect()
246}