1use 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, pub platform: String, pub commit: String, pub ran_at: String, pub result: String, pub falsifiable: Option<bool>, }
18
19pub fn test_key(reference: &str) -> String {
21 let digest = Sha256::digest(reference.as_bytes());
22 hex::encode(&digest[..6])
23}
24
25pub 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
68pub 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
80pub 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 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 let r = from_value(&v).expect("valid");
147 assert_eq!(r.falsifiable, Some(false));
149 }
150
151 #[test]
152 fn from_value_should_default_falsifiable_to_none_when_absent() {
153 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 let r = from_value(&v).expect("valid");
161 assert_eq!(r.falsifiable, None);
163 }
164
165 #[test]
166 fn test_key_should_be_stable_and_12_hex_when_given_a_ref() {
167 let reference = "pytest tests/test_redis_absent.py";
169
170 let a = test_key(reference);
172 let b = test_key(reference);
173
174 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 let (_p, s) = store();
184 let r = receipt("linux-ci", "2026-01-01T00:00:00Z", "green");
185
186 append(&s, &r).unwrap();
188 let back = read_for(&s, "pytest x").unwrap();
189
190 assert_eq!(back, vec![r]);
192 }
193
194 #[test]
195 fn read_for_should_return_empty_when_no_receipt_file_exists() {
196 let (_p, s) = store();
198
199 let back = read_for(&s, "pytest never-run").unwrap();
201
202 assert!(back.is_empty());
204 }
205
206 #[test]
207 fn from_value_should_reject_the_receipt_when_result_is_not_in_the_enum() {
208 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 let parsed = from_value(&v);
217
218 assert!(parsed.is_err());
220 }
221}