1use 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#[derive(Debug, Deserialize)]
65struct RawStage {
66 name: String,
67 action: Option<RawActor>,
68 result_check: Option<RawCheck>,
69 fail_action: Option<RawFailAction>,
70 kind: Option<IgnoredAny>,
72 cmd: Option<IgnoredAny>,
74 verdict: Option<IgnoredAny>,
76 on_fail: Option<IgnoredAny>,
78}
79
80fn 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#[derive(Debug, Deserialize)]
121#[serde(untagged)]
122enum RawActor {
123 Name(String),
124 Agent {
125 #[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
141fn 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
177fn 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#[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
212fn 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
252fn 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#[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
299fn 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
316fn 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
350fn 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
389fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}