1use crate::receipt::Receipt;
8use crate::selected::SelectedList;
9use crate::tick::{Check, Ground};
10
11#[derive(Debug, Clone, PartialEq)]
16pub enum StaleKind {
17 Sha,
18 CountWindow,
19 Age,
20}
21
22impl StaleKind {
23 pub fn as_str(&self) -> &'static str {
24 match self {
25 StaleKind::Sha => "sha",
26 StaleKind::CountWindow => "count-window",
27 StaleKind::Age => "age",
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub enum Verdict {
34 Green,
35 Red,
36 GrayRed,
37 Unproven,
38 NotRun { missing_platforms: Vec<String> },
39 Stale { kind: StaleKind, reason: String },
40 SilentlyUnbound,
41 Exempt, Memo, NotApplicable, }
45
46impl Verdict {
47 pub fn label(&self) -> &'static str {
49 match self {
50 Verdict::Green => "green",
51 Verdict::Red => "red",
52 Verdict::GrayRed => "gray->red",
53 Verdict::Unproven => "unproven",
54 Verdict::NotRun { .. } => "not-run",
55 Verdict::Stale { .. } => "stale",
56 Verdict::SilentlyUnbound => "silently-unbound",
57 Verdict::Exempt => "exempt",
58 Verdict::Memo => "memo",
59 Verdict::NotApplicable => "n/a",
60 }
61 }
62
63 pub fn event_label(&self) -> String {
67 match self {
68 Verdict::Stale { kind, .. } => format!("stale:{}", kind.as_str()),
69 other => other.label().to_string(),
70 }
71 }
72}
73
74use time::{format_description::well_known::Rfc3339, OffsetDateTime};
75
76pub struct Ctx {
79 pub live_origin_sha: Option<String>, pub selected: Option<SelectedList>, pub now_unix: i64, pub staleness_secs: i64, pub attest: Option<Vec<String>>, }
85
86pub fn verdict_for(
88 ground: &Ground,
89 receipts: &[Receipt],
90 ctx: &Ctx,
91 triggered_since: bool,
92) -> Verdict {
93 let (reference, verified_at_sha, liveness) = match &ground.check {
94 Some(Check::Test {
95 reference,
96 verified_at_sha,
97 liveness,
98 ..
99 }) => (reference.as_str(), verified_at_sha.as_str(), liveness),
100 _ => return Verdict::NotApplicable,
101 };
102
103 if let Some(origin) = ctx.live_origin_sha.as_deref() {
104 if origin != verified_at_sha {
105 return Verdict::Stale {
106 kind: StaleKind::Sha,
107 reason: "verified_at_sha behind live origin".into(),
108 };
109 }
110 }
111
112 let attested: Vec<&String> = match &ctx.attest {
115 Some(set) => liveness
116 .platforms
117 .iter()
118 .filter(|p| set.contains(p))
119 .collect(),
120 None => liveness.platforms.iter().collect(),
121 };
122 if ctx.attest.is_some() && attested.is_empty() {
123 return Verdict::Exempt; }
125 let mut missing = Vec::new();
126 let mut deciding: Vec<&Receipt> = Vec::new();
127 for p in attested {
128 let latest = receipts
130 .iter()
131 .filter(|r| r.test == reference && &r.platform == p)
132 .max_by(|a, b| a.ran_at.cmp(&b.ran_at));
133 match latest {
134 None => missing.push(p.clone()),
135 Some(r) => deciding.push(r),
136 }
137 }
138 if !missing.is_empty() {
139 return Verdict::NotRun {
140 missing_platforms: missing,
141 };
142 }
143
144 if triggered_since {
148 return Verdict::Stale {
149 kind: StaleKind::CountWindow,
150 reason: "a triggering change landed after the last run".into(),
151 };
152 }
153
154 let stale_by_age = deciding.iter().any(|r| {
157 OffsetDateTime::parse(&r.ran_at, &Rfc3339)
158 .map(|dt| ctx.now_unix - dt.unix_timestamp() > ctx.staleness_secs)
159 .unwrap_or(false)
160 });
161 if stale_by_age {
162 return Verdict::Stale {
163 kind: StaleKind::Age,
164 reason: "deciding receipt older than the staleness window".into(),
165 };
166 }
167
168 if deciding.iter().any(|r| r.falsifiable == Some(false)) {
171 return Verdict::Unproven;
172 }
173
174 if deciding.iter().any(|r| r.result == "gray") {
175 return Verdict::GrayRed;
176 }
177 if deciding.iter().any(|r| r.result == "red") {
178 return Verdict::Red;
179 }
180
181 if let Some(sl) = ctx.selected.as_ref() {
184 let touched = liveness
185 .triggered_by
186 .iter()
187 .any(|t| sl.changed.iter().any(|c| c == t));
188 let was_selected = sl.selected.iter().any(|s| s == reference);
189 if touched && !was_selected {
190 return Verdict::SilentlyUnbound;
191 }
192 }
193
194 Verdict::Green
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::receipt::Receipt;
201 use crate::tick::{Check, Ground, Liveness};
202
203 fn test_ground(platforms: &[&str]) -> Ground {
204 Ground {
205 claim: "no Redis".into(),
206 supports: "chosen".into(),
207 check: Some(Check::Test {
208 reference: "pytest x".into(),
209 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
210 counter_test: Some("pytest x::flips".into()),
211 liveness: Liveness {
212 platforms: platforms.iter().map(|s| s.to_string()).collect(),
213 triggered_by: vec!["pyproject.toml".into()],
214 surfaces: vec!["pyproject-deps".into()],
215 },
216 }),
217 }
218 }
219 fn rcpt(platform: &str, ran_at: &str, result: &str) -> Receipt {
220 Receipt {
221 test: "pytest x".into(),
222 platform: platform.into(),
223 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
224 ran_at: ran_at.into(),
225 result: result.into(),
226 falsifiable: None,
227 }
228 }
229 fn ctx(live_origin_sha: Option<&str>, selected: Option<SelectedList>) -> Ctx {
231 Ctx {
232 live_origin_sha: live_origin_sha.map(|s| s.to_string()),
233 selected,
234 now_unix: 0,
235 staleness_secs: i64::MAX,
236 attest: None,
237 }
238 }
239 fn ctx_attest(attest: Option<&[&str]>) -> Ctx {
240 Ctx {
241 live_origin_sha: None,
242 selected: None,
243 now_unix: 0,
244 staleness_secs: i64::MAX,
245 attest: attest.map(|a| a.iter().map(|s| s.to_string()).collect()),
246 }
247 }
248
249 #[test]
250 fn verdict_for_should_tag_a_sha_stale_kind_when_the_origin_moved() {
251 let g = test_ground(&["linux-ci"]);
253
254 let v = verdict_for(
256 &g,
257 &[],
258 &ctx(Some("0000000000000000000000000000000000000000"), None),
259 false,
260 );
261
262 assert!(matches!(
264 v,
265 Verdict::Stale {
266 kind: StaleKind::Sha,
267 ..
268 }
269 ));
270 assert_eq!(v.event_label(), "stale:sha");
271 }
272
273 #[test]
274 fn verdict_for_should_tag_a_count_window_stale_kind_when_a_trigger_landed_after_the_run() {
275 let g = test_ground(&["linux-ci"]);
277 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
278
279 let v = verdict_for(&g, &receipts, &ctx(None, None), true);
281
282 assert!(matches!(
284 v,
285 Verdict::Stale {
286 kind: StaleKind::CountWindow,
287 ..
288 }
289 ));
290 assert_eq!(v.event_label(), "stale:count-window");
291 }
292
293 #[test]
294 fn verdict_for_should_tag_an_age_stale_kind_when_the_receipt_is_past_the_window() {
295 let g = test_ground(&["linux-ci"]);
297 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
298 let c = Ctx {
299 live_origin_sha: None,
300 selected: None,
301 now_unix: 9_000_000_000, staleness_secs: 1,
303 attest: None,
304 };
305
306 let v = verdict_for(&g, &receipts, &c, false);
308
309 assert!(matches!(
311 v,
312 Verdict::Stale {
313 kind: StaleKind::Age,
314 ..
315 }
316 ));
317 assert_eq!(v.event_label(), "stale:age");
318 }
319
320 #[test]
321 fn verdict_for_should_be_not_applicable_when_the_ground_has_a_person_check() {
322 let g = Ground {
324 claim: "c".into(),
325 supports: "chosen".into(),
326 check: Some(Check::Person {
327 reference: "Q3".into(),
328 }),
329 };
330
331 let v = verdict_for(&g, &[], &ctx(None, None), false);
333
334 assert_eq!(v, Verdict::NotApplicable);
336 }
337
338 #[test]
339 fn verdict_for_should_be_not_run_when_a_declared_platform_has_no_receipt() {
340 let g = test_ground(&["linux-ci", "mac"]);
342 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
343
344 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
346
347 assert_eq!(
349 v,
350 Verdict::NotRun {
351 missing_platforms: vec!["mac".into()]
352 }
353 );
354 }
355
356 #[test]
357 fn verdict_for_should_promote_gray_to_red_when_the_deciding_receipt_is_gray() {
358 let g = test_ground(&["linux-ci"]);
360 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "gray")];
361
362 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
364
365 assert_eq!(v, Verdict::GrayRed);
367 }
368
369 #[test]
370 fn verdict_for_should_be_red_when_the_latest_receipt_is_red() {
371 let g = test_ground(&["linux-ci"]);
373 let receipts = vec![
374 rcpt("linux-ci", "2026-01-01T00:00:00Z", "green"),
375 rcpt("linux-ci", "2026-02-01T00:00:00Z", "red"),
376 ];
377
378 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
380
381 assert_eq!(v, Verdict::Red);
383 }
384
385 #[test]
386 fn verdict_for_should_be_green_when_every_platform_has_a_fresh_green_receipt() {
387 let g = test_ground(&["linux-ci", "mac"]);
389 let receipts = vec![
390 rcpt("linux-ci", "2026-01-01T00:00:00Z", "green"),
391 rcpt("mac", "2026-01-01T00:00:00Z", "green"),
392 ];
393
394 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
396
397 assert_eq!(v, Verdict::Green);
399 }
400
401 #[test]
402 fn verdict_for_should_be_stale_when_verified_at_sha_is_behind_the_live_origin() {
403 let g = test_ground(&["linux-ci"]);
405 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
406 let origin = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
407
408 let v = verdict_for(&g, &receipts, &ctx(Some(origin), None), false);
410
411 assert!(matches!(v, Verdict::Stale { .. }));
413 }
414
415 #[test]
416 fn verdict_for_should_be_silently_unbound_when_a_touched_trigger_was_not_selected() {
417 let g = test_ground(&["linux-ci"]);
419 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
420 let sl = crate::selected::SelectedList {
421 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
422 changed: vec!["pyproject.toml".into()],
423 selected: vec![],
424 };
425
426 let v = verdict_for(&g, &receipts, &ctx(None, Some(sl)), false);
428
429 assert_eq!(v, Verdict::SilentlyUnbound);
431 }
432
433 #[test]
434 fn verdict_for_should_be_green_when_the_touched_trigger_was_selected() {
435 let g = test_ground(&["linux-ci"]);
437 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
438 let sl = crate::selected::SelectedList {
439 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
440 changed: vec!["pyproject.toml".into()],
441 selected: vec!["pytest x".into()],
442 };
443
444 let v = verdict_for(&g, &receipts, &ctx(None, Some(sl)), false);
446
447 assert_eq!(v, Verdict::Green);
449 }
450
451 #[test]
452 fn verdict_for_should_be_stale_when_the_deciding_receipt_is_older_than_the_window() {
453 let g = test_ground(&["linux-ci"]);
455 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
456 let c = Ctx {
457 live_origin_sha: None,
458 selected: None,
459 now_unix: OffsetDateTime::parse("2026-06-01T00:00:00Z", &Rfc3339)
460 .unwrap()
461 .unix_timestamp(),
462 staleness_secs: 7 * 86_400,
463 attest: None,
464 };
465
466 let v = verdict_for(&g, &receipts, &c, false);
468
469 assert!(matches!(v, Verdict::Stale { .. }));
471 }
472
473 #[test]
474 fn verdict_for_should_be_green_when_the_deciding_receipt_is_within_the_window() {
475 let g = test_ground(&["linux-ci"]);
477 let receipts = vec![rcpt("linux-ci", "2026-06-01T00:00:00Z", "green")];
478 let c = Ctx {
479 live_origin_sha: None,
480 selected: None,
481 now_unix: OffsetDateTime::parse("2026-06-01T01:00:00Z", &Rfc3339)
482 .unwrap()
483 .unix_timestamp(),
484 staleness_secs: 7 * 86_400,
485 attest: None,
486 };
487
488 let v = verdict_for(&g, &receipts, &c, false);
490
491 assert_eq!(v, Verdict::Green);
493 }
494
495 #[test]
496 fn verdict_for_should_be_stale_when_a_triggering_change_landed_after_the_last_run() {
497 let g = test_ground(&["linux-ci"]);
499 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
500
501 let v = verdict_for(&g, &receipts, &ctx(None, None), true);
503
504 assert!(matches!(v, Verdict::Stale { .. }));
506 }
507
508 #[test]
509 fn verdict_for_should_ignore_triggered_since_when_a_platform_is_already_not_run() {
510 let g = test_ground(&["linux-ci", "mac"]);
512 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
513
514 let v = verdict_for(&g, &receipts, &ctx(None, None), true);
516
517 assert_eq!(
519 v,
520 Verdict::NotRun {
521 missing_platforms: vec!["mac".into()]
522 }
523 );
524 }
525
526 #[test]
527 fn verdict_for_should_exempt_a_binding_whose_platforms_this_runner_does_not_attest() {
528 let g = test_ground(&["mac"]);
530 assert_eq!(
532 verdict_for(&g, &[], &ctx_attest(Some(&["linux-ci"])), false),
533 Verdict::Exempt
534 );
535 }
536
537 #[test]
538 fn verdict_for_should_ignore_an_unattested_platform_when_an_attested_one_is_green() {
539 let g = test_ground(&["linux-ci", "mac"]);
541 let receipts = vec![rcpt("linux-ci", "2026-01-01T00:00:00Z", "green")];
542 assert_eq!(
544 verdict_for(&g, &receipts, &ctx_attest(Some(&["linux-ci"])), false),
545 Verdict::Green
546 );
547 }
548
549 #[test]
550 fn verdict_for_should_be_unproven_when_a_deciding_receipt_is_not_falsifiable() {
551 let g = test_ground(&["linux-ci"]);
553 let receipts = vec![Receipt {
554 test: "pytest x".into(),
555 platform: "linux-ci".into(),
556 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
557 ran_at: "2026-01-01T00:00:00Z".into(),
558 result: "green".into(),
559 falsifiable: Some(false),
560 }];
561 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
563 assert_eq!(v, Verdict::Unproven);
565 }
566
567 #[test]
568 fn verdict_for_should_be_green_when_a_deciding_receipt_is_proven_falsifiable() {
569 let g = test_ground(&["linux-ci"]);
571 let receipts = vec![Receipt {
572 test: "pytest x".into(),
573 platform: "linux-ci".into(),
574 commit: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
575 ran_at: "2026-01-01T00:00:00Z".into(),
576 result: "green".into(),
577 falsifiable: Some(true),
578 }];
579 let v = verdict_for(&g, &receipts, &ctx(None, None), false);
581 assert_eq!(v, Verdict::Green);
583 }
584}