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());
140 };
141 let consumed = &evidence[..position];
142 if row.stage_index != cursor || row.attempt != attempt_at(consumed, cursor) {
143 return corrupt(stage.name.clone());
144 }
145 match row.verdict {
146 Verdict::Pass => cursor += 1,
147 Verdict::Fail => match &stage.fail_action {
148 FailAction::Halt => {
149 return Step::Halted {
150 failed_stage: stage.name.clone(),
151 reason: HaltReason::FailActionHalt,
152 };
153 }
154 FailAction::Continue => cursor += 1,
155 FailAction::ReturnTo {
156 stage: target,
157 attempts,
158 } => {
159 let Some(target_index) = flow
160 .stages
161 .iter()
162 .position(|candidate| candidate.name == *target)
163 else {
164 return corrupt(stage.name.clone());
165 };
166 if returns_taken(consumed, cursor) >= *attempts {
167 return Step::Halted {
168 failed_stage: stage.name.clone(),
169 reason: HaltReason::ReturnBudgetExhausted,
170 };
171 }
172 cursor = target_index;
173 }
174 },
175 }
176 }
177 match flow.stages.get(cursor) {
178 Some(stage) => Step::Run {
179 stage,
180 attempt: attempt_at(evidence, cursor),
181 },
182 None => Step::Complete,
183 }
184}
185
186pub fn return_trigger(flow: &Flow, evidence: &[StageEvidence]) -> Option<(usize, u32)> {
196 evidence
197 .iter()
198 .rev()
199 .find(|row| {
200 row.verdict == Verdict::Fail
201 && matches!(
202 flow.stages
203 .get(row.stage_index)
204 .map(|stage| &stage.fail_action),
205 Some(FailAction::ReturnTo { .. })
206 )
207 })
208 .map(|row| (row.stage_index, row.attempt))
209}
210
211fn corrupt(failed_stage: String) -> Step<'static> {
212 Step::Halted {
213 failed_stage,
214 reason: HaltReason::CorruptLog,
215 }
216}
217
218fn attempt_at(consumed: &[StageEvidence], index: usize) -> u32 {
224 count(consumed.iter().filter(|row| row.stage_index == index)).saturating_add(1)
225}
226
227fn returns_taken(consumed: &[StageEvidence], index: usize) -> u32 {
232 count(
233 consumed
234 .iter()
235 .filter(|row| row.stage_index == index && row.verdict == Verdict::Fail),
236 )
237}
238
239fn count<'a>(rows: impl Iterator<Item = &'a StageEvidence>) -> u32 {
240 u32::try_from(rows.count()).unwrap_or(u32::MAX)
241}
242
243#[cfg(test)]
244mod tests {
245 use crate::flow::{
246 Actor, Builtin, Check, FailAction, Flow, HaltReason, Reported, Stage, StageEvidence, Step,
247 Verdict, VerdictSource, next_step, resolve_verdict, return_trigger,
248 };
249
250 fn commits() -> Check {
251 Check::Actor(Actor::Builtin(Builtin::Commits))
252 }
253
254 fn build_review_merge() -> Flow {
255 Flow {
256 name: "example".into(),
257 stages: vec![
258 Stage {
259 name: "build".into(),
260 action: Actor::Agent,
261 result_check: commits(),
262 fail_action: FailAction::Halt,
263 ff_only: false,
264 },
265 Stage {
266 name: "review".into(),
267 action: Actor::Exec {
268 cmd: vec!["true".into()],
269 },
270 result_check: Check::None,
271 fail_action: FailAction::Halt,
272 ff_only: false,
273 },
274 Stage {
275 name: "merge".into(),
276 action: Actor::Builtin(Builtin::Merge),
277 result_check: Check::None,
278 fail_action: FailAction::Halt,
279 ff_only: false,
280 },
281 ],
282 }
283 }
284
285 fn flow_with(stages: &[(&str, FailAction)]) -> Flow {
288 Flow {
289 name: "example".into(),
290 stages: stages
291 .iter()
292 .map(|(name, fail_action)| Stage {
293 name: (*name).into(),
294 action: Actor::Exec {
295 cmd: vec!["true".into()],
296 },
297 result_check: Check::None,
298 fail_action: fail_action.clone(),
299 ff_only: false,
300 })
301 .collect(),
302 }
303 }
304
305 fn return_to(stage: &str, attempts: u32) -> FailAction {
306 FailAction::ReturnTo {
307 stage: stage.into(),
308 attempts,
309 }
310 }
311
312 fn row(flow: &Flow, index: usize, attempt: u32, verdict: Verdict) -> StageEvidence {
314 StageEvidence {
315 stage: flow.stages[index].name.clone(),
316 stage_index: index,
317 attempt,
318 verdict,
319 source: VerdictSource::ExitCode,
320 reason: None,
321 }
322 }
323
324 fn pass(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
325 row(flow, index, attempt, Verdict::Pass)
326 }
327
328 fn fail(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
329 row(flow, index, attempt, Verdict::Fail)
330 }
331
332 fn passed(flow: &Flow, index: usize) -> StageEvidence {
333 pass(flow, index, 1)
334 }
335
336 fn failed(flow: &Flow, index: usize) -> StageEvidence {
337 fail(flow, index, 1)
338 }
339
340 fn run(flow: &Flow, index: usize, attempt: u32) -> Step<'_> {
341 Step::Run {
342 stage: &flow.stages[index],
343 attempt,
344 }
345 }
346
347 fn halted(stage: &str, reason: HaltReason) -> Step<'static> {
348 Step::Halted {
349 failed_stage: stage.into(),
350 reason,
351 }
352 }
353
354 #[test]
355 fn a_linear_log_of_passes_advances_one_stage_per_row() {
356 let flow = build_review_merge();
357
358 assert_eq!(next_step(&flow, &[]), run(&flow, 0, 1));
359 assert_eq!(next_step(&flow, &[passed(&flow, 0)]), run(&flow, 1, 1));
360 assert_eq!(
361 next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
362 run(&flow, 2, 1)
363 );
364 }
365
366 #[test]
367 fn next_step_is_complete_only_when_the_cursor_runs_off_the_end() {
368 let flow = build_review_merge();
369
370 assert_eq!(
371 next_step(
372 &flow,
373 &[passed(&flow, 0), passed(&flow, 1), passed(&flow, 2)]
374 ),
375 Step::Complete
376 );
377 assert_ne!(
378 next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
379 Step::Complete
380 );
381 }
382
383 #[test]
384 fn a_failed_row_halts_the_walk_and_later_stages_are_never_requested() {
385 let flow = build_review_merge();
386
387 let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
388
389 assert_eq!(
390 next_step(&flow, &evidence),
391 halted("review", HaltReason::FailActionHalt)
392 );
393 }
394
395 #[test]
396 fn a_continue_fail_action_advances_past_the_failure() {
397 let flow = flow_with(&[
398 ("build", FailAction::Halt),
399 ("lint", FailAction::Continue),
400 ("merge", FailAction::Halt),
401 ]);
402
403 let evidence = [passed(&flow, 0), failed(&flow, 1)];
404 assert_eq!(next_step(&flow, &evidence), run(&flow, 2, 1));
405
406 let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
407 assert_eq!(next_step(&flow, &evidence), Step::Complete);
408 }
409
410 #[test]
411 fn a_return_to_loop_converges_on_the_second_attempt() {
412 let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
413
414 let mut log = vec![pass(&flow, 0, 1), fail(&flow, 1, 1)];
415 assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
416
417 log.push(pass(&flow, 0, 2));
418 assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
419
420 log.push(pass(&flow, 1, 2));
421 assert_eq!(next_step(&flow, &log), Step::Complete);
422 }
423
424 #[test]
425 fn a_return_to_loop_halts_once_its_edge_budget_is_spent() {
426 let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 1))]);
427
428 let log = vec![
429 pass(&flow, 0, 1),
430 fail(&flow, 1, 1),
431 pass(&flow, 0, 2),
432 fail(&flow, 1, 2),
433 ];
434
435 assert_eq!(
436 next_step(&flow, &log),
437 halted("test", HaltReason::ReturnBudgetExhausted)
438 );
439 }
440
441 #[test]
442 fn distinct_backward_edges_keep_independent_budgets() {
443 let flow = flow_with(&[
444 ("build", FailAction::Halt),
445 ("test", return_to("build", 1)),
446 ("review", return_to("test", 1)),
447 ]);
448
449 let mut log = vec![
450 pass(&flow, 0, 1),
451 fail(&flow, 1, 1),
452 pass(&flow, 0, 2),
453 pass(&flow, 1, 2),
454 fail(&flow, 2, 1),
455 ];
456 assert_eq!(next_step(&flow, &log), run(&flow, 1, 3));
457
458 log.push(fail(&flow, 1, 3));
459 assert_eq!(
460 next_step(&flow, &log),
461 halted("test", HaltReason::ReturnBudgetExhausted)
462 );
463 }
464
465 #[test]
466 fn a_jump_supersedes_the_passes_recorded_inside_its_span() {
467 let flow = flow_with(&[
468 ("build", FailAction::Halt),
469 ("lint", FailAction::Halt),
470 ("test", return_to("build", 1)),
471 ]);
472
473 let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
474 assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
475
476 log.push(pass(&flow, 0, 2));
477 assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
478 }
479
480 #[test]
483 fn a_row_the_cursor_did_not_expect_is_a_corrupt_log() {
484 let flow = build_review_merge();
485
486 assert_eq!(
487 next_step(&flow, &[passed(&flow, 1)]),
488 halted("build", HaltReason::CorruptLog)
489 );
490
491 assert_eq!(
492 next_step(&flow, &[passed(&flow, 0), passed(&flow, 0)]),
493 halted("review", HaltReason::CorruptLog)
494 );
495
496 assert_eq!(
497 next_step(&flow, &[pass(&flow, 0, 2)]),
498 halted("build", HaltReason::CorruptLog)
499 );
500
501 assert_eq!(
502 next_step(
503 &flow,
504 &[
505 passed(&flow, 0),
506 passed(&flow, 1),
507 passed(&flow, 2),
508 pass(&flow, 2, 2),
509 ]
510 ),
511 halted("merge", HaltReason::CorruptLog)
512 );
513 }
514
515 #[test]
520 fn a_return_to_with_no_such_stage_is_a_corrupt_log() {
521 let flow = flow_with(&[
522 ("build", FailAction::Halt),
523 ("test", return_to("nowhere", 1)),
524 ]);
525
526 assert_eq!(
527 next_step(&flow, &[passed(&flow, 0), failed(&flow, 1)]),
528 halted("test", HaltReason::CorruptLog)
529 );
530 }
531
532 #[test]
535 fn the_return_trigger_is_the_failure_the_walk_jumped_on() {
536 let flow = flow_with(&[
537 ("build", FailAction::Halt),
538 ("lint", FailAction::Halt),
539 ("test", return_to("build", 2)),
540 ]);
541
542 assert_eq!(return_trigger(&flow, &[]), None);
543 assert_eq!(return_trigger(&flow, &[pass(&flow, 0, 1)]), None);
544
545 assert_eq!(
546 return_trigger(&flow, &[pass(&flow, 0, 1), fail(&flow, 1, 1)]),
547 None
548 );
549
550 let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
551 assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
552
553 log.push(pass(&flow, 0, 2));
554 assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
555
556 log.extend([pass(&flow, 1, 2), fail(&flow, 2, 2)]);
557 assert_eq!(return_trigger(&flow, &log), Some((2, 2)));
558 }
559
560 #[test]
561 fn replaying_an_identical_log_yields_an_identical_step() {
562 let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
563 let log = [
564 pass(&flow, 0, 1),
565 fail(&flow, 1, 1),
566 pass(&flow, 0, 2),
567 fail(&flow, 1, 2),
568 ];
569
570 assert_eq!(next_step(&flow, &log), next_step(&flow, &log));
571 assert_eq!(next_step(&flow, &log), run(&flow, 0, 3));
572 }
573
574 #[test]
575 fn only_reported_policy_consults_reported_verdicts() {
576 assert_eq!(
577 resolve_verdict(&Check::None, Verdict::Pass, None),
578 (Verdict::Pass, VerdictSource::ExitCode, None)
579 );
580
581 let reported = Reported {
582 verdict: Verdict::Fail,
583 reason: Some("changes requested".into()),
584 };
585 assert_eq!(
586 resolve_verdict(&Check::Reported, Verdict::Pass, Some(reported)),
587 (
588 Verdict::Fail,
589 VerdictSource::Reported,
590 Some("changes requested".into())
591 )
592 );
593 }
594
595 #[test]
596 fn non_reported_policies_ignore_reports() {
597 let reported = Reported {
598 verdict: Verdict::Pass,
599 reason: Some("looks fine to me".into()),
600 };
601
602 assert_eq!(
603 resolve_verdict(&commits(), Verdict::Fail, Some(reported)),
604 (Verdict::Fail, VerdictSource::ExitCode, None)
605 );
606 }
607
608 #[test]
609 fn missing_report_is_a_failed_reported_verdict() {
610 assert_eq!(
611 resolve_verdict(&Check::Reported, Verdict::Pass, None),
612 (
613 Verdict::Fail,
614 VerdictSource::Reported,
615 Some("no verdict reported".into())
616 )
617 );
618 }
619}