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())),
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
179fn 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#[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
214fn 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 "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
256fn 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#[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
303fn 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 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
323fn validate_order(stages: &[Stage]) -> Result<(), String> {
327 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 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
364fn 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
403fn 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 assert!(!snapshot.contains("ff_only"), "{snapshot}");
741 assert_eq!(serde_json::from_str::<Flow>(&snapshot).unwrap(), plain);
742 }
743
744 #[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 #[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 #[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 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 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 #[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}