Skip to main content

sloop/flow/
parse.rs

1//! The YAML grammar: what a flow file may say, and the validation that turns
2//! it into a `Flow`. Each `Raw*` wire type sits beside the function that is
3//! its only reader, so a change to the grammar is a change to one adjacent
4//! pair rather than to two halves of a file.
5
6use std::collections::HashSet;
7
8use serde::Deserialize;
9use serde::de::IgnoredAny;
10
11use super::panel::{RawPanel, parse_panel};
12use super::{
13    Actor, Builtin, Check, FailAction, Flow, MAX_FLOW_EXECUTIONS, MAX_RETURN_ATTEMPTS, Stage,
14};
15
16pub fn parse(name: &str, contents: &str) -> Result<Flow, String> {
17    let file: RawFlowFile = serde_yaml::from_str(contents).map_err(|error| error.to_string())?;
18    let raw_stages = match file {
19        RawFlowFile::List(stages) => stages,
20        RawFlowFile::Map { stages } => stages,
21    };
22
23    let mut stages = Vec::with_capacity(raw_stages.len());
24    let mut names = HashSet::new();
25    for raw in raw_stages {
26        if !names.insert(raw.name.clone()) {
27            return Err(format!("duplicate stage name `{}`", raw.name));
28        }
29        reject_removed_keys(&raw)?;
30        let (action, ff_only) = parse_action(&raw.name, raw.action)?;
31        let result_check = parse_result_check(&raw.name, &action, raw.result_check)?;
32        let fail_action = parse_fail_action(&raw.name, raw.fail_action)?;
33        validate_stage(&raw.name, &action, &result_check)?;
34        stages.push(Stage {
35            name: raw.name,
36            action,
37            result_check,
38            fail_action,
39            ff_only,
40        });
41    }
42
43    validate_order(&stages)?;
44    Ok(Flow {
45        name: name.to_owned(),
46        stages,
47    })
48}
49
50#[derive(Debug, Deserialize)]
51#[serde(untagged)]
52enum RawFlowFile {
53    List(Vec<RawStage>),
54    Map { stages: Vec<RawStage> },
55}
56
57/// A stage exactly as written.
58///
59/// The removed keys are still read — as payloads nobody looks at — so that
60/// `reject_removed_keys` can name them. Unknown fields are otherwise accepted
61/// here, so dropping them outright would turn an older flow file into a stage
62/// that appears to define nothing at all, or into one whose repair block was
63/// quietly ignored.
64#[derive(Debug, Deserialize)]
65struct RawStage {
66    name: String,
67    action: Option<RawActor>,
68    result_check: Option<RawCheck>,
69    fail_action: Option<RawFailAction>,
70    /// Removed in 0.4.0; `action` replaces it.
71    kind: Option<IgnoredAny>,
72    /// Removed in 0.4.0; `action: { exec: [...] }` replaces it.
73    cmd: Option<IgnoredAny>,
74    /// Removed in 0.4.0; `result_check` replaces it.
75    verdict: Option<IgnoredAny>,
76    /// Removed in 0.4.0; `fail_action: { return_to: ... }` replaces it.
77    on_fail: Option<IgnoredAny>,
78}
79
80/// Refuses every key the current grammar has dropped, by name. The keys are
81/// still read off the stage (see [`RawStage`]) for exactly this: a flow file
82/// written against an older grammar *does* say what its stages are, in a
83/// spelling nothing reads any more, and the error that names the replacement is
84/// the whole migration experience for whoever wrote it. Silence — or a generic
85/// "must define an `action`" — would leave them to diff against a template.
86fn reject_removed_keys(raw: &RawStage) -> Result<(), String> {
87    let stage = &raw.name;
88    if raw.kind.is_some() {
89        return Err(format!(
90            "stage `{stage}` uses the removed `kind` key; write `action: agent` instead \
91             (see the 0.4.0 migration table in CHANGELOG.md)"
92        ));
93    }
94    if raw.cmd.is_some() {
95        return Err(format!(
96            "stage `{stage}` uses the removed `cmd` key; write `action: {{ exec: [...] }}`"
97        ));
98    }
99    if raw.verdict.is_some() {
100        return Err(format!(
101            "stage `{stage}` uses the removed `verdict` key; write `result_check: ...`"
102        ));
103    }
104    if raw.on_fail.is_some() {
105        return Err(format!(
106            "stage `{stage}` uses the removed `on_fail` key; write \
107             `fail_action: {{ return_to: <stage>, attempts: N }}` instead"
108        ));
109    }
110    Ok(())
111}
112
113/// `action: agent` | `{ agent: <ignored> }` | `{ exec: [argv] }` |
114/// `{ builtin: merge | sync }`. The `agent` payload is accepted and discarded:
115/// an agent action's prompt comes from the ticket, not the flow file.
116///
117/// `ff_only` is carried on every mapping variant, not just the builtin one, so
118/// that writing it on an action that cannot honour it is an error the author
119/// sees rather than a key that is silently dropped.
120#[derive(Debug, Deserialize)]
121#[serde(untagged)]
122enum RawActor {
123    Name(String),
124    Agent {
125        /// Present only so `{ agent: <prompt> }` matches this variant; the
126        /// payload is deliberately discarded.
127        #[allow(dead_code)]
128        agent: IgnoredAny,
129        ff_only: Option<bool>,
130    },
131    Exec {
132        exec: Vec<String>,
133        ff_only: Option<bool>,
134    },
135    Builtin {
136        builtin: String,
137        ff_only: Option<bool>,
138    },
139}
140
141/// Resolves a stage's action, along with the `ff_only` option the merge
142/// builtin alone accepts.
143fn parse_action(stage: &str, action: Option<RawActor>) -> Result<(Actor, bool), String> {
144    let Some(action) = action else {
145        return Err(format!("stage `{stage}` must define an `action`"));
146    };
147    match action {
148        RawActor::Agent { ff_only, .. } => {
149            reject_ff_only(stage, ff_only)?;
150            Ok((Actor::Agent, false))
151        }
152        RawActor::Exec { exec, ff_only } => {
153            reject_ff_only(stage, ff_only)?;
154            if exec.is_empty() {
155                return Err(format!(
156                    "stage `{stage}` exec action must define a non-empty command"
157                ));
158            }
159            Ok((Actor::Exec { cmd: exec }, false))
160        }
161        RawActor::Builtin { builtin, ff_only } => match builtin.as_str() {
162            "merge" => Ok((Actor::Builtin(Builtin::Merge), ff_only.unwrap_or_default())),
163            "sync" => {
164                reject_ff_only(stage, ff_only)?;
165                Ok((Actor::Builtin(Builtin::Sync), false))
166            }
167            "commits" => Err(format!(
168                "stage `{stage}` builtin `commits` is a result_check, not an action"
169            )),
170            other => Err(format!("stage `{stage}` has unknown builtin `{other}`")),
171        },
172        RawActor::Name(name) if name == "agent" => Ok((Actor::Agent, false)),
173        RawActor::Name(name) => Err(format!("stage `{stage}` has unknown action `{name}`")),
174    }
175}
176
177/// `ff_only` describes what the merge stage does to the default branch, so it
178/// says nothing anywhere else. Writing it elsewhere is far more likely to be a
179/// misplaced expectation than a harmless extra key, and is refused as one.
180fn reject_ff_only(stage: &str, ff_only: Option<bool>) -> Result<(), String> {
181    match ff_only {
182        None => Ok(()),
183        Some(_) => Err(format!(
184            "stage `{stage}` may only define `ff_only` on the `merge` builtin"
185        )),
186    }
187}
188
189/// `result_check: none | reported` | `{ exec: [argv] }` |
190/// `{ builtin: commits }` | `{ panel: {...} }`.
191///
192/// `Panel` is last because its payload is deliberately lenient — every field
193/// inside it is optional so `parse_panel` can name what is missing instead of
194/// serde reporting that nothing matched. Only the `panel` key itself is
195/// required, which is what keeps the other shapes from falling into it.
196#[derive(Debug, Deserialize)]
197#[serde(untagged)]
198enum RawCheck {
199    Name(String),
200    Exec {
201        exec: Vec<String>,
202    },
203    Builtin {
204        builtin: String,
205        ff_only: Option<bool>,
206    },
207    Panel {
208        panel: RawPanel,
209    },
210}
211
212/// Resolves a stage's result check, falling back to the check its action
213/// implies when the stage names none.
214fn parse_result_check(
215    stage: &str,
216    action: &Actor,
217    result_check: Option<RawCheck>,
218) -> Result<Check, String> {
219    let Some(check) = result_check else {
220        return Ok(default_check(action));
221    };
222    match check {
223        RawCheck::Exec { exec } => {
224            if exec.is_empty() {
225                return Err(format!(
226                    "stage `{stage}` exec result_check must define a non-empty command"
227                ));
228            }
229            Ok(Check::Actor(Actor::Exec { cmd: exec }))
230        }
231        RawCheck::Builtin { builtin, ff_only } => {
232            reject_ff_only(stage, ff_only)?;
233            match builtin.as_str() {
234                "commits" => Ok(Check::Actor(Actor::Builtin(Builtin::Commits))),
235                "merge" | "sync" => Err(format!(
236                    "stage `{stage}` result_check may not be the `{builtin}` builtin"
237                )),
238                other => Err(format!("stage `{stage}` has unknown builtin `{other}`")),
239            }
240        }
241        RawCheck::Panel { panel } => Ok(Check::Panel(parse_panel(stage, panel)?)),
242        RawCheck::Name(name) => match name.as_str() {
243            "none" => Ok(Check::None),
244            "reported" => Ok(Check::Reported),
245            other => Err(format!(
246                "stage `{stage}` has unknown result_check `{other}`"
247            )),
248        },
249    }
250}
251
252/// The check a stage gets when it names none: an agent is never trusted to
253/// grade itself by exiting cleanly, so it defaults to the commits gate;
254/// everything else is judged by its own exit.
255fn default_check(action: &Actor) -> Check {
256    match action {
257        Actor::Agent => Check::Actor(Actor::Builtin(Builtin::Commits)),
258        Actor::Exec { .. } | Actor::Builtin(_) => Check::None,
259    }
260}
261
262/// `fail_action: fail | continue` | `{ return_to: <stage>, attempts: N }`.
263#[derive(Debug, Deserialize)]
264#[serde(untagged)]
265enum RawFailAction {
266    Name(String),
267    ReturnTo {
268        return_to: String,
269        attempts: Option<u32>,
270    },
271}
272
273fn parse_fail_action(stage: &str, raw: Option<RawFailAction>) -> Result<FailAction, String> {
274    match raw {
275        None => Ok(FailAction::Halt),
276        Some(RawFailAction::ReturnTo {
277            return_to,
278            attempts,
279        }) => {
280            let attempts = attempts.unwrap_or(1);
281            if attempts == 0 || attempts > MAX_RETURN_ATTEMPTS {
282                return Err(format!(
283                    "stage `{stage}` return_to attempts must be between 1 and {MAX_RETURN_ATTEMPTS}"
284                ));
285            }
286            Ok(FailAction::ReturnTo {
287                stage: return_to,
288                attempts,
289            })
290        }
291        Some(RawFailAction::Name(name)) => match name.as_str() {
292            "fail" => Ok(FailAction::Halt),
293            "continue" => Ok(FailAction::Continue),
294            other => Err(format!("stage `{stage}` has unknown fail_action `{other}`")),
295        },
296    }
297}
298
299/// Checks that a stage's action and result check can stand together.
300fn validate_stage(stage: &str, action: &Actor, result_check: &Check) -> Result<(), String> {
301    if *action == Actor::Agent && *result_check == Check::None {
302        return Err(format!(
303            "stage `{stage}` is an agent action, so its result_check may not be `none`"
304        ));
305    }
306    for (builtin, name) in [(Builtin::Merge, "merge"), (Builtin::Sync, "sync")] {
307        if *action == Actor::Builtin(builtin) && *result_check != Check::None {
308            return Err(format!(
309                "{name} stage `{stage}` must have `result_check: none`"
310            ));
311        }
312    }
313    Ok(())
314}
315
316/// Structural rules on a whole flow. Agent actions are deliberately absent:
317/// one driver walks every stage the same way, so an agent action is legal in
318/// any position and any number of times, each with its own supervised process.
319fn validate_order(stages: &[Stage]) -> Result<(), String> {
320    if stages.is_empty() {
321        return Err("flow must define at least one stage".into());
322    }
323
324    let merge = Actor::Builtin(Builtin::Merge);
325    let sync = Actor::Builtin(Builtin::Sync);
326    let merge_count = stages.iter().filter(|stage| stage.action == merge).count();
327    if merge_count > 1 {
328        return Err(format!(
329            "flow may contain at most one merge stage; found {merge_count}"
330        ));
331    }
332    if let Some(merge_index) = stages.iter().position(|stage| stage.action == merge)
333        && let Some(stray) = stages[merge_index..]
334            .iter()
335            .find(|stage| stage.action == sync)
336    {
337        return Err(format!(
338            "sync stage `{}` must come before the merge stage",
339            stray.name
340        ));
341    }
342    if merge_count == 1 && stages.last().map(|stage| &stage.action) != Some(&merge) {
343        return Err("merge stage must be last".into());
344    }
345
346    validate_return_edges(stages)?;
347    Ok(())
348}
349
350/// Every `return_to` must point backwards, and the worst case they imply
351/// together must stay bounded. Each edge re-runs the span from its target
352/// through the stage that owns it, once per attempt; a flow whose total
353/// executions could exceed `MAX_FLOW_EXECUTIONS` is refused rather than
354/// left to spin.
355fn validate_return_edges(stages: &[Stage]) -> Result<(), String> {
356    let mut executions: u64 = stages.iter().map(stage_executions).sum();
357    for (index, stage) in stages.iter().enumerate() {
358        let FailAction::ReturnTo {
359            stage: target,
360            attempts,
361        } = &stage.fail_action
362        else {
363            continue;
364        };
365        let Some(target_index) = stages[..index]
366            .iter()
367            .position(|candidate| candidate.name == *target)
368        else {
369            return Err(format!(
370                "stage `{}` return_to must name an earlier stage; `{target}` is not one",
371                stage.name
372            ));
373        };
374        let span: u64 = stages[target_index..=index]
375            .iter()
376            .map(stage_executions)
377            .sum();
378        executions += u64::from(*attempts) * span;
379    }
380    if executions > MAX_FLOW_EXECUTIONS {
381        return Err(format!(
382            "flow may execute at most {MAX_FLOW_EXECUTIONS} stages in the worst case; \
383             its return_to budgets imply {executions}"
384        ));
385    }
386    Ok(())
387}
388
389/// What one execution of a stage costs the worst-case budget: the action
390/// itself, plus one spawn for every seat on its panel. A panel is the only
391/// check that spawns more than one process, and the budget exists precisely so
392/// nobody discovers at runtime that a looping flow of five-seat panels implies
393/// a hundred agent spawns.
394fn stage_executions(stage: &Stage) -> u64 {
395    1 + match &stage.result_check {
396        Check::Panel(panel) => panel.reviewers.len() as u64,
397        Check::None | Check::Reported | Check::Actor(_) => 0,
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use crate::flow::{Actor, Builtin, Check, FailAction, Flow, Stage, parse};
404
405    fn error(yaml: &str) -> String {
406        parse("example", yaml).unwrap_err()
407    }
408
409    fn commits() -> Check {
410        Check::Actor(Actor::Builtin(Builtin::Commits))
411    }
412
413    fn exec_check(cmd: &[&str]) -> Check {
414        Check::Actor(Actor::Exec {
415            cmd: cmd.iter().map(|part| (*part).to_owned()).collect(),
416        })
417    }
418
419    #[test]
420    fn valid_multi_stage_flow_parses_in_order() {
421        let flow = parse(
422            "release",
423            "stages:\n  - name: build\n    action: agent\n  - name: test\n    action: { exec: [cargo, test] }\n    result_check: { exec: [cargo, clippy] }\n  - name: merge\n    action: { builtin: merge }\n",
424        )
425        .unwrap();
426
427        assert_eq!(
428            flow,
429            Flow {
430                name: "release".into(),
431                stages: vec![
432                    Stage {
433                        name: "build".into(),
434                        action: Actor::Agent,
435                        result_check: commits(),
436                        fail_action: FailAction::Halt,
437                        ff_only: false,
438                    },
439                    Stage {
440                        name: "test".into(),
441                        action: Actor::Exec {
442                            cmd: vec!["cargo".into(), "test".into()],
443                        },
444                        result_check: exec_check(&["cargo", "clippy"]),
445                        fail_action: FailAction::Halt,
446                        ff_only: false,
447                    },
448                    Stage {
449                        name: "merge".into(),
450                        action: Actor::Builtin(Builtin::Merge),
451                        result_check: Check::None,
452                        fail_action: FailAction::Halt,
453                        ff_only: false,
454                    },
455                ],
456            }
457        );
458    }
459
460    /// Every part a stage can leave unwritten defaults to the same stage the
461    /// long form spells out, so the two forms are one grammar rather than two.
462    #[test]
463    fn a_fully_written_stage_matches_the_defaults_it_restates() {
464        let terse = parse(
465            "release",
466            "stages:\n  - name: build\n    action: agent\n  - name: test\n    action: { exec: [cargo, test] }\n    result_check: { exec: [cargo, clippy] }\n  - name: merge\n    action: { builtin: merge }\n",
467        )
468        .unwrap();
469        let explicit = parse(
470            "release",
471            "stages:\n  - name: build\n    action: { agent: ignored }\n    result_check: { builtin: commits }\n    fail_action: fail\n  - name: test\n    action: { exec: [cargo, test] }\n    result_check: { exec: [cargo, clippy] }\n    fail_action: fail\n  - name: merge\n    action: { builtin: merge }\n    result_check: none\n    fail_action: fail\n",
472        )
473        .unwrap();
474
475        assert_eq!(terse, explicit);
476    }
477
478    /// An agent action's prompt comes from the ticket, so the mapping form's
479    /// payload is accepted and discarded rather than read.
480    #[test]
481    fn a_bare_agent_action_needs_no_payload() {
482        for yaml in [
483            "- { name: build, action: agent }\n",
484            "- { name: build, action: { agent: ignored } }\n",
485        ] {
486            let flow = parse("example", yaml).unwrap();
487            assert_eq!(flow.stages[0].action, Actor::Agent);
488            assert_eq!(flow.stages[0].result_check, commits());
489        }
490    }
491
492    #[test]
493    fn named_result_checks_parse() {
494        let flow = parse(
495            "example",
496            "- { name: build, action: agent, result_check: reported }\n- { name: test, action: { exec: ['true'] }, result_check: none }\n- { name: gate, action: { exec: ['true'] }, result_check: { builtin: commits } }\n",
497        )
498        .unwrap();
499        assert_eq!(flow.stages[0].result_check, Check::Reported);
500        assert_eq!(flow.stages[1].result_check, Check::None);
501        assert_eq!(flow.stages[2].result_check, commits());
502    }
503
504    /// The snapshot contract: a flow this binary writes onto a `runs` row is
505    /// the flow this binary reads back when it recovers that run. Every field
506    /// a stage can carry is exercised, because the one that is skipped when
507    /// absent — `ff_only` — is exactly the one a missing serde default would
508    /// silently break.
509    #[test]
510    fn snapshots_round_trip_through_the_new_vocabulary() {
511        let flow = parse(
512            "example",
513            concat!(
514                "- { name: build, action: agent, result_check: reported }\n",
515                "- { name: test, action: { exec: [cargo, test] }, result_check: { exec: [cargo, fmt] }, fail_action: { return_to: build, attempts: 2 } }\n",
516                "- name: review\n",
517                "  action: agent\n",
518                "  result_check:\n",
519                "    panel:\n",
520                "      prompt: prompts/review.md\n",
521                "      reviewers: [{ target: claude }, { target: codex, model: gpt }]\n",
522                "      require: { quorum: 2 }\n",
523                "- { name: merge, action: { builtin: merge, ff_only: true }, result_check: none }\n",
524            ),
525        )
526        .unwrap();
527        let snapshot = serde_json::to_string(&flow).unwrap();
528
529        assert!(snapshot.contains("result_check"), "{snapshot}");
530        assert!(snapshot.contains("ff_only"), "{snapshot}");
531        assert!(snapshot.contains("Panel"), "{snapshot}");
532        assert!(!snapshot.contains("\"kind\""), "{snapshot}");
533        assert!(!snapshot.contains("\"verdict\""), "{snapshot}");
534        assert_eq!(serde_json::from_str::<Flow>(&snapshot).unwrap(), flow);
535    }
536
537    /// An omitted `result_check` is the one its action implies, and every
538    /// check a stage can name binds what it says.
539    #[test]
540    fn result_checks_and_their_defaults_parse() {
541        let flow = parse(
542            "example",
543            "- { name: build, action: agent, result_check: reported }\n- { name: test, action: { exec: ['true'] }, result_check: { builtin: commits } }\n- { name: review, action: { exec: ['true'] }, result_check: none }\n",
544        )
545        .unwrap();
546        assert_eq!(flow.stages[0].result_check, Check::Reported);
547        assert_eq!(flow.stages[1].result_check, commits());
548        assert_eq!(flow.stages[2].result_check, Check::None);
549
550        let defaults = parse(
551            "example",
552            "- { name: build, action: agent }\n- { name: test, action: { exec: ['true'] } }\n- { name: merge, action: { builtin: merge } }\n",
553        )
554        .unwrap();
555        assert_eq!(defaults.stages[0].result_check, commits());
556        assert_eq!(defaults.stages[1].result_check, Check::None);
557        assert_eq!(defaults.stages[2].result_check, Check::None);
558    }
559
560    /// A flow file written for `0.3.0` is refused by name rather than by
561    /// omission: the stage below does say what it is, in a spelling nothing
562    /// reads any more, and the error is the only migration note its author
563    /// gets.
564    #[test]
565    fn the_removed_grammar_is_rejected_by_name() {
566        for (yaml, needle) in [
567            (
568                "- { name: build, kind: agent }\n",
569                "uses the removed `kind` key; write `action: agent` instead",
570            ),
571            (
572                "- { name: test, action: { exec: ['true'] }, cmd: ['true'] }\n",
573                "uses the removed `cmd` key; write `action: { exec: [...] }`",
574            ),
575            (
576                "- { name: test, action: agent, verdict: reported }\n",
577                "uses the removed `verdict` key; write `result_check: ...`",
578            ),
579            (
580                "- { name: test, action: { exec: ['true'] }, on_fail: { agent: fix it } }\n",
581                "uses the removed `on_fail` key; write `fail_action: { return_to: <stage>, attempts: N }`",
582            ),
583        ] {
584            let error = error(yaml);
585            assert!(error.contains(needle), "{error}");
586        }
587
588        let legacy = error("- { name: test, kind: exec, cmd: ['true'], verdict: exit }\n");
589        assert!(legacy.contains("stage `test`"), "{legacy}");
590        assert!(legacy.contains("removed `kind` key"), "{legacy}");
591        assert!(
592            legacy.contains("see the 0.4.0 migration table in CHANGELOG.md"),
593            "{legacy}"
594        );
595    }
596
597    #[test]
598    fn an_agent_action_may_not_go_unjudged() {
599        let error = error("- { name: build, action: agent, result_check: none }\n");
600        assert!(error.contains("stage `build`"), "{error}");
601        assert!(
602            error.contains("is an agent action, so its result_check may not be `none`"),
603            "{error}"
604        );
605    }
606
607    #[test]
608    fn the_merge_builtin_is_an_action_and_never_a_check() {
609        let as_check = error(
610            "- { name: build, action: agent }\n- { name: gate, action: { exec: ['true'] }, result_check: { builtin: merge } }\n",
611        );
612        assert!(
613            as_check.contains("result_check may not be the `merge` builtin"),
614            "{as_check}"
615        );
616
617        let judged = error(
618            "- { name: build, action: agent }\n- { name: merge, action: { builtin: merge }, result_check: reported }\n",
619        );
620        assert!(
621            judged.contains("must have `result_check: none`"),
622            "{judged}"
623        );
624    }
625
626    #[test]
627    fn the_sync_builtin_parses_as_an_action() {
628        let flow = parse(
629            "example",
630            "- { name: build, action: agent }\n- { name: sync, action: { builtin: sync } }\n",
631        )
632        .unwrap();
633        assert_eq!(flow.stages[1].action, Actor::Builtin(Builtin::Sync));
634        assert_eq!(flow.stages[1].result_check, Check::None);
635    }
636
637    /// Sync moves the run branch. A judge that moves a branch is not judging
638    /// anything, and a builtin that runs no worker could never report.
639    #[test]
640    fn the_sync_builtin_is_an_action_and_never_a_check() {
641        let as_check = error(
642            "- { name: build, action: agent }\n- { name: gate, action: { exec: ['true'] }, result_check: { builtin: sync } }\n",
643        );
644        assert!(
645            as_check.contains("result_check may not be the `sync` builtin"),
646            "{as_check}"
647        );
648
649        let judged = error(
650            "- { name: build, action: agent }\n- { name: sync, action: { builtin: sync }, result_check: reported }\n",
651        );
652        assert!(
653            judged.contains("sync stage `sync` must have `result_check: none`"),
654            "{judged}"
655        );
656    }
657
658    /// Any number of syncs, anywhere before the merge — but integrating the
659    /// default branch after it has already been moved answers nothing.
660    #[test]
661    fn sync_stages_may_repeat_but_must_precede_the_merge() {
662        let repeated = parse(
663            "example",
664            "- { name: build, action: agent }\n- { name: sync, action: { builtin: sync } }\n- { name: test, action: { exec: ['true'] } }\n- { name: resync, action: { builtin: sync } }\n- { name: merge, action: { builtin: merge } }\n",
665        )
666        .unwrap();
667        assert_eq!(repeated.stages.len(), 5);
668
669        let after = error(
670            "- { name: build, action: agent }\n- { name: merge, action: { builtin: merge } }\n- { name: sync, action: { builtin: sync } }\n",
671        );
672        assert!(
673            after.contains("sync stage `sync` must come before the merge stage"),
674            "{after}"
675        );
676    }
677
678    #[test]
679    fn ff_only_binds_on_the_merge_stage_and_defaults_to_off() {
680        let plain = parse(
681            "example",
682            "- { name: build, action: agent }\n- { name: merge, action: { builtin: merge } }\n",
683        )
684        .unwrap();
685        assert!(!plain.stages[1].ff_only);
686
687        let train = parse(
688            "example",
689            "- { name: build, action: agent }\n- { name: merge, action: { builtin: merge, ff_only: true }, result_check: none }\n",
690        )
691        .unwrap();
692        assert!(train.stages[1].ff_only);
693
694        let explicit = parse(
695            "example",
696            "- { name: build, action: agent }\n- { name: merge, action: { builtin: merge, ff_only: false } }\n",
697        )
698        .unwrap();
699        assert!(!explicit.stages[1].ff_only);
700    }
701
702    /// A queued run must recover the mode it was admitted with, and a stage
703    /// that never asked for the mode must not start carrying it.
704    #[test]
705    fn ff_only_survives_a_snapshot_round_trip() {
706        let flow = parse(
707            "example",
708            "- { name: build, action: agent }\n- { name: merge, action: { builtin: merge, ff_only: true } }\n",
709        )
710        .unwrap();
711        let snapshot = serde_json::to_string(&flow).unwrap();
712        assert!(snapshot.contains("ff_only"), "{snapshot}");
713        assert_eq!(serde_json::from_str::<Flow>(&snapshot).unwrap(), flow);
714
715        let plain = parse(
716            "example",
717            "- { name: build, action: agent }\n- { name: merge, action: { builtin: merge } }\n",
718        )
719        .unwrap();
720        let snapshot = serde_json::to_string(&plain).unwrap();
721        assert!(!snapshot.contains("ff_only"), "{snapshot}");
722        assert_eq!(serde_json::from_str::<Flow>(&snapshot).unwrap(), plain);
723    }
724
725    /// `ff_only` describes what the merge does to the default branch, so
726    /// anywhere else it is a misplaced expectation rather than an extra key
727    /// worth dropping quietly.
728    #[test]
729    fn ff_only_is_refused_off_the_merge_builtin() {
730        for yaml in [
731            "- { name: build, action: agent }\n- { name: sync, action: { builtin: sync, ff_only: true } }\n",
732            "- { name: test, action: { exec: ['true'], ff_only: true } }\n",
733            "- { name: build, action: { agent: ignored, ff_only: true } }\n",
734            "- { name: build, action: agent, result_check: { builtin: commits, ff_only: true } }\n",
735        ] {
736            let error = error(yaml);
737            assert!(
738                error.contains("may only define `ff_only` on the `merge` builtin"),
739                "{error}"
740            );
741        }
742    }
743
744    #[test]
745    fn the_commits_builtin_is_a_check_and_never_an_action() {
746        let error = error("- { name: build, action: { builtin: commits } }\n");
747        assert!(
748            error.contains("`commits` is a result_check, not an action"),
749            "{error}"
750        );
751    }
752
753    #[test]
754    fn unknown_words_are_rejected() {
755        for (yaml, needle) in [
756            (
757                "- { name: build, action: wizard }\n",
758                "unknown action `wizard`",
759            ),
760            (
761                "- { name: build, action: { builtin: sparkle } }\n",
762                "unknown builtin `sparkle`",
763            ),
764            (
765                "- { name: build, action: agent, result_check: magic }\n",
766                "unknown result_check `magic`",
767            ),
768            (
769                "- { name: build, action: agent, fail_action: retry }\n",
770                "unknown fail_action `retry`",
771            ),
772        ] {
773            let error = error(yaml);
774            assert!(error.contains(needle), "{error}");
775        }
776    }
777
778    /// Both spellings of "no stages at all" are refused at parse time rather
779    /// than posted to and walked straight to complete.
780    #[test]
781    fn a_stageless_flow_is_rejected() {
782        for yaml in ["[]\n", "stages: []\n"] {
783            let error = error(yaml);
784            assert!(error.contains("must define at least one stage"), "{error}");
785        }
786    }
787
788    #[test]
789    fn empty_commands_are_rejected() {
790        let action = error("- { name: build, action: { exec: [] } }\n");
791        assert!(
792            action.contains("exec action must define a non-empty command"),
793            "{action}"
794        );
795
796        let check = error("- { name: build, action: agent, result_check: { exec: [] } }\n");
797        assert!(
798            check.contains("exec result_check must define a non-empty command"),
799            "{check}"
800        );
801    }
802
803    /// Both advisory failures and backward edges are live: the walk honours
804    /// what the fold returns, so the vocabulary binds rather than parsing into
805    /// a rejection.
806    #[test]
807    fn continue_and_return_to_bind_the_edges_they_name() {
808        let advisory = parse(
809            "example",
810            "- { name: build, action: agent }\n- { name: lint, action: { exec: ['true'] }, fail_action: continue }\n",
811        )
812        .unwrap();
813        assert_eq!(advisory.stages[1].fail_action, FailAction::Continue);
814
815        let looping = parse(
816            "example",
817            "- { name: build, action: agent }\n- { name: test, action: { exec: ['true'] }, fail_action: { return_to: build, attempts: 2 } }\n",
818        )
819        .unwrap();
820        assert_eq!(
821            looping.stages[1].fail_action,
822            FailAction::ReturnTo {
823                stage: "build".into(),
824                attempts: 2,
825            }
826        );
827
828        let defaulted = parse(
829            "example",
830            "- { name: build, action: agent }\n- { name: test, action: { exec: ['true'] }, fail_action: { return_to: build } }\n",
831        )
832        .unwrap();
833        assert_eq!(
834            defaulted.stages[1].fail_action,
835            FailAction::ReturnTo {
836                stage: "build".into(),
837                attempts: 1,
838            }
839        );
840    }
841
842    #[test]
843    fn return_to_must_name_an_earlier_stage() {
844        for target in ["test", "later", "missing"] {
845            let error = error(&format!(
846                "- {{ name: build, action: agent }}\n- {{ name: test, action: {{ exec: ['true'] }}, fail_action: {{ return_to: {target} }} }}\n- {{ name: later, action: {{ exec: ['true'] }} }}\n",
847            ));
848            assert!(
849                error.contains("return_to must name an earlier stage"),
850                "{error}"
851            );
852        }
853    }
854
855    #[test]
856    fn return_to_rejects_out_of_range_attempts() {
857        for attempts in ["0", "4"] {
858            let error = error(&format!(
859                "- {{ name: build, action: agent }}\n- {{ name: test, action: {{ exec: ['true'] }}, fail_action: {{ return_to: build, attempts: {attempts} }} }}\n",
860            ));
861            assert!(error.contains("stage `test`"), "{error}");
862            assert!(
863                error.contains("return_to attempts must be between 1 and 3"),
864                "{error}"
865            );
866        }
867    }
868
869    #[test]
870    fn the_worst_case_execution_count_is_capped() {
871        let mut yaml = String::from("- { name: build, action: agent }\n");
872        for index in 1..9 {
873            yaml.push_str(&format!(
874                "- {{ name: s{index}, action: {{ exec: ['true'] }} }}\n"
875            ));
876        }
877        yaml.push_str(
878            "- { name: last, action: { exec: ['true'] }, fail_action: { return_to: build, attempts: 3 } }\n",
879        );
880
881        let error = error(&yaml);
882        assert!(
883            error.contains("at most 32 stages in the worst case"),
884            "{error}"
885        );
886        assert!(error.contains("imply 40"), "{error}");
887    }
888
889    #[test]
890    fn duplicate_stage_names_are_rejected() {
891        let error = error(
892            "- { name: build, action: agent }\n- { name: build, action: { builtin: merge } }\n",
893        );
894        assert!(error.contains("duplicate stage name `build`"), "{error}");
895    }
896
897    /// One driver walks every stage, so nothing about a flow's shape depends
898    /// on where its agent actions sit or how many there are.
899    #[test]
900    fn agent_actions_are_legal_in_any_position_and_any_number() {
901        let leading_exec = parse(
902            "example",
903            "- { name: check, action: { exec: ['true'] } }\n- { name: build, action: agent }\n",
904        )
905        .unwrap();
906        assert_eq!(leading_exec.stages[1].action, Actor::Agent);
907
908        let two_agents = parse(
909            "example",
910            "- { name: build, action: agent }\n- { name: review, action: agent, result_check: reported }\n",
911        )
912        .unwrap();
913        assert_eq!(two_agents.stages[0].action, Actor::Agent);
914        assert_eq!(two_agents.stages[1].action, Actor::Agent);
915
916        let no_agent = parse("example", "- { name: check, action: { exec: ['true'] } }\n").unwrap();
917        assert_eq!(no_agent.stages.len(), 1);
918    }
919
920    #[test]
921    fn at_most_one_merge_stage_is_allowed() {
922        let error = error(
923            "- { name: build, action: agent }\n- { name: merge-one, action: { builtin: merge } }\n- { name: merge-two, action: { builtin: merge } }\n",
924        );
925        assert!(error.contains("at most one merge stage"), "{error}");
926    }
927
928    #[test]
929    fn merge_stage_must_be_last() {
930        let error = error(
931            "- { name: build, action: agent }\n- { name: merge, action: { builtin: merge } }\n- { name: check, action: { exec: ['true'] } }\n",
932        );
933        assert!(error.contains("merge stage must be last"), "{error}");
934    }
935}