Skip to main content

banc_host/
evidence.rs

1//! Per-test evidence: everything observed while a test ran, attached to the
2//! failure when it fails and discarded when it passes.
3//!
4//! Producers (RTT readers, node subscriptions, instrument drivers) hold a
5//! cheap clone and `record()` from any task or thread.
6
7use std::fmt::Write as _;
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex};
10use std::time::Instant;
11
12#[derive(Clone)]
13pub struct Evidence {
14    inner: Arc<Mutex<Inner>>,
15}
16
17struct Inner {
18    test: String,
19    started: Instant,
20    entries: Vec<Entry>,
21}
22
23struct Entry {
24    at_us: u128,
25    source: &'static str,
26    line: String,
27}
28
29impl Evidence {
30    pub fn new(test: &str) -> Self {
31        Evidence {
32            inner: Arc::new(Mutex::new(Inner {
33                test: test.to_owned(),
34                started: Instant::now(),
35                entries: Vec::new(),
36            })),
37        }
38    }
39
40    /// Record one line from a named source ("defmt", "assistant:a0", ...).
41    /// Timestamped with host time relative to test start; this timestamp is
42    /// for correlating the narrative, never for timing assertions — those use
43    /// assistant-local timestamps carried inside the events themselves.
44    pub fn record(&self, source: &'static str, line: impl Into<String>) {
45        let mut inner = self.inner.lock().unwrap();
46        let at_us = inner.started.elapsed().as_micros();
47        inner.entries.push(Entry { at_us, source, line: line.into() });
48    }
49
50    pub fn is_empty(&self) -> bool {
51        self.inner.lock().unwrap().entries.is_empty()
52    }
53
54    /// Last `n` lines, formatted for inline display under a failure.
55    pub fn tail(&self, n: usize) -> String {
56        let inner = self.inner.lock().unwrap();
57        let skip = inner.entries.len().saturating_sub(n);
58        let mut out = String::new();
59        for e in &inner.entries[skip..] {
60            let _ = writeln!(out, "[{:>10.3}ms {}] {}", e.at_us as f64 / 1000.0, e.source, e.line);
61        }
62        out
63    }
64
65    /// Write the full log under `dir` and return the file path.
66    pub fn persist(&self, dir: &std::path::Path) -> std::io::Result<PathBuf> {
67        let inner = self.inner.lock().unwrap();
68        std::fs::create_dir_all(dir)?;
69        let path = dir.join(format!("{}.evidence.log", inner.test.replace(['/', ':'], "_")));
70        let mut out = String::new();
71        for e in &inner.entries {
72            let _ = writeln!(out, "[{:>10.3}ms {}] {}", e.at_us as f64 / 1000.0, e.source, e.line);
73        }
74        std::fs::write(&path, out)?;
75        Ok(path)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn tail_returns_last_lines() {
85        let ev = Evidence::new("t");
86        for i in 0..10 {
87            ev.record("test", format!("line {i}"));
88        }
89        let tail = ev.tail(3);
90        assert!(tail.contains("line 7") && tail.contains("line 9"));
91        assert!(!tail.contains("line 6"));
92    }
93
94    #[test]
95    fn persist_writes_file() {
96        let ev = Evidence::new("suite/case:1");
97        ev.record("x", "hello");
98        let dir = std::env::temp_dir().join("banc-evidence-test");
99        let path = ev.persist(&dir).unwrap();
100        let content = std::fs::read_to_string(&path).unwrap();
101        assert!(content.contains("hello"));
102        std::fs::remove_file(path).ok();
103    }
104}