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