1use crate::receipt::Receipt;
8use crate::selected::SelectedList;
9use crate::tick::{Check, Ground};
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum Verdict {
13 Green,
14 Red,
15 GrayRed,
16 Unproven,
17 NotRun { missing_platforms: Vec<String> },
18 Stale { reason: String },
19 SilentlyUnbound,
20 Exempt, Memo, NotApplicable, }
24
25impl Verdict {
26 pub fn label(&self) -> &'static str {
28 match self {
29 Verdict::Green => "green",
30 Verdict::Red => "red",
31 Verdict::GrayRed => "gray->red",
32 Verdict::Unproven => "unproven",
33 Verdict::NotRun { .. } => "not-run",
34 Verdict::Stale { .. } => "stale",
35 Verdict::SilentlyUnbound => "silently-unbound",
36 Verdict::Exempt => "exempt",
37 Verdict::Memo => "memo",
38 Verdict::NotApplicable => "n/a",
39 }
40 }
41}
42
43use time::{format_description::well_known::Rfc3339, OffsetDateTime};
44
45pub struct Ctx {
48 pub live_origin_sha: Option<String>, pub selected: Option<SelectedList>, pub now_unix: i64, pub staleness_secs: i64, pub attest: Option<Vec<String>>, }
54
55pub fn verdict_for(
57 ground: &Ground,
58 receipts: &[Receipt],
59 ctx: &Ctx,
60 triggered_since: bool,
61) -> Verdict {
62 let (reference, verified_at_sha, liveness) = match &ground.check {
63 Some(Check::Test {
64 reference,
65 verified_at_sha,
66 liveness,
67 ..
68 }) => (reference.as_str(), verified_at_sha.as_str(), liveness),
69 _ => return Verdict::NotApplicable,
70 };
71
72 if let Some(origin) = ctx.live_origin_sha.as_deref() {
73 if origin != verified_at_sha {
74 return Verdict::Stale {
75 reason: "verified_at_sha behind live origin".into(),
76 };
77 }
78 }
79
80 let attested: Vec<&String> = match &ctx.attest {
83 Some(set) => liveness
84 .platforms
85 .iter()
86 .filter(|p| set.contains(p))
87 .collect(),
88 None => liveness.platforms.iter().collect(),
89 };
90 if ctx.attest.is_some() && attested.is_empty() {
91 return Verdict::Exempt; }
93 let mut missing = Vec::new();
94 let mut deciding: Vec<&Receipt> = Vec::new();
95 for p in attested {
96 let latest = receipts
98 .iter()
99 .filter(|r| r.test == reference && &r.platform == p)
100 .max_by(|a, b| a.ran_at.cmp(&b.ran_at));
101 match latest {
102 None => missing.push(p.clone()),
103 Some(r) => deciding.push(r),
104 }
105 }
106 if !missing.is_empty() {
107 return Verdict::NotRun {
108 missing_platforms: missing,
109 };
110 }
111
112 if triggered_since {
116 return Verdict::Stale {
117 reason: "a triggering change landed after the last run".into(),
118 };
119 }
120
121 let stale_by_age = deciding.iter().any(|r| {
124 OffsetDateTime::parse(&r.ran_at, &Rfc3339)
125 .map(|dt| ctx.now_unix - dt.unix_timestamp() > ctx.staleness_secs)
126 .unwrap_or(false)
127 });
128 if stale_by_age {
129 return Verdict::Stale {
130 reason: "deciding receipt older than the staleness window".into(),
131 };
132 }
133
134 if deciding.iter().any(|r| r.falsifiable == Some(false)) {
137 return Verdict::Unproven;
138 }
139
140 if deciding.iter().any(|r| r.result == "gray") {
141 return Verdict::GrayRed;
142 }
143 if deciding.iter().any(|r| r.result == "red") {
144 return Verdict::Red;
145 }
146
147 if let Some(sl) = ctx.selected.as_ref() {
150 let touched = liveness
151 .triggered_by
152 .iter()
153 .any(|t| sl.changed.iter().any(|c| c == t));
154 let was_selected = sl.selected.iter().any(|s| s == reference);
155 if touched && !was_selected {
156 return Verdict::SilentlyUnbound;
157 }
158 }
159
160 Verdict::Green
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::receipt::Receipt;
167 use crate::tick::{Check, Ground, Liveness};
168
169 fn test_ground(platforms: &[&str]) -> Ground {
170 Ground {
171 claim: "no Redis".into(),
172 supports: "chosen".into(),
173 check: Some(Check::Test {
174 reference: "pytest x".into(),
175 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
176 counter_test: Some("pytest x::flips".into()),
177 liveness: Liveness {
178 platforms: platforms.iter().map(|s| s.to_string()).collect(),
179 triggered_by: vec!["pyproject.toml".into()],
180 surfaces: vec!["pyproject-deps".into()],
181 },
182 }),
183 }
184 }
185 fn rcpt(platform: &str, ran_at: &str, result: &str) -> Receipt {
186 Receipt {
187 test: "pytest x".into(),
188 platform: platform.into(),
189 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
190 ran_at: ran_at.into(),
191 result: result.into(),
192 falsifiable: None,
193 }
194 }
195 fn ctx(live_origin_sha: Option<&str>, selected: Option<SelectedList>) -> Ctx {
197 Ctx {
198 live_origin_sha: live_origin_sha.map(|s| s.to_string()),
199 selected,
200 now_unix: 0,
201 staleness_secs: i64::MAX,
202 attest: None,
203 }
204 }
205 fn ctx_attest(attest: Option<&[&str]>) -> Ctx {
206 Ctx {
207 live_origin_sha: None,
208 selected: None,
209 now_unix: 0,
210 staleness_secs: i64::MAX,
211 attest: attest.map(|a| a.iter().map(|s| s.to_string()).collect()),
212 }
213 }
214
215 #[test]
216 fn verdict_for_should_be_not_applicable_when_the_ground_has_a_person_check() {
217 let g = Ground {
219 claim: "c".into(),
220 supports: "chosen".into(),
221 check: Some(Check::Person {
222 reference: "Q3".into(),
223 }),
224 };
225
226 let v = verdict_for(&g, &[], &ctx(None, None), false);
228
229 assert_eq!(v, Verdict::NotApplicable);
231 }
232
233 #[test]
234 fn verdict_for_should_be_not_run_when_a_declared_platform_has_no_receipt() {
235 let g = test_ground(&["linux-ci", "mac"]);
237 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
238
239 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
241
242 assert_eq!(
244 v,
245 Verdict::NotRun {
246 missing_platforms: vec!["mac".into()]
247 }
248 );
249 }
250
251 #[test]
252 fn verdict_for_should_promote_gray_to_red_when_the_deciding_receipt_is_gray() {
253 let g = test_ground(&["linux-ci"]);
255 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "gray")];
256
257 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
259
260 assert_eq!(v, Verdict::GrayRed);
262 }
263
264 #[test]
265 fn verdict_for_should_be_red_when_the_latest_receipt_is_red() {
266 let g = test_ground(&["linux-ci"]);
268 let receipts = vec![
269 rcpt("linux-ci", "2026-01-01T00:00:00Z", "green"),
270 rcpt("linux-ci", "2026-02-01T00:00:00Z", "red"),
271 ];
272
273 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
275
276 assert_eq!(v, Verdict::Red);
278 }
279
280 #[test]
281 fn verdict_for_should_be_green_when_every_platform_has_a_fresh_green_receipt() {
282 let g = test_ground(&["linux-ci", "mac"]);
284 let receipts = vec![
285 rcpt("linux-ci", "2026-01-01T00:00:00Z", "green"),
286 rcpt("mac", "2026-01-01T00:00:00Z", "green"),
287 ];
288
289 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
291
292 assert_eq!(v, Verdict::Green);
294 }
295
296 #[test]
297 fn verdict_for_should_be_stale_when_verified_at_sha_is_behind_the_live_origin() {
298 let g = test_ground(&["linux-ci"]);
300 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
301 let origin = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
302
303 let v = verdict_for(&g, &receipts, &ctx(Some(origin), None), false);
305
306 assert!(matches!(v, Verdict::Stale { .. }));
308 }
309
310 #[test]
311 fn verdict_for_should_be_silently_unbound_when_a_touched_trigger_was_not_selected() {
312 let g = test_ground(&["linux-ci"]);
314 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
315 let sl = crate::selected::SelectedList {
316 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
317 changed: vec!["pyproject.toml".into()],
318 selected: vec![],
319 };
320
321 let v = verdict_for(&g, &receipts, &ctx(None, Some(sl)), false);
323
324 assert_eq!(v, Verdict::SilentlyUnbound);
326 }
327
328 #[test]
329 fn verdict_for_should_be_green_when_the_touched_trigger_was_selected() {
330 let g = test_ground(&["linux-ci"]);
332 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
333 let sl = crate::selected::SelectedList {
334 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
335 changed: vec!["pyproject.toml".into()],
336 selected: vec!["pytest x".into()],
337 };
338
339 let v = verdict_for(&g, &receipts, &ctx(None, Some(sl)), false);
341
342 assert_eq!(v, Verdict::Green);
344 }
345
346 #[test]
347 fn verdict_for_should_be_stale_when_the_deciding_receipt_is_older_than_the_window() {
348 let g = test_ground(&["linux-ci"]);
350 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
351 let c = Ctx {
352 live_origin_sha: None,
353 selected: None,
354 now_unix: OffsetDateTime::parse("2026-06-01T00:00:00Z", &Rfc3339)
355 .unwrap()
356 .unix_timestamp(),
357 staleness_secs: 7 * 86_400,
358 attest: None,
359 };
360
361 let v = verdict_for(&g, &receipts, &c, false);
363
364 assert!(matches!(v, Verdict::Stale { .. }));
366 }
367
368 #[test]
369 fn verdict_for_should_be_green_when_the_deciding_receipt_is_within_the_window() {
370 let g = test_ground(&["linux-ci"]);
372 let receipts = vec![rcpt("linux-ci", "2026-06-01T00:00:00Z", "green")];
373 let c = Ctx {
374 live_origin_sha: None,
375 selected: None,
376 now_unix: OffsetDateTime::parse("2026-06-01T01:00:00Z", &Rfc3339)
377 .unwrap()
378 .unix_timestamp(),
379 staleness_secs: 7 * 86_400,
380 attest: None,
381 };
382
383 let v = verdict_for(&g, &receipts, &c, false);
385
386 assert_eq!(v, Verdict::Green);
388 }
389
390 #[test]
391 fn verdict_for_should_be_stale_when_a_triggering_change_landed_after_the_last_run() {
392 let g = test_ground(&["linux-ci"]);
394 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
395
396 let v = verdict_for(&g, &receipts, &ctx(None, None), true);
398
399 assert!(matches!(v, Verdict::Stale { .. }));
401 }
402
403 #[test]
404 fn verdict_for_should_ignore_triggered_since_when_a_platform_is_already_not_run() {
405 let g = test_ground(&["linux-ci", "mac"]);
407 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
408
409 let v = verdict_for(&g, &receipts, &ctx(None, None), true);
411
412 assert_eq!(
414 v,
415 Verdict::NotRun {
416 missing_platforms: vec!["mac".into()]
417 }
418 );
419 }
420
421 #[test]
422 fn verdict_for_should_exempt_a_binding_whose_platforms_this_runner_does_not_attest() {
423 let g = test_ground(&["mac"]);
425 assert_eq!(
427 verdict_for(&g, &[], &ctx_attest(Some(&["linux-ci"])), false),
428 Verdict::Exempt
429 );
430 }
431
432 #[test]
433 fn verdict_for_should_ignore_an_unattested_platform_when_an_attested_one_is_green() {
434 let g = test_ground(&["linux-ci", "mac"]);
436 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
437 assert_eq!(
439 verdict_for(&g, &receipts, &ctx_attest(Some(&["linux-ci"])), false),
440 Verdict::Green
441 );
442 }
443
444 #[test]
445 fn verdict_for_should_be_unproven_when_a_deciding_receipt_is_not_falsifiable() {
446 let g = test_ground(&["linux-ci"]);
448 let receipts = vec![Receipt {
449 test: "pytest x".into(),
450 platform: "linux-ci".into(),
451 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
452 ran_at: "2026-01-01T00:00:00Z".into(),
453 result: "green".into(),
454 falsifiable: Some(false),
455 }];
456 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
458 assert_eq!(v, Verdict::Unproven);
460 }
461
462 #[test]
463 fn verdict_for_should_be_green_when_a_deciding_receipt_is_proven_falsifiable() {
464 let g = test_ground(&["linux-ci"]);
466 let receipts = vec![Receipt {
467 test: "pytest x".into(),
468 platform: "linux-ci".into(),
469 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
470 ran_at: "2026-01-01T00:00:00Z".into(),
471 result: "green".into(),
472 falsifiable: Some(true),
473 }];
474 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
476 assert_eq!(v, Verdict::Green);
478 }
479}