1use super::{Check, FailAction, Flow, Stage};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Verdict {
12 Pass,
13 Fail,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum VerdictSource {
19 ExitCode,
22 Reported,
24 Panel,
27}
28
29impl VerdictSource {
30 pub fn as_str(self) -> &'static str {
31 match self {
32 Self::ExitCode => "exit_code",
33 Self::Reported => "reported",
34 Self::Panel => "panel",
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Reported {
43 pub verdict: Verdict,
44 pub reason: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct StageEvidence {
59 pub stage: String,
60 pub stage_index: usize,
61 pub attempt: u32,
64 pub verdict: Verdict,
65 pub source: VerdictSource,
66 pub reason: Option<String>,
67}
68
69pub fn resolve_verdict(
73 check: &Check,
74 exit: Verdict,
75 reported: Option<Reported>,
76) -> (Verdict, VerdictSource, Option<String>) {
77 if *check != Check::Reported {
78 return (exit, VerdictSource::ExitCode, None);
79 }
80 match reported {
81 Some(reported) => (reported.verdict, VerdictSource::Reported, reported.reason),
82 None => (
83 Verdict::Fail,
84 VerdictSource::Reported,
85 Some("no verdict reported".into()),
86 ),
87 }
88}
89
90#[derive(Debug, PartialEq, Eq)]
92pub enum Step<'a> {
93 Run { stage: &'a Stage, attempt: u32 },
96 Halted {
99 failed_stage: String,
100 reason: HaltReason,
101 },
102 Complete,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum HaltReason {
109 FailActionHalt,
111 ReturnBudgetExhausted,
114 CorruptLog,
119}
120
121pub fn next_step<'a>(flow: &'a Flow, evidence: &[StageEvidence]) -> Step<'a> {
136 let mut cursor = 0usize;
137 for (position, row) in evidence.iter().enumerate() {
138 let Some(stage) = flow.stages.get(cursor) else {
139 return corrupt(row.stage.clone());
142 };
143 let consumed = &evidence[..position];
144 if row.stage_index != cursor || row.attempt != attempt_at(consumed, cursor) {
145 return corrupt(stage.name.clone());
146 }
147 match row.verdict {
148 Verdict::Pass => cursor += 1,
149 Verdict::Fail => match &stage.fail_action {
150 FailAction::Halt => {
151 return Step::Halted {
152 failed_stage: stage.name.clone(),
153 reason: HaltReason::FailActionHalt,
154 };
155 }
156 FailAction::Continue => cursor += 1,
157 FailAction::ReturnTo {
158 stage: target,
159 attempts,
160 } => {
161 let Some(target_index) = flow
162 .stages
163 .iter()
164 .position(|candidate| candidate.name == *target)
165 else {
166 return corrupt(stage.name.clone());
167 };
168 if returns_taken(consumed, cursor) >= *attempts {
169 return Step::Halted {
170 failed_stage: stage.name.clone(),
171 reason: HaltReason::ReturnBudgetExhausted,
172 };
173 }
174 cursor = target_index;
178 }
179 },
180 }
181 }
182 match flow.stages.get(cursor) {
183 Some(stage) => Step::Run {
184 stage,
185 attempt: attempt_at(evidence, cursor),
186 },
187 None => Step::Complete,
188 }
189}
190
191pub fn return_trigger(flow: &Flow, evidence: &[StageEvidence]) -> Option<(usize, u32)> {
201 evidence
202 .iter()
203 .rev()
204 .find(|row| {
205 row.verdict == Verdict::Fail
206 && matches!(
207 flow.stages
208 .get(row.stage_index)
209 .map(|stage| &stage.fail_action),
210 Some(FailAction::ReturnTo { .. })
211 )
212 })
213 .map(|row| (row.stage_index, row.attempt))
214}
215
216fn corrupt(failed_stage: String) -> Step<'static> {
217 Step::Halted {
218 failed_stage,
219 reason: HaltReason::CorruptLog,
220 }
221}
222
223fn attempt_at(consumed: &[StageEvidence], index: usize) -> u32 {
229 count(consumed.iter().filter(|row| row.stage_index == index)).saturating_add(1)
230}
231
232fn returns_taken(consumed: &[StageEvidence], index: usize) -> u32 {
237 count(
238 consumed
239 .iter()
240 .filter(|row| row.stage_index == index && row.verdict == Verdict::Fail),
241 )
242}
243
244fn count<'a>(rows: impl Iterator<Item = &'a StageEvidence>) -> u32 {
245 u32::try_from(rows.count()).unwrap_or(u32::MAX)
246}
247
248#[cfg(test)]
249mod tests {
250 use crate::flow::{
251 Actor, Builtin, Check, FailAction, Flow, HaltReason, Reported, Stage, StageEvidence, Step,
252 Verdict, VerdictSource, next_step, resolve_verdict, return_trigger,
253 };
254
255 fn commits() -> Check {
256 Check::Actor(Actor::Builtin(Builtin::Commits))
257 }
258
259 fn build_review_merge() -> Flow {
260 Flow {
261 name: "example".into(),
262 stages: vec![
263 Stage {
264 name: "build".into(),
265 action: Actor::Agent,
266 result_check: commits(),
267 fail_action: FailAction::Halt,
268 ff_only: false,
269 },
270 Stage {
271 name: "review".into(),
272 action: Actor::Exec {
273 cmd: vec!["true".into()],
274 },
275 result_check: Check::None,
276 fail_action: FailAction::Halt,
277 ff_only: false,
278 },
279 Stage {
280 name: "merge".into(),
281 action: Actor::Builtin(Builtin::Merge),
282 result_check: Check::None,
283 fail_action: FailAction::Halt,
284 ff_only: false,
285 },
286 ],
287 }
288 }
289
290 fn flow_with(stages: &[(&str, FailAction)]) -> Flow {
293 Flow {
294 name: "example".into(),
295 stages: stages
296 .iter()
297 .map(|(name, fail_action)| Stage {
298 name: (*name).into(),
299 action: Actor::Exec {
300 cmd: vec!["true".into()],
301 },
302 result_check: Check::None,
303 fail_action: fail_action.clone(),
304 ff_only: false,
305 })
306 .collect(),
307 }
308 }
309
310 fn return_to(stage: &str, attempts: u32) -> FailAction {
311 FailAction::ReturnTo {
312 stage: stage.into(),
313 attempts,
314 }
315 }
316
317 fn row(flow: &Flow, index: usize, attempt: u32, verdict: Verdict) -> StageEvidence {
319 StageEvidence {
320 stage: flow.stages[index].name.clone(),
321 stage_index: index,
322 attempt,
323 verdict,
324 source: VerdictSource::ExitCode,
325 reason: None,
326 }
327 }
328
329 fn pass(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
330 row(flow, index, attempt, Verdict::Pass)
331 }
332
333 fn fail(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
334 row(flow, index, attempt, Verdict::Fail)
335 }
336
337 fn passed(flow: &Flow, index: usize) -> StageEvidence {
338 pass(flow, index, 1)
339 }
340
341 fn failed(flow: &Flow, index: usize) -> StageEvidence {
342 fail(flow, index, 1)
343 }
344
345 fn run(flow: &Flow, index: usize, attempt: u32) -> Step<'_> {
346 Step::Run {
347 stage: &flow.stages[index],
348 attempt,
349 }
350 }
351
352 fn halted(stage: &str, reason: HaltReason) -> Step<'static> {
353 Step::Halted {
354 failed_stage: stage.into(),
355 reason,
356 }
357 }
358
359 #[test]
360 fn a_linear_log_of_passes_advances_one_stage_per_row() {
361 let flow = build_review_merge();
362
363 assert_eq!(next_step(&flow, &[]), run(&flow, 0, 1));
364 assert_eq!(next_step(&flow, &[passed(&flow, 0)]), run(&flow, 1, 1));
365 assert_eq!(
366 next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
367 run(&flow, 2, 1)
368 );
369 }
370
371 #[test]
372 fn next_step_is_complete_only_when_the_cursor_runs_off_the_end() {
373 let flow = build_review_merge();
374
375 assert_eq!(
376 next_step(
377 &flow,
378 &[passed(&flow, 0), passed(&flow, 1), passed(&flow, 2)]
379 ),
380 Step::Complete
381 );
382 assert_ne!(
383 next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
384 Step::Complete
385 );
386 }
387
388 #[test]
389 fn a_failed_row_halts_the_walk_and_later_stages_are_never_requested() {
390 let flow = build_review_merge();
391
392 let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
396
397 assert_eq!(
398 next_step(&flow, &evidence),
399 halted("review", HaltReason::FailActionHalt)
400 );
401 }
402
403 #[test]
404 fn a_continue_fail_action_advances_past_the_failure() {
405 let flow = flow_with(&[
406 ("build", FailAction::Halt),
407 ("lint", FailAction::Continue),
408 ("merge", FailAction::Halt),
409 ]);
410
411 let evidence = [passed(&flow, 0), failed(&flow, 1)];
412 assert_eq!(next_step(&flow, &evidence), run(&flow, 2, 1));
413
414 let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
415 assert_eq!(next_step(&flow, &evidence), Step::Complete);
416 }
417
418 #[test]
419 fn a_return_to_loop_converges_on_the_second_attempt() {
420 let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
421
422 let mut log = vec![pass(&flow, 0, 1), fail(&flow, 1, 1)];
425 assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
426
427 log.push(pass(&flow, 0, 2));
428 assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
429
430 log.push(pass(&flow, 1, 2));
431 assert_eq!(next_step(&flow, &log), Step::Complete);
432 }
433
434 #[test]
435 fn a_return_to_loop_halts_once_its_edge_budget_is_spent() {
436 let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 1))]);
437
438 let log = vec![
439 pass(&flow, 0, 1),
440 fail(&flow, 1, 1),
441 pass(&flow, 0, 2),
442 fail(&flow, 1, 2),
443 ];
444
445 assert_eq!(
446 next_step(&flow, &log),
447 halted("test", HaltReason::ReturnBudgetExhausted)
448 );
449 }
450
451 #[test]
452 fn distinct_backward_edges_keep_independent_budgets() {
453 let flow = flow_with(&[
455 ("build", FailAction::Halt),
456 ("test", return_to("build", 1)),
457 ("review", return_to("test", 1)),
458 ]);
459
460 let mut log = vec![
463 pass(&flow, 0, 1),
464 fail(&flow, 1, 1),
465 pass(&flow, 0, 2),
466 pass(&flow, 1, 2),
467 fail(&flow, 2, 1),
468 ];
469 assert_eq!(next_step(&flow, &log), run(&flow, 1, 3));
470
471 log.push(fail(&flow, 1, 3));
474 assert_eq!(
475 next_step(&flow, &log),
476 halted("test", HaltReason::ReturnBudgetExhausted)
477 );
478 }
479
480 #[test]
481 fn a_jump_supersedes_the_passes_recorded_inside_its_span() {
482 let flow = flow_with(&[
483 ("build", FailAction::Halt),
484 ("lint", FailAction::Halt),
485 ("test", return_to("build", 1)),
486 ]);
487
488 let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
491 assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
492
493 log.push(pass(&flow, 0, 2));
494 assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
495 }
496
497 #[test]
500 fn a_row_the_cursor_did_not_expect_is_a_corrupt_log() {
501 let flow = build_review_merge();
502
503 assert_eq!(
505 next_step(&flow, &[passed(&flow, 1)]),
506 halted("build", HaltReason::CorruptLog)
507 );
508
509 assert_eq!(
512 next_step(&flow, &[passed(&flow, 0), passed(&flow, 0)]),
513 halted("review", HaltReason::CorruptLog)
514 );
515
516 assert_eq!(
518 next_step(&flow, &[pass(&flow, 0, 2)]),
519 halted("build", HaltReason::CorruptLog)
520 );
521
522 assert_eq!(
524 next_step(
525 &flow,
526 &[
527 passed(&flow, 0),
528 passed(&flow, 1),
529 passed(&flow, 2),
530 pass(&flow, 2, 2),
531 ]
532 ),
533 halted("merge", HaltReason::CorruptLog)
534 );
535 }
536
537 #[test]
542 fn a_return_to_with_no_such_stage_is_a_corrupt_log() {
543 let flow = flow_with(&[
544 ("build", FailAction::Halt),
545 ("test", return_to("nowhere", 1)),
546 ]);
547
548 assert_eq!(
549 next_step(&flow, &[passed(&flow, 0), failed(&flow, 1)]),
550 halted("test", HaltReason::CorruptLog)
551 );
552 }
553
554 #[test]
557 fn the_return_trigger_is_the_failure_the_walk_jumped_on() {
558 let flow = flow_with(&[
559 ("build", FailAction::Halt),
560 ("lint", FailAction::Halt),
561 ("test", return_to("build", 2)),
562 ]);
563
564 assert_eq!(return_trigger(&flow, &[]), None);
566 assert_eq!(return_trigger(&flow, &[pass(&flow, 0, 1)]), None);
567
568 assert_eq!(
570 return_trigger(&flow, &[pass(&flow, 0, 1), fail(&flow, 1, 1)]),
571 None
572 );
573
574 let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
575 assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
576
577 log.push(pass(&flow, 0, 2));
580 assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
581
582 log.extend([pass(&flow, 1, 2), fail(&flow, 2, 2)]);
584 assert_eq!(return_trigger(&flow, &log), Some((2, 2)));
585 }
586
587 #[test]
588 fn replaying_an_identical_log_yields_an_identical_step() {
589 let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
590 let log = [
591 pass(&flow, 0, 1),
592 fail(&flow, 1, 1),
593 pass(&flow, 0, 2),
594 fail(&flow, 1, 2),
595 ];
596
597 assert_eq!(next_step(&flow, &log), next_step(&flow, &log));
598 assert_eq!(next_step(&flow, &log), run(&flow, 0, 3));
599 }
600
601 #[test]
602 fn only_reported_policy_consults_reported_verdicts() {
603 assert_eq!(
604 resolve_verdict(&Check::None, Verdict::Pass, None),
605 (Verdict::Pass, VerdictSource::ExitCode, None)
606 );
607
608 let reported = Reported {
609 verdict: Verdict::Fail,
610 reason: Some("changes requested".into()),
611 };
612 assert_eq!(
613 resolve_verdict(&Check::Reported, Verdict::Pass, Some(reported)),
614 (
615 Verdict::Fail,
616 VerdictSource::Reported,
617 Some("changes requested".into())
618 )
619 );
620 }
621
622 #[test]
623 fn non_reported_policies_ignore_reports() {
624 let reported = Reported {
625 verdict: Verdict::Pass,
626 reason: Some("looks fine to me".into()),
627 };
628
629 assert_eq!(
630 resolve_verdict(&commits(), Verdict::Fail, Some(reported)),
631 (Verdict::Fail, VerdictSource::ExitCode, None)
632 );
633 }
634
635 #[test]
636 fn missing_report_is_a_failed_reported_verdict() {
637 assert_eq!(
638 resolve_verdict(&Check::Reported, Verdict::Pass, None),
639 (
640 Verdict::Fail,
641 VerdictSource::Reported,
642 Some("no verdict reported".into())
643 )
644 );
645 }
646}