Skip to main content

smb_server_testsuite/
recorder.rs

1//! Result persistence: one JSON file per run plus append-only CSVs, and a
2//! rebuilt aggregate `results.json` that the static UI renders. Everything
3//! lives under `test/data/` and is committed so history is versioned.
4
5use 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/// A full run: metadata plus every case result.
15#[derive(Serialize, Deserialize, Clone, Debug)]
16pub struct RunReport {
17    /// Unique run identifier (`<epoch>-<short-commit>`).
18    pub run_id: String,
19    /// Run start time (seconds since the Unix epoch).
20    pub timestamp_epoch: u64,
21    /// Git commit the server was built from.
22    pub commit: String,
23    /// Server version string.
24    pub server_version: String,
25    /// Host the run executed against.
26    pub host: String,
27    /// Total cases in the run.
28    pub total: usize,
29    /// Cases that passed.
30    pub passed: usize,
31    /// Cases that failed an assertion.
32    pub failed: usize,
33    /// Cases skipped (precondition unmet).
34    pub skipped: usize,
35    /// Cases that aborted unexpectedly.
36    pub errored: usize,
37    /// Total wall-clock duration in milliseconds.
38    pub duration_ms: u128,
39    /// Every case result in the run.
40    pub cases: Vec<CaseResult>,
41}
42
43impl RunReport {
44    /// Build a report from case results, filling in counts and metadata.
45    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    /// Fraction of non-skipped cases that passed (0.0 when none ran).
73    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    /// Length of an abbreviated git commit hash.
81    const SHORT_HASH_LEN: usize = 12;
82    commit.chars().take(SHORT_HASH_LEN).collect()
83}
84
85/// Persist a run and rebuild the aggregate the UI reads.
86pub 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
179/// Read every per-run JSON and produce the aggregate the UI loads.
180fn 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}