use super::{Check, FailAction, Flow, Stage};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Pass,
Fail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerdictSource {
ExitCode,
Reported,
Panel,
}
impl VerdictSource {
pub fn as_str(self) -> &'static str {
match self {
Self::ExitCode => "exit_code",
Self::Reported => "reported",
Self::Panel => "panel",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reported {
pub verdict: Verdict,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StageEvidence {
pub stage: String,
pub stage_index: usize,
pub attempt: u32,
pub verdict: Verdict,
pub source: VerdictSource,
pub reason: Option<String>,
}
pub fn resolve_verdict(
check: &Check,
exit: Verdict,
reported: Option<Reported>,
) -> (Verdict, VerdictSource, Option<String>) {
if *check != Check::Reported {
return (exit, VerdictSource::ExitCode, None);
}
match reported {
Some(reported) => (reported.verdict, VerdictSource::Reported, reported.reason),
None => (
Verdict::Fail,
VerdictSource::Reported,
Some("no verdict reported".into()),
),
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Step<'a> {
Run { stage: &'a Stage, attempt: u32 },
Halted {
failed_stage: String,
reason: HaltReason,
},
Complete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HaltReason {
FailActionHalt,
ReturnBudgetExhausted,
CorruptLog,
}
pub fn next_step<'a>(flow: &'a Flow, evidence: &[StageEvidence]) -> Step<'a> {
let mut cursor = 0usize;
for (position, row) in evidence.iter().enumerate() {
let Some(stage) = flow.stages.get(cursor) else {
return corrupt(row.stage.clone());
};
let consumed = &evidence[..position];
if row.stage_index != cursor || row.attempt != attempt_at(consumed, cursor) {
return corrupt(stage.name.clone());
}
match row.verdict {
Verdict::Pass => cursor += 1,
Verdict::Fail => match &stage.fail_action {
FailAction::Halt => {
return Step::Halted {
failed_stage: stage.name.clone(),
reason: HaltReason::FailActionHalt,
};
}
FailAction::Continue => cursor += 1,
FailAction::ReturnTo {
stage: target,
attempts,
} => {
let Some(target_index) = flow
.stages
.iter()
.position(|candidate| candidate.name == *target)
else {
return corrupt(stage.name.clone());
};
if returns_taken(consumed, cursor) >= *attempts {
return Step::Halted {
failed_stage: stage.name.clone(),
reason: HaltReason::ReturnBudgetExhausted,
};
}
cursor = target_index;
}
},
}
}
match flow.stages.get(cursor) {
Some(stage) => Step::Run {
stage,
attempt: attempt_at(evidence, cursor),
},
None => Step::Complete,
}
}
pub fn return_trigger(flow: &Flow, evidence: &[StageEvidence]) -> Option<(usize, u32)> {
evidence
.iter()
.rev()
.find(|row| {
row.verdict == Verdict::Fail
&& matches!(
flow.stages
.get(row.stage_index)
.map(|stage| &stage.fail_action),
Some(FailAction::ReturnTo { .. })
)
})
.map(|row| (row.stage_index, row.attempt))
}
fn corrupt(failed_stage: String) -> Step<'static> {
Step::Halted {
failed_stage,
reason: HaltReason::CorruptLog,
}
}
fn attempt_at(consumed: &[StageEvidence], index: usize) -> u32 {
count(consumed.iter().filter(|row| row.stage_index == index)).saturating_add(1)
}
fn returns_taken(consumed: &[StageEvidence], index: usize) -> u32 {
count(
consumed
.iter()
.filter(|row| row.stage_index == index && row.verdict == Verdict::Fail),
)
}
fn count<'a>(rows: impl Iterator<Item = &'a StageEvidence>) -> u32 {
u32::try_from(rows.count()).unwrap_or(u32::MAX)
}
#[cfg(test)]
mod tests {
use crate::flow::{
Actor, Builtin, Check, FailAction, Flow, HaltReason, Reported, Stage, StageEvidence, Step,
Verdict, VerdictSource, next_step, resolve_verdict, return_trigger,
};
fn commits() -> Check {
Check::Actor(Actor::Builtin(Builtin::Commits))
}
fn build_review_merge() -> Flow {
Flow {
name: "example".into(),
stages: vec![
Stage {
name: "build".into(),
action: Actor::Agent,
result_check: commits(),
fail_action: FailAction::Halt,
ff_only: false,
},
Stage {
name: "review".into(),
action: Actor::Exec {
cmd: vec!["true".into()],
},
result_check: Check::None,
fail_action: FailAction::Halt,
ff_only: false,
},
Stage {
name: "merge".into(),
action: Actor::Builtin(Builtin::Merge),
result_check: Check::None,
fail_action: FailAction::Halt,
ff_only: false,
},
],
}
}
fn flow_with(stages: &[(&str, FailAction)]) -> Flow {
Flow {
name: "example".into(),
stages: stages
.iter()
.map(|(name, fail_action)| Stage {
name: (*name).into(),
action: Actor::Exec {
cmd: vec!["true".into()],
},
result_check: Check::None,
fail_action: fail_action.clone(),
ff_only: false,
})
.collect(),
}
}
fn return_to(stage: &str, attempts: u32) -> FailAction {
FailAction::ReturnTo {
stage: stage.into(),
attempts,
}
}
fn row(flow: &Flow, index: usize, attempt: u32, verdict: Verdict) -> StageEvidence {
StageEvidence {
stage: flow.stages[index].name.clone(),
stage_index: index,
attempt,
verdict,
source: VerdictSource::ExitCode,
reason: None,
}
}
fn pass(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
row(flow, index, attempt, Verdict::Pass)
}
fn fail(flow: &Flow, index: usize, attempt: u32) -> StageEvidence {
row(flow, index, attempt, Verdict::Fail)
}
fn passed(flow: &Flow, index: usize) -> StageEvidence {
pass(flow, index, 1)
}
fn failed(flow: &Flow, index: usize) -> StageEvidence {
fail(flow, index, 1)
}
fn run(flow: &Flow, index: usize, attempt: u32) -> Step<'_> {
Step::Run {
stage: &flow.stages[index],
attempt,
}
}
fn halted(stage: &str, reason: HaltReason) -> Step<'static> {
Step::Halted {
failed_stage: stage.into(),
reason,
}
}
#[test]
fn a_linear_log_of_passes_advances_one_stage_per_row() {
let flow = build_review_merge();
assert_eq!(next_step(&flow, &[]), run(&flow, 0, 1));
assert_eq!(next_step(&flow, &[passed(&flow, 0)]), run(&flow, 1, 1));
assert_eq!(
next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
run(&flow, 2, 1)
);
}
#[test]
fn next_step_is_complete_only_when_the_cursor_runs_off_the_end() {
let flow = build_review_merge();
assert_eq!(
next_step(
&flow,
&[passed(&flow, 0), passed(&flow, 1), passed(&flow, 2)]
),
Step::Complete
);
assert_ne!(
next_step(&flow, &[passed(&flow, 0), passed(&flow, 1)]),
Step::Complete
);
}
#[test]
fn a_failed_row_halts_the_walk_and_later_stages_are_never_requested() {
let flow = build_review_merge();
let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
assert_eq!(
next_step(&flow, &evidence),
halted("review", HaltReason::FailActionHalt)
);
}
#[test]
fn a_continue_fail_action_advances_past_the_failure() {
let flow = flow_with(&[
("build", FailAction::Halt),
("lint", FailAction::Continue),
("merge", FailAction::Halt),
]);
let evidence = [passed(&flow, 0), failed(&flow, 1)];
assert_eq!(next_step(&flow, &evidence), run(&flow, 2, 1));
let evidence = [passed(&flow, 0), failed(&flow, 1), passed(&flow, 2)];
assert_eq!(next_step(&flow, &evidence), Step::Complete);
}
#[test]
fn a_return_to_loop_converges_on_the_second_attempt() {
let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
let mut log = vec![pass(&flow, 0, 1), fail(&flow, 1, 1)];
assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
log.push(pass(&flow, 0, 2));
assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
log.push(pass(&flow, 1, 2));
assert_eq!(next_step(&flow, &log), Step::Complete);
}
#[test]
fn a_return_to_loop_halts_once_its_edge_budget_is_spent() {
let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 1))]);
let log = vec![
pass(&flow, 0, 1),
fail(&flow, 1, 1),
pass(&flow, 0, 2),
fail(&flow, 1, 2),
];
assert_eq!(
next_step(&flow, &log),
halted("test", HaltReason::ReturnBudgetExhausted)
);
}
#[test]
fn distinct_backward_edges_keep_independent_budgets() {
let flow = flow_with(&[
("build", FailAction::Halt),
("test", return_to("build", 1)),
("review", return_to("test", 1)),
]);
let mut log = vec![
pass(&flow, 0, 1),
fail(&flow, 1, 1),
pass(&flow, 0, 2),
pass(&flow, 1, 2),
fail(&flow, 2, 1),
];
assert_eq!(next_step(&flow, &log), run(&flow, 1, 3));
log.push(fail(&flow, 1, 3));
assert_eq!(
next_step(&flow, &log),
halted("test", HaltReason::ReturnBudgetExhausted)
);
}
#[test]
fn a_jump_supersedes_the_passes_recorded_inside_its_span() {
let flow = flow_with(&[
("build", FailAction::Halt),
("lint", FailAction::Halt),
("test", return_to("build", 1)),
]);
let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
assert_eq!(next_step(&flow, &log), run(&flow, 0, 2));
log.push(pass(&flow, 0, 2));
assert_eq!(next_step(&flow, &log), run(&flow, 1, 2));
}
#[test]
fn a_row_the_cursor_did_not_expect_is_a_corrupt_log() {
let flow = build_review_merge();
assert_eq!(
next_step(&flow, &[passed(&flow, 1)]),
halted("build", HaltReason::CorruptLog)
);
assert_eq!(
next_step(&flow, &[passed(&flow, 0), passed(&flow, 0)]),
halted("review", HaltReason::CorruptLog)
);
assert_eq!(
next_step(&flow, &[pass(&flow, 0, 2)]),
halted("build", HaltReason::CorruptLog)
);
assert_eq!(
next_step(
&flow,
&[
passed(&flow, 0),
passed(&flow, 1),
passed(&flow, 2),
pass(&flow, 2, 2),
]
),
halted("merge", HaltReason::CorruptLog)
);
}
#[test]
fn a_return_to_with_no_such_stage_is_a_corrupt_log() {
let flow = flow_with(&[
("build", FailAction::Halt),
("test", return_to("nowhere", 1)),
]);
assert_eq!(
next_step(&flow, &[passed(&flow, 0), failed(&flow, 1)]),
halted("test", HaltReason::CorruptLog)
);
}
#[test]
fn the_return_trigger_is_the_failure_the_walk_jumped_on() {
let flow = flow_with(&[
("build", FailAction::Halt),
("lint", FailAction::Halt),
("test", return_to("build", 2)),
]);
assert_eq!(return_trigger(&flow, &[]), None);
assert_eq!(return_trigger(&flow, &[pass(&flow, 0, 1)]), None);
assert_eq!(
return_trigger(&flow, &[pass(&flow, 0, 1), fail(&flow, 1, 1)]),
None
);
let mut log = vec![pass(&flow, 0, 1), pass(&flow, 1, 1), fail(&flow, 2, 1)];
assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
log.push(pass(&flow, 0, 2));
assert_eq!(return_trigger(&flow, &log), Some((2, 1)));
log.extend([pass(&flow, 1, 2), fail(&flow, 2, 2)]);
assert_eq!(return_trigger(&flow, &log), Some((2, 2)));
}
#[test]
fn replaying_an_identical_log_yields_an_identical_step() {
let flow = flow_with(&[("build", FailAction::Halt), ("test", return_to("build", 2))]);
let log = [
pass(&flow, 0, 1),
fail(&flow, 1, 1),
pass(&flow, 0, 2),
fail(&flow, 1, 2),
];
assert_eq!(next_step(&flow, &log), next_step(&flow, &log));
assert_eq!(next_step(&flow, &log), run(&flow, 0, 3));
}
#[test]
fn only_reported_policy_consults_reported_verdicts() {
assert_eq!(
resolve_verdict(&Check::None, Verdict::Pass, None),
(Verdict::Pass, VerdictSource::ExitCode, None)
);
let reported = Reported {
verdict: Verdict::Fail,
reason: Some("changes requested".into()),
};
assert_eq!(
resolve_verdict(&Check::Reported, Verdict::Pass, Some(reported)),
(
Verdict::Fail,
VerdictSource::Reported,
Some("changes requested".into())
)
);
}
#[test]
fn non_reported_policies_ignore_reports() {
let reported = Reported {
verdict: Verdict::Pass,
reason: Some("looks fine to me".into()),
};
assert_eq!(
resolve_verdict(&commits(), Verdict::Fail, Some(reported)),
(Verdict::Fail, VerdictSource::ExitCode, None)
);
}
#[test]
fn missing_report_is_a_failed_reported_verdict() {
assert_eq!(
resolve_verdict(&Check::Reported, Verdict::Pass, None),
(
Verdict::Fail,
VerdictSource::Reported,
Some("no verdict reported".into())
)
);
}
}