Skip to main content

ev/
receipt.rs

1//! Run-receipts: the non-hashed evidence that a bound test ran — one JSON object
2//! per line in results/receipts/<test-key>.jsonl. Deleting receipts never changes a
3//! tick id (the hashed/cached split). Unsigned, trust-on-write for 0.1.0.
4use crate::store::Store;
5use serde_json::{Map, Value};
6use sha2::{Digest, Sha256};
7use std::io::Write;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct Receipt {
11    pub test: String,              // == the bound check ref, byte-for-byte
12    pub platform: String,          // one of the binding's liveness.platforms
13    pub commit: String,            // 40-hex sha
14    pub ran_at: String,            // RFC 3339 UTC
15    pub result: String,            // "green" | "red" | "gray"
16    pub falsifiable: Option<bool>, // counter-test produced the opposite? set by --run; non-hashed
17}
18
19/// Stable, filesystem-safe key for a bound test's receipt log: first 12 hex of SHA-256(ref).
20pub fn test_key(reference: &str) -> String {
21    let digest = Sha256::digest(reference.as_bytes());
22    hex::encode(&digest[..6])
23}
24
25/// Strict parse of one receipt line: closed schema + result enum.
26pub fn from_value(v: &Value) -> Result<Receipt, String> {
27    use crate::tick::{only_keys, req_str};
28    let obj = v.as_object().ok_or("receipt is not an object")?;
29    only_keys(
30        obj,
31        &[
32            "test",
33            "platform",
34            "commit",
35            "ran_at",
36            "result",
37            "falsifiable",
38        ],
39        "receipt",
40    )?;
41    let result = req_str(obj, "result")?;
42    if !["green", "red", "gray"].contains(&result.as_str()) {
43        return Err(format!("receipt.result must be green|red|gray: {result}"));
44    }
45    Ok(Receipt {
46        test: req_str(obj, "test")?,
47        platform: req_str(obj, "platform")?,
48        commit: req_str(obj, "commit")?,
49        ran_at: req_str(obj, "ran_at")?,
50        result,
51        falsifiable: obj.get("falsifiable").and_then(|x| x.as_bool()),
52    })
53}
54
55fn to_line(r: &Receipt) -> String {
56    let mut m = Map::new();
57    m.insert("test".into(), Value::String(r.test.clone()));
58    m.insert("platform".into(), Value::String(r.platform.clone()));
59    m.insert("commit".into(), Value::String(r.commit.clone()));
60    m.insert("ran_at".into(), Value::String(r.ran_at.clone()));
61    m.insert("result".into(), Value::String(r.result.clone()));
62    if let Some(b) = r.falsifiable {
63        m.insert("falsifiable".into(), Value::Bool(b));
64    }
65    serde_json::to_string(&Value::Object(m)).expect("serializable")
66}
67
68/// Append one receipt as a JSON line to results/receipts/<test-key>.jsonl.
69pub fn append(store: &Store, r: &Receipt) -> std::io::Result<()> {
70    let dir = store.root.join("results").join("receipts");
71    std::fs::create_dir_all(&dir)?;
72    let path = dir.join(format!("{}.jsonl", test_key(&r.test)));
73    let mut f = std::fs::OpenOptions::new()
74        .create(true)
75        .append(true)
76        .open(path)?;
77    writeln!(f, "{}", to_line(r))
78}
79
80/// Read every receipt for a bound test ref (its <test-key>.jsonl). Empty vec if the file is absent.
81pub fn read_for(store: &Store, reference: &str) -> std::io::Result<Vec<Receipt>> {
82    let path = store
83        .root
84        .join("results")
85        .join("receipts")
86        .join(format!("{}.jsonl", test_key(reference)));
87    let text = match std::fs::read_to_string(&path) {
88        Ok(t) => t,
89        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
90        Err(e) => return Err(e),
91    };
92    let mut out = Vec::new();
93    for line in text.lines() {
94        if line.trim().is_empty() {
95            continue;
96        }
97        let v: Value = serde_json::from_str(line)
98            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
99        out.push(
100            from_value(&v).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
101        );
102    }
103    Ok(out)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::store::Store;
110
111    fn store() -> (std::path::PathBuf, Store) {
112        use std::sync::atomic::{AtomicU64, Ordering};
113        static N: AtomicU64 = AtomicU64::new(0);
114        let p = std::env::temp_dir().join(format!(
115            "ev-receipt-{}-{}",
116            std::process::id(),
117            N.fetch_add(1, Ordering::Relaxed)
118        ));
119        let _ = std::fs::remove_dir_all(&p);
120        std::fs::create_dir_all(&p).unwrap();
121        let s = Store::at(&p);
122        s.init().unwrap();
123        (p, s)
124    }
125
126    fn receipt(platform: &str, ran_at: &str, result: &str) -> Receipt {
127        Receipt {
128            test: "pytest x".into(),
129            platform: platform.into(),
130            commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
131            ran_at: ran_at.into(),
132            result: result.into(),
133            falsifiable: None,
134        }
135    }
136
137    #[test]
138    fn from_value_should_round_trip_falsifiable_when_present() {
139        // given: a receipt line carrying falsifiable=false
140        let v = serde_json::json!({
141            "test": "pytest x", "platform": "linux-ci",
142            "commit": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
143            "ran_at": "2026-01-01T00:00:00Z", "result": "green", "falsifiable": false
144        });
145        // when: parsed
146        let r = from_value(&v).expect("valid");
147        // then: falsifiable is preserved
148        assert_eq!(r.falsifiable, Some(false));
149    }
150
151    #[test]
152    fn from_value_should_default_falsifiable_to_none_when_absent() {
153        // given: a receipt with no falsifiable field
154        let v = serde_json::json!({
155            "test": "pytest x", "platform": "linux-ci",
156            "commit": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
157            "ran_at": "2026-01-01T00:00:00Z", "result": "green"
158        });
159        // when: parsed
160        let r = from_value(&v).expect("valid");
161        // then: falsifiable is None (absent = not evaluated)
162        assert_eq!(r.falsifiable, None);
163    }
164
165    #[test]
166    fn test_key_should_be_stable_and_12_hex_when_given_a_ref() {
167        // given: a check reference
168        let reference = "pytest tests/test_redis_absent.py";
169
170        // when: its receipt-log key is computed twice
171        let a = test_key(reference);
172        let b = test_key(reference);
173
174        // then: it is a stable 12-char lowercase-hex string
175        assert_eq!(a, b);
176        assert_eq!(a.len(), 12);
177        assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
178    }
179
180    #[test]
181    fn append_then_read_for_should_round_trip_the_receipt_when_one_is_written() {
182        // given: an initialized store and a green receipt
183        let (_p, s) = store();
184        let r = receipt("linux-ci", "2026-01-01T00:00:00Z", "green");
185
186        // when: it is appended and read back by ref
187        append(&s, &r).unwrap();
188        let back = read_for(&s, "pytest x").unwrap();
189
190        // then: exactly that receipt round-trips
191        assert_eq!(back, vec![r]);
192    }
193
194    #[test]
195    fn read_for_should_return_empty_when_no_receipt_file_exists() {
196        // given: an initialized store with no receipts
197        let (_p, s) = store();
198
199        // when: receipts are read for an unbound ref
200        let back = read_for(&s, "pytest never-run").unwrap();
201
202        // then: the result is empty (absence is not an error)
203        assert!(back.is_empty());
204    }
205
206    #[test]
207    fn from_value_should_reject_the_receipt_when_result_is_not_in_the_enum() {
208        // given: a receipt line whose result is outside green|red|gray
209        let v = serde_json::json!({
210            "test": "pytest x", "platform": "linux-ci",
211            "commit": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
212            "ran_at": "2026-01-01T00:00:00Z", "result": "purple"
213        });
214
215        // when: it is parsed
216        let parsed = from_value(&v);
217
218        // then: parsing fails
219        assert!(parsed.is_err());
220    }
221}