Skip to main content

sloop/
flow.rs

1//! Flow definitions and the pure walk over them. Parsing turns a committed
2//! YAML file into a validated `Flow`; `next_step` then turns a flow and the
3//! evidence gathered so far into the next stage to run or a terminal
4//! reading. Neither half touches a clock, a process, or the store, so
5//! policy can be tested without a daemon.
6
7use std::collections::HashSet;
8
9use serde::{Deserialize, Deserializer, Serialize};
10
11pub const DEFAULT_FLOW_NAME: &str = "default";
12pub const REVIEW_PROMPT_PATH: &str = ".agents/sloop/prompts/review.md";
13pub const REVIEW_PROMPT_INSTRUCTION: &str = "Review the completed work for correctness and regressions. Run relevant tests, then report a pass or fail verdict with a concise reason.";
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Flow {
17    pub name: String,
18    pub stages: Vec<Stage>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct Stage {
23    pub name: String,
24    pub kind: StageKind,
25    pub verdict: VerdictPolicy,
26    /// Optional repair agent for `exec` and `merge` stages. When the stage
27    /// fails, this agent is spawned in the run worktree to fix the tree in
28    /// place; the stage is then re-run and its own verdict policy re-applied.
29    /// The repair agent never produces the verdict.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub on_fail: Option<OnFail>,
32}
33
34/// A stage's optional repair configuration. It configures the repair worker
35/// (prompt, attempt budget, and target/model/effort overrides) but can never
36/// alter the stage's verdict policy, command, or ordering.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct OnFail {
39    /// The prompt handed to the repair agent.
40    pub agent: String,
41    /// How many repair-then-retry cycles are allowed per stage per run.
42    pub attempts: u32,
43    /// Agent target override; defaults to the ticket's target, then the
44    /// configured default target.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub target: Option<String>,
47    /// Model override; defaults to the ticket's model.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub model: Option<String>,
50    /// Effort override; defaults to the ticket's effort.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub effort: Option<String>,
53}
54
55/// The inclusive upper bound on `on_fail.attempts`.
56pub const MAX_ON_FAIL_ATTEMPTS: u32 = 3;
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub enum StageKind {
60    #[serde(alias = "Build")]
61    Agent,
62    Merge,
63    Exec {
64        cmd: Vec<String>,
65    },
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub enum VerdictPolicy {
70    Exit,
71    Commits,
72    Check { cmd: Vec<String> },
73    Reported,
74}
75
76impl<'de> Deserialize<'de> for Stage {
77    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
78    where
79        D: Deserializer<'de>,
80    {
81        #[derive(Deserialize)]
82        struct SnapshotStage {
83            name: String,
84            kind: StageKind,
85            verdict: Option<VerdictPolicy>,
86            #[serde(default)]
87            on_fail: Option<OnFail>,
88        }
89
90        let stage = SnapshotStage::deserialize(deserializer)?;
91        let verdict = stage.verdict.unwrap_or(match &stage.kind {
92            StageKind::Agent => VerdictPolicy::Commits,
93            StageKind::Exec { .. } | StageKind::Merge => VerdictPolicy::Exit,
94        });
95        Ok(Self {
96            name: stage.name,
97            kind: stage.kind,
98            verdict,
99            on_fail: stage.on_fail,
100        })
101    }
102}
103
104pub fn parse(name: &str, contents: &str) -> Result<Flow, String> {
105    let file: RawFlowFile = serde_yaml::from_str(contents).map_err(|error| error.to_string())?;
106    let raw_stages = match file {
107        RawFlowFile::List(stages) => stages,
108        RawFlowFile::Map { stages } => stages,
109    };
110
111    let mut stages = Vec::with_capacity(raw_stages.len());
112    let mut names = HashSet::new();
113    for raw in raw_stages {
114        if !names.insert(raw.name.clone()) {
115            return Err(format!("duplicate stage name `{}`", raw.name));
116        }
117        let kind = match raw.kind.as_str() {
118            "agent" | "build" => {
119                if raw.cmd.is_some() {
120                    return Err(format!("agent stage `{}` must not define `cmd`", raw.name));
121                }
122                StageKind::Agent
123            }
124            "merge" => {
125                if raw.cmd.is_some() {
126                    return Err(format!("merge stage `{}` must not define `cmd`", raw.name));
127                }
128                StageKind::Merge
129            }
130            "exec" => {
131                let cmd = raw.cmd.unwrap_or_default();
132                if cmd.is_empty() {
133                    return Err(format!(
134                        "exec stage `{}` must define a non-empty `cmd`",
135                        raw.name
136                    ));
137                }
138                StageKind::Exec { cmd }
139            }
140            kind => return Err(format!("stage `{}` has unknown kind `{kind}`", raw.name)),
141        };
142        let verdict = match (&kind, raw.verdict) {
143            (StageKind::Merge, Some(_)) => {
144                return Err(format!(
145                    "merge stage `{}` must not define `verdict`",
146                    raw.name
147                ));
148            }
149            (StageKind::Merge | StageKind::Exec { .. }, None) => VerdictPolicy::Exit,
150            (StageKind::Agent, None) => VerdictPolicy::Commits,
151            (_, Some(RawVerdict::Name(name))) => match name.as_str() {
152                "exit" => VerdictPolicy::Exit,
153                "commits" => VerdictPolicy::Commits,
154                "reported" => VerdictPolicy::Reported,
155                _ => {
156                    return Err(format!(
157                        "stage `{}` has unknown verdict policy `{name}`",
158                        raw.name
159                    ));
160                }
161            },
162            (_, Some(RawVerdict::Check { check })) => {
163                if check.is_empty() {
164                    return Err(format!(
165                        "stage `{}` check verdict must define a non-empty command",
166                        raw.name
167                    ));
168                }
169                VerdictPolicy::Check { cmd: check }
170            }
171        };
172        let on_fail = match raw.on_fail {
173            None => None,
174            Some(_) if kind == StageKind::Agent => {
175                return Err(format!(
176                    "agent stage `{}` must not define `on_fail`",
177                    raw.name
178                ));
179            }
180            Some(on_fail) => Some(validate_on_fail(&raw.name, on_fail)?),
181        };
182        stages.push(Stage {
183            name: raw.name,
184            kind,
185            verdict,
186            on_fail,
187        });
188    }
189
190    validate_order(&stages)?;
191    Ok(Flow {
192        name: name.to_owned(),
193        stages,
194    })
195}
196
197/// Validates an `on_fail` block's own shape. Target existence is checked
198/// later, where the configured agent targets are known (see `config.rs`).
199fn validate_on_fail(stage: &str, raw: RawOnFail) -> Result<OnFail, String> {
200    if raw.agent.trim().is_empty() {
201        return Err(format!(
202            "stage `{stage}` on_fail must define a non-empty `agent` prompt"
203        ));
204    }
205    let attempts = raw.attempts.unwrap_or(1);
206    if attempts == 0 || attempts > MAX_ON_FAIL_ATTEMPTS {
207        return Err(format!(
208            "stage `{stage}` on_fail attempts must be between 1 and {MAX_ON_FAIL_ATTEMPTS}"
209        ));
210    }
211    Ok(OnFail {
212        agent: raw.agent,
213        attempts,
214        target: raw.target,
215        model: raw.model,
216        effort: raw.effort,
217    })
218}
219
220pub(crate) fn built_in_default() -> Flow {
221    let stages = vec![
222        Stage {
223            name: "build".into(),
224            kind: StageKind::Agent,
225            verdict: VerdictPolicy::Commits,
226            on_fail: None,
227        },
228        Stage {
229            name: "merge".into(),
230            kind: StageKind::Merge,
231            verdict: VerdictPolicy::Exit,
232            on_fail: None,
233        },
234    ];
235    Flow {
236        name: DEFAULT_FLOW_NAME.into(),
237        stages,
238    }
239}
240
241fn validate_order(stages: &[Stage]) -> Result<(), String> {
242    if !stages
243        .first()
244        .is_some_and(|stage| stage.kind == StageKind::Agent)
245    {
246        return Err("the first stage must be an agent stage".into());
247    }
248    let agent_count = stages
249        .iter()
250        .filter(|stage| stage.kind == StageKind::Agent)
251        .count();
252    if agent_count > 1 {
253        return Err(
254            "only the first stage may be an agent stage; additional agent stages require runner support"
255                .into(),
256        );
257    }
258
259    let merge_count = stages
260        .iter()
261        .filter(|stage| stage.kind == StageKind::Merge)
262        .count();
263    if merge_count > 1 {
264        return Err(format!(
265            "flow may contain at most one merge stage; found {merge_count}"
266        ));
267    }
268    if merge_count == 1 && stages.last().map(|stage| &stage.kind) != Some(&StageKind::Merge) {
269        return Err("merge stage must be last".into());
270    }
271    Ok(())
272}
273
274/// A stage's pass/fail reading. Richer verdicts (e.g. `changes-requested`)
275/// are a later phase; v1 is strictly binary.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum Verdict {
278    Pass,
279    Fail,
280}
281
282/// Where a stage's verdict came from.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum VerdictSource {
285    /// The stage process's own exit status: 0 is `Pass`, anything else is
286    /// `Fail`.
287    ExitCode,
288    /// A worker called `sloop verdict` over its stage's socket.
289    Reported,
290}
291
292/// A worker's self-reported verdict for the stage it is running, gated to
293/// at most one report per stage.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct Reported {
296    pub verdict: Verdict,
297    pub reason: Option<String>,
298}
299
300/// One stage's recorded result. Rows persist as they are produced, so a
301/// daemon crash mid-flow resumes idempotently at the first stage without a
302/// row: `next_step` re-derives the same answer from the same rows.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct StageEvidence {
305    pub stage: String,
306    pub verdict: Verdict,
307    pub source: VerdictSource,
308    pub reason: Option<String>,
309}
310
311/// Resolves a stage's verdict, source, and reason from the evidence selected
312/// by its policy. Reports are authoritative only for `Reported`; other
313/// policies ignore them.
314pub fn resolve_verdict(
315    policy: &VerdictPolicy,
316    exit: Verdict,
317    reported: Option<Reported>,
318) -> (Verdict, VerdictSource, Option<String>) {
319    if *policy != VerdictPolicy::Reported {
320        return (exit, VerdictSource::ExitCode, None);
321    }
322    match reported {
323        Some(reported) => (reported.verdict, VerdictSource::Reported, reported.reason),
324        None => (
325            Verdict::Fail,
326            VerdictSource::Reported,
327            Some("no verdict reported".into()),
328        ),
329    }
330}
331
332/// What the walk does next, given a flow and its evidence so far.
333#[derive(Debug, PartialEq, Eq)]
334pub enum Step<'a> {
335    /// The first stage without an evidence row; every row before it is
336    /// `Pass`.
337    Run(&'a Stage),
338    /// Some row is `Fail`; the walk stops there. Stages after it are never
339    /// requested.
340    Halted { failed_stage: String },
341    /// Every stage has a `Pass` row.
342    Complete,
343}
344
345/// The pure decision at the heart of a flow: given the flow and the
346/// evidence recorded so far, what runs next. Linear and halt-on-fail, with
347/// no notion of loops, branches, or retries (see `sloop-flows.md` §4).
348///
349/// Because this only reads persisted evidence rows and never a clock or a
350/// process, resuming after a crash with the same rows yields the same
351/// `Step`: the walk is idempotent by construction.
352pub fn next_step<'a>(flow: &'a Flow, evidence: &[StageEvidence]) -> Step<'a> {
353    for stage in &flow.stages {
354        match evidence.iter().find(|row| row.stage == stage.name) {
355            None => return Step::Run(stage),
356            Some(row) if row.verdict == Verdict::Pass => continue,
357            Some(row) => {
358                return Step::Halted {
359                    failed_stage: row.stage.clone(),
360                };
361            }
362        }
363    }
364    Step::Complete
365}
366
367#[derive(Debug, Deserialize)]
368#[serde(untagged)]
369enum RawFlowFile {
370    List(Vec<RawStage>),
371    Map { stages: Vec<RawStage> },
372}
373
374#[derive(Debug, Deserialize)]
375struct RawStage {
376    name: String,
377    kind: String,
378    cmd: Option<Vec<String>>,
379    verdict: Option<RawVerdict>,
380    on_fail: Option<RawOnFail>,
381}
382
383#[derive(Debug, Deserialize)]
384#[serde(deny_unknown_fields)]
385struct RawOnFail {
386    agent: String,
387    attempts: Option<u32>,
388    target: Option<String>,
389    model: Option<String>,
390    effort: Option<String>,
391}
392
393#[derive(Debug, Deserialize)]
394#[serde(untagged)]
395enum RawVerdict {
396    Name(String),
397    Check { check: Vec<String> },
398}
399
400#[cfg(test)]
401mod tests {
402    use super::{
403        Flow, Reported, Stage, StageEvidence, StageKind, Step, Verdict, VerdictPolicy,
404        VerdictSource, next_step, parse, resolve_verdict,
405    };
406
407    fn error(yaml: &str) -> String {
408        parse("example", yaml).unwrap_err()
409    }
410
411    #[test]
412    fn valid_multi_stage_flow_parses_in_order() {
413        let flow = parse(
414            "release",
415            "stages:\n  - name: build\n    kind: agent\n  - name: test\n    kind: exec\n    cmd: [cargo, test]\n    verdict: { check: [cargo, clippy] }\n  - name: merge\n    kind: merge\n",
416        )
417        .unwrap();
418
419        assert_eq!(
420            flow,
421            Flow {
422                name: "release".into(),
423                stages: vec![
424                    Stage {
425                        name: "build".into(),
426                        kind: StageKind::Agent,
427                        verdict: VerdictPolicy::Commits,
428                        on_fail: None,
429                    },
430                    Stage {
431                        name: "test".into(),
432                        kind: StageKind::Exec {
433                            cmd: vec!["cargo".into(), "test".into()],
434                        },
435                        verdict: VerdictPolicy::Check {
436                            cmd: vec!["cargo".into(), "clippy".into()],
437                        },
438                        on_fail: None,
439                    },
440                    Stage {
441                        name: "merge".into(),
442                        kind: StageKind::Merge,
443                        verdict: VerdictPolicy::Exit,
444                        on_fail: None,
445                    },
446                ],
447            }
448        );
449    }
450
451    #[test]
452    fn build_is_a_deprecated_alias_for_agent() {
453        let flow = parse("example", "- { name: build, kind: build }\n").unwrap();
454        assert_eq!(flow.stages[0].kind, StageKind::Agent);
455        assert_eq!(flow.stages[0].verdict, VerdictPolicy::Commits);
456    }
457
458    #[test]
459    fn old_build_snapshots_deserialize_with_the_agent_default() {
460        let flow: Flow = serde_json::from_str(
461            r#"{"name":"example","stages":[{"name":"build","kind":"Build"}]}"#,
462        )
463        .unwrap();
464        assert_eq!(flow.stages[0].kind, StageKind::Agent);
465        assert_eq!(flow.stages[0].verdict, VerdictPolicy::Commits);
466    }
467
468    #[test]
469    fn verdict_policies_and_defaults_parse() {
470        let flow = parse(
471            "example",
472            "- { name: build, kind: agent, verdict: exit }\n- { name: test, kind: exec, cmd: ['true'], verdict: commits }\n- { name: review, kind: exec, cmd: ['true'], verdict: reported }\n",
473        )
474        .unwrap();
475        assert_eq!(flow.stages[0].verdict, VerdictPolicy::Exit);
476        assert_eq!(flow.stages[1].verdict, VerdictPolicy::Commits);
477        assert_eq!(flow.stages[2].verdict, VerdictPolicy::Reported);
478
479        let defaults = parse(
480            "example",
481            "- { name: build, kind: agent }\n- { name: test, kind: exec, cmd: ['true'] }\n",
482        )
483        .unwrap();
484        assert_eq!(defaults.stages[0].verdict, VerdictPolicy::Commits);
485        assert_eq!(defaults.stages[1].verdict, VerdictPolicy::Exit);
486    }
487
488    #[test]
489    fn on_fail_parses_with_defaults_and_overrides() {
490        let flow = parse(
491            "example",
492            "- { name: build, kind: agent }\n- name: test\n  kind: exec\n  cmd: [cargo, test]\n  on_fail:\n    agent: fix the tests\n- name: merge\n  kind: merge\n  on_fail:\n    agent: integrate the default branch\n    attempts: 2\n    target: claude\n    model: haiku\n    effort: low\n",
493        )
494        .unwrap();
495
496        let test = flow.stages[1].on_fail.as_ref().unwrap();
497        assert_eq!(test.agent, "fix the tests");
498        assert_eq!(test.attempts, 1);
499        assert_eq!(test.target, None);
500
501        let merge = flow.stages[2].on_fail.as_ref().unwrap();
502        assert_eq!(merge.attempts, 2);
503        assert_eq!(merge.target.as_deref(), Some("claude"));
504        assert_eq!(merge.model.as_deref(), Some("haiku"));
505        assert_eq!(merge.effort.as_deref(), Some("low"));
506    }
507
508    #[test]
509    fn on_fail_survives_a_snapshot_round_trip() {
510        let flow = parse(
511            "example",
512            "- { name: build, kind: agent }\n- name: test\n  kind: exec\n  cmd: [cargo, test]\n  on_fail:\n    agent: fix the tests\n    attempts: 3\n    model: haiku\n",
513        )
514        .unwrap();
515        let snapshot = serde_json::to_string(&flow).unwrap();
516        let restored: Flow = serde_json::from_str(&snapshot).unwrap();
517        assert_eq!(flow, restored);
518        assert_eq!(restored.stages[1].on_fail.as_ref().unwrap().attempts, 3);
519    }
520
521    #[test]
522    fn on_fail_is_rejected_on_agent_stages() {
523        let error = error(
524            "- name: build\n  kind: agent\n  on_fail:\n    agent: patch it\n- { name: merge, kind: merge }\n",
525        );
526        assert!(error.contains("agent stage `build`"), "{error}");
527        assert!(error.contains("must not define `on_fail`"), "{error}");
528    }
529
530    #[test]
531    fn on_fail_rejects_an_empty_prompt() {
532        let error = error(
533            "- { name: build, kind: agent }\n- name: test\n  kind: exec\n  cmd: ['true']\n  on_fail:\n    agent: '   '\n",
534        );
535        assert!(error.contains("stage `test`"), "{error}");
536        assert!(error.contains("non-empty `agent` prompt"), "{error}");
537    }
538
539    #[test]
540    fn on_fail_rejects_out_of_range_attempts() {
541        for attempts in ["0", "4"] {
542            let error = error(&format!(
543                "- {{ name: build, kind: agent }}\n- name: test\n  kind: exec\n  cmd: ['true']\n  on_fail:\n    agent: fix it\n    attempts: {attempts}\n",
544            ));
545            assert!(error.contains("stage `test`"), "{error}");
546            assert!(
547                error.contains("attempts must be between 1 and 3"),
548                "{error}"
549            );
550        }
551    }
552
553    #[test]
554    fn merge_stages_reject_verdict_policies() {
555        let error = error(
556            "- { name: build, kind: agent }\n- { name: merge, kind: merge, verdict: exit }\n",
557        );
558        assert!(error.contains("must not define `verdict`"), "{error}");
559    }
560
561    #[test]
562    fn unknown_kinds_are_rejected() {
563        let error = error("- { name: build, kind: build }\n- { name: deploy, kind: magic }\n");
564        assert!(error.contains("unknown kind `magic`"), "{error}");
565    }
566
567    #[test]
568    fn duplicate_stage_names_are_rejected() {
569        let error = error("- { name: build, kind: build }\n- { name: build, kind: merge }\n");
570        assert!(error.contains("duplicate stage name `build`"), "{error}");
571    }
572
573    #[test]
574    fn exactly_one_first_agent_stage_is_required() {
575        let missing = error("- { name: check, kind: exec, cmd: ['true'] }\n");
576        assert!(
577            missing.contains("first stage must be an agent"),
578            "{missing}"
579        );
580
581        let duplicate = error("- { name: build, kind: agent }\n- { name: rebuild, kind: agent }\n");
582        assert!(duplicate.contains("require runner support"), "{duplicate}");
583    }
584
585    #[test]
586    fn at_most_one_merge_stage_is_allowed() {
587        let error = error(
588            "- { name: build, kind: build }\n- { name: merge-one, kind: merge }\n- { name: merge-two, kind: merge }\n",
589        );
590        assert!(error.contains("at most one merge stage"), "{error}");
591    }
592
593    #[test]
594    fn merge_stage_must_be_last() {
595        let error = error(
596            "- { name: build, kind: build }\n- { name: merge, kind: merge }\n- { name: check, kind: exec, cmd: ['true'] }\n",
597        );
598        assert!(error.contains("merge stage must be last"), "{error}");
599    }
600
601    #[test]
602    fn exec_stage_command_must_be_nonempty() {
603        for yaml in [
604            "- { name: build, kind: build }\n- { name: check, kind: exec }\n",
605            "- { name: build, kind: build }\n- { name: check, kind: exec, cmd: [] }\n",
606        ] {
607            let error = error(yaml);
608            assert!(error.contains("non-empty `cmd`"), "{error}");
609        }
610    }
611
612    fn build_review_merge() -> Flow {
613        Flow {
614            name: "example".into(),
615            stages: vec![
616                Stage {
617                    name: "build".into(),
618                    kind: StageKind::Agent,
619                    verdict: VerdictPolicy::Commits,
620                    on_fail: None,
621                },
622                Stage {
623                    name: "review".into(),
624                    kind: StageKind::Exec {
625                        cmd: vec!["true".into()],
626                    },
627                    verdict: VerdictPolicy::Exit,
628                    on_fail: None,
629                },
630                Stage {
631                    name: "merge".into(),
632                    kind: StageKind::Merge,
633                    verdict: VerdictPolicy::Exit,
634                    on_fail: None,
635                },
636            ],
637        }
638    }
639
640    fn passed(stage: &str) -> StageEvidence {
641        StageEvidence {
642            stage: stage.into(),
643            verdict: Verdict::Pass,
644            source: VerdictSource::ExitCode,
645            reason: None,
646        }
647    }
648
649    fn failed(stage: &str) -> StageEvidence {
650        StageEvidence {
651            stage: stage.into(),
652            verdict: Verdict::Fail,
653            source: VerdictSource::ExitCode,
654            reason: None,
655        }
656    }
657
658    #[test]
659    fn next_step_selects_the_first_stage_without_a_row() {
660        let flow = build_review_merge();
661
662        assert_eq!(next_step(&flow, &[]), Step::Run(&flow.stages[0]));
663        assert_eq!(
664            next_step(&flow, &[passed("build")]),
665            Step::Run(&flow.stages[1])
666        );
667        assert_eq!(
668            next_step(&flow, &[passed("build"), passed("review")]),
669            Step::Run(&flow.stages[2])
670        );
671    }
672
673    #[test]
674    fn next_step_is_complete_only_when_every_stage_passed() {
675        let flow = build_review_merge();
676
677        assert_eq!(
678            next_step(&flow, &[passed("build"), passed("review"), passed("merge")]),
679            Step::Complete
680        );
681        assert_ne!(
682            next_step(&flow, &[passed("build"), passed("review")]),
683            Step::Complete
684        );
685    }
686
687    #[test]
688    fn a_failed_row_halts_the_walk_and_later_stages_are_never_requested() {
689        let flow = build_review_merge();
690
691        // A `merge` row is present despite `review` failing first; the walk
692        // must still halt at `review`, proving stages after a failure are
693        // never requested even if evidence for them exists.
694        let evidence = [passed("build"), failed("review"), passed("merge")];
695
696        assert_eq!(
697            next_step(&flow, &evidence),
698            Step::Halted {
699                failed_stage: "review".into()
700            }
701        );
702    }
703
704    #[test]
705    fn resuming_with_identical_evidence_yields_an_identical_step() {
706        let flow = build_review_merge();
707        let evidence = [passed("build")];
708
709        assert_eq!(next_step(&flow, &evidence), next_step(&flow, &evidence));
710    }
711
712    #[test]
713    fn only_reported_policy_consults_reported_verdicts() {
714        assert_eq!(
715            resolve_verdict(&VerdictPolicy::Exit, Verdict::Pass, None),
716            (Verdict::Pass, VerdictSource::ExitCode, None)
717        );
718
719        let reported = Reported {
720            verdict: Verdict::Fail,
721            reason: Some("changes requested".into()),
722        };
723        assert_eq!(
724            resolve_verdict(&VerdictPolicy::Reported, Verdict::Pass, Some(reported)),
725            (
726                Verdict::Fail,
727                VerdictSource::Reported,
728                Some("changes requested".into())
729            )
730        );
731    }
732
733    #[test]
734    fn non_reported_policies_ignore_reports() {
735        let reported = Reported {
736            verdict: Verdict::Pass,
737            reason: Some("looks fine to me".into()),
738        };
739
740        assert_eq!(
741            resolve_verdict(&VerdictPolicy::Commits, Verdict::Fail, Some(reported)),
742            (Verdict::Fail, VerdictSource::ExitCode, None)
743        );
744    }
745
746    #[test]
747    fn missing_report_is_a_failed_reported_verdict() {
748        assert_eq!(
749            resolve_verdict(&VerdictPolicy::Reported, Verdict::Pass, None),
750            (
751                Verdict::Fail,
752                VerdictSource::Reported,
753                Some("no verdict reported".into())
754            )
755        );
756    }
757}