1use std::collections::HashSet;
2use std::fmt;
3
4use super::types::{
5 Condition, InputType, OnChildFail, ScriptNode, WorkflowDef, WorkflowNode, QUALITY_GATE_TYPE,
6};
7use crate::traits::item_provider::ItemProviderRegistry;
8
9#[derive(Debug, Clone)]
15pub struct ValidationError {
16 pub message: String,
17 pub hint: Option<String>,
18}
19
20impl fmt::Display for ValidationError {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match &self.hint {
23 Some(h) => write!(f, "{} (hint: {h})", self.message),
24 None => write!(f, "{}", self.message),
25 }
26 }
27}
28
29#[derive(Debug, Default)]
31pub struct ValidationReport {
32 pub errors: Vec<ValidationError>,
33 pub warnings: Vec<String>,
34}
35
36impl ValidationReport {
37 pub fn is_ok(&self) -> bool {
38 self.errors.is_empty()
39 }
40}
41
42pub struct ValidationContext<'a> {
44 pub registry: &'a ItemProviderRegistry,
46 pub valid_targets: &'a [&'a str],
48}
49
50pub fn validate_workflow_semantics<F>(
51 def: &WorkflowDef,
52 loader: &F,
53 ctx: &ValidationContext<'_>,
54) -> ValidationReport
55where
56 F: Fn(&str) -> std::result::Result<WorkflowDef, String>,
57{
58 let mut errors = Vec::new();
59 let mut warnings = Vec::new();
60 let mut produced: HashSet<String> = HashSet::new();
61
62 let bool_inputs: HashSet<String> = def
63 .inputs
64 .iter()
65 .filter(|i| i.input_type == InputType::Boolean)
66 .map(|i| i.name.clone())
67 .collect();
68
69 validate_nodes(
70 &def.body,
71 &mut produced,
72 &mut errors,
73 &mut warnings,
74 loader,
75 &bool_inputs,
76 ctx,
77 );
78
79 let mut always_produced = produced.clone();
80 validate_nodes(
81 &def.always,
82 &mut always_produced,
83 &mut errors,
84 &mut warnings,
85 loader,
86 &bool_inputs,
87 ctx,
88 );
89
90 if !ctx.valid_targets.is_empty() {
91 for target in &def.targets {
92 if !ctx.valid_targets.contains(&target.as_str()) {
93 errors.push(ValidationError {
94 message: format!(
95 "Unknown target '{}' in workflow '{}'. Valid targets: {}",
96 target,
97 def.name,
98 ctx.valid_targets.join(", ")
99 ),
100 hint: Some(format!(
101 "Change '{}' to one of: {}",
102 target,
103 ctx.valid_targets.join(", ")
104 )),
105 });
106 }
107 }
108 }
109
110 ValidationReport { errors, warnings }
111}
112
113fn validate_nodes<F>(
114 nodes: &[WorkflowNode],
115 produced: &mut HashSet<String>,
116 errors: &mut Vec<ValidationError>,
117 warnings: &mut Vec<String>,
118 loader: &F,
119 bool_inputs: &HashSet<String>,
120 ctx: &ValidationContext<'_>,
121) where
122 F: Fn(&str) -> std::result::Result<WorkflowDef, String>,
123{
124 for node in nodes {
125 match node {
126 WorkflowNode::Call(n) => {
127 produced.insert(n.agent.step_key());
128 }
129 WorkflowNode::CallWorkflow(n) => {
130 match loader(&n.workflow) {
131 Ok(sub_def) => {
132 for input_decl in &sub_def.inputs {
133 if input_decl.required && !n.inputs.contains_key(&input_decl.name) {
134 errors.push(ValidationError {
135 message: format!(
136 "Sub-workflow '{}' requires input '{}' but it was not provided at the call site",
137 n.workflow, input_decl.name
138 ),
139 hint: None,
140 });
141 }
142 }
143
144 for sub_node in &sub_def.body {
145 for key in node_step_keys(sub_node) {
146 produced.insert(key);
147 }
148 }
149 }
150 Err(e) => {
151 errors.push(ValidationError {
152 message: format!(
153 "Sub-workflow '{}' could not be loaded: {}",
154 n.workflow, e
155 ),
156 hint: None,
157 });
158 }
159 }
160 produced.insert(n.workflow.clone());
161 }
162 WorkflowNode::Parallel(n) => {
163 for (step_name, _marker) in n.call_if.values() {
164 check_condition_reachable(step_name, produced, errors);
165 }
166 for call in &n.calls {
167 produced.insert(call.step_key());
168 }
169 }
170 WorkflowNode::If(n) => {
171 validate_conditional_branch(
172 &n.condition,
173 &n.body,
174 produced,
175 errors,
176 warnings,
177 loader,
178 bool_inputs,
179 ctx,
180 );
181 }
182 WorkflowNode::Unless(n) => {
183 validate_conditional_branch(
184 &n.condition,
185 &n.body,
186 produced,
187 errors,
188 warnings,
189 loader,
190 bool_inputs,
191 ctx,
192 );
193 }
194 WorkflowNode::While(n) => {
195 check_condition_reachable(&n.step, produced, errors);
196 let mut body_produced = produced.clone();
197 validate_nodes(
198 &n.body,
199 &mut body_produced,
200 errors,
201 warnings,
202 loader,
203 bool_inputs,
204 ctx,
205 );
206 produced.extend(body_produced);
207 }
208 WorkflowNode::DoWhile(n) => {
209 validate_nodes(
210 &n.body,
211 produced,
212 errors,
213 warnings,
214 loader,
215 bool_inputs,
216 ctx,
217 );
218 check_condition_reachable(&n.step, produced, errors);
219 }
220 WorkflowNode::Do(n) => {
221 validate_nodes(
222 &n.body,
223 produced,
224 errors,
225 warnings,
226 loader,
227 bool_inputs,
228 ctx,
229 );
230 }
231 WorkflowNode::Gate(n) => {
232 if n.gate_type == QUALITY_GATE_TYPE && n.quality_gate.is_none() {
233 errors.push(ValidationError {
234 message: format!(
235 "Quality gate '{}' is missing required `source` and `threshold` fields",
236 n.name
237 ),
238 hint: Some("Add `source = \"step_name\"` and `threshold = 70` to configure the quality gate".to_string()),
239 });
240 }
241 if let Some(source) = n.quality_gate.as_ref().map(|qg| &qg.source) {
242 if !produced.contains(source.as_str()) {
243 errors.push(ValidationError {
244 message: format!(
245 "Quality gate '{}' references source step '{}' which has not been produced at this point in the workflow",
246 n.name, source
247 ),
248 hint: Some(format!(
249 "Ensure a call or script step named '{}' appears before this gate",
250 source
251 )),
252 });
253 }
254 }
255 }
256 WorkflowNode::Script(n) => {
257 produced.insert(n.name.clone());
258 }
259 WorkflowNode::Always(n) => {
260 validate_nodes(
261 &n.body,
262 produced,
263 errors,
264 warnings,
265 loader,
266 bool_inputs,
267 ctx,
268 );
269 }
270 WorkflowNode::ForEach(n) => {
271 validate_foreach_node(n, errors, warnings, loader, ctx);
272 produced.insert(format!("foreach:{}", n.name));
273 }
274 }
275 }
276}
277
278fn validate_foreach_node<F>(
279 n: &super::types::ForEachNode,
280 errors: &mut Vec<ValidationError>,
281 warnings: &mut Vec<String>,
282 loader: &F,
283 ctx: &ValidationContext<'_>,
284) where
285 F: Fn(&str) -> std::result::Result<WorkflowDef, String>,
286{
287 match loader(&n.workflow) {
288 Ok(child_def) => {
289 for input_decl in &child_def.inputs {
290 if input_decl.required && !n.inputs.contains_key(&input_decl.name) {
291 errors.push(ValidationError {
292 message: format!(
293 "foreach '{}': child workflow '{}' requires input '{}' \
294 but it is not in the inputs map",
295 n.name, n.workflow, input_decl.name
296 ),
297 hint: Some(format!(
298 "Add `{} = \"{{{{item.*}}}}\"` or a literal value to the inputs block",
299 input_decl.name
300 )),
301 });
302 }
303 }
304 }
305 Err(e) => {
306 errors.push(ValidationError {
307 message: format!(
308 "foreach '{}': child workflow '{}' could not be loaded: {}",
309 n.name, n.workflow, e
310 ),
311 hint: None,
312 });
313 }
314 }
315
316 let provider = match ctx.registry.get(&n.over) {
317 Some(p) => p,
318 None => {
319 errors.push(ValidationError {
320 message: format!(
321 "foreach '{}': unknown provider '{}' — no ItemProvider registered for this name",
322 n.name, n.over
323 ),
324 hint: None,
325 });
326 return;
327 }
328 };
329
330 if let Err(e) = provider.parse_scope(n.scope.as_ref()) {
332 errors.push(ValidationError {
333 message: format!("foreach '{}': {e}", n.name),
334 hint: None,
335 });
336 }
337
338 for w in provider.scope_warnings(n.scope.as_ref()) {
340 warnings.push(format!("foreach '{}': {w}", n.name));
341 }
342
343 if n.ordered && !provider.supports_ordered() {
345 let ordered_names: Vec<String> = ctx
346 .registry
347 .iter()
348 .filter(|p| p.supports_ordered())
349 .map(|p| p.name().to_string())
350 .collect();
351 let hint = if ordered_names.is_empty() {
352 "Remove `ordered = true`".to_string()
353 } else {
354 format!(
355 "Remove `ordered = true` or change `over` to one of: {}",
356 ordered_names.join(", ")
357 )
358 };
359 errors.push(ValidationError {
360 message: format!(
361 "foreach '{}': ordered = true is not supported by provider '{}'",
362 n.name, n.over
363 ),
364 hint: Some(hint),
365 });
366 }
367
368 if n.on_child_fail == OnChildFail::SkipDependents && !n.ordered {
369 errors.push(ValidationError {
370 message: format!(
371 "foreach '{}': on_child_fail = skip_dependents has no effect without ordered = true",
372 n.name
373 ),
374 hint: Some(
375 "Add `ordered = true` or change on_child_fail to `continue` or `halt`".to_string(),
376 ),
377 });
378 }
379
380 if provider.requires_filter() && n.filter.is_empty() {
382 errors.push(ValidationError {
383 message: format!(
384 "foreach '{}': `filter` is required when over = {}",
385 n.name, n.over
386 ),
387 hint: Some(
388 "Add `filter = { status = \"failed\" }` (or another terminal status)".to_string(),
389 ),
390 });
391 }
392
393 if !n.filter.is_empty() {
394 if let Err(e) = provider.validate_filter(&n.filter) {
395 errors.push(ValidationError {
396 message: format!("foreach '{}': {e}", n.name),
397 hint: None,
398 });
399 }
400 }
401}
402
403fn node_step_keys(node: &WorkflowNode) -> Vec<String> {
404 match node {
405 WorkflowNode::Call(n) => vec![n.agent.step_key()],
406 WorkflowNode::CallWorkflow(n) => vec![n.workflow.clone()],
407 WorkflowNode::Script(n) => vec![n.name.clone()],
408 WorkflowNode::Parallel(n) => n.calls.iter().map(|c| c.step_key()).collect(),
409 WorkflowNode::ForEach(n) => vec![format!("foreach:{}", n.name)],
410 _ => vec![],
411 }
412}
413
414fn check_condition_reachable(
415 step: &str,
416 produced: &HashSet<String>,
417 errors: &mut Vec<ValidationError>,
418) {
419 if !produced.contains(step) {
420 errors.push(ValidationError {
421 message: format!(
422 "Condition references step '{}' which has not been produced at this point in the workflow",
423 step
424 ),
425 hint: None,
426 });
427 }
428}
429
430fn check_bool_input_declared(
431 input: &str,
432 bool_inputs: &HashSet<String>,
433 errors: &mut Vec<ValidationError>,
434) {
435 if !bool_inputs.contains(input) {
436 errors.push(ValidationError {
437 message: format!(
438 "Condition references '{}' which is not a declared boolean input",
439 input
440 ),
441 hint: Some(format!(
442 "Declare it in the workflow inputs block: `{} boolean`",
443 input
444 )),
445 });
446 }
447}
448
449#[allow(clippy::too_many_arguments)]
450fn validate_conditional_branch<F>(
451 condition: &Condition,
452 body: &[WorkflowNode],
453 produced: &mut HashSet<String>,
454 errors: &mut Vec<ValidationError>,
455 warnings: &mut Vec<String>,
456 loader: &F,
457 bool_inputs: &HashSet<String>,
458 ctx: &ValidationContext<'_>,
459) where
460 F: Fn(&str) -> std::result::Result<WorkflowDef, String>,
461{
462 match condition {
463 Condition::StepMarker { step, .. } => {
464 check_condition_reachable(step, produced, errors);
465 }
466 Condition::BoolInput { input } => {
467 check_bool_input_declared(input, bool_inputs, errors);
468 }
469 }
470 let mut branch_produced = produced.clone();
471 validate_nodes(
472 body,
473 &mut branch_produced,
474 errors,
475 warnings,
476 loader,
477 bool_inputs,
478 ctx,
479 );
480 produced.extend(branch_produced);
481}
482
483pub fn validate_script_steps<F>(def: &WorkflowDef, path_resolver: &F) -> Vec<ValidationError>
488where
489 F: Fn(&str) -> Result<std::path::PathBuf, String>,
490{
491 let mut errors = Vec::new();
492 let nodes: Vec<&ScriptNode> = collect_script_nodes(&def.body)
493 .into_iter()
494 .chain(collect_script_nodes(&def.always))
495 .collect();
496
497 for node in nodes {
498 let run = &node.run;
499
500 if run.contains("{{") {
501 continue;
502 }
503
504 match path_resolver(run) {
505 Err(searched) => {
506 errors.push(ValidationError {
507 message: format!(
508 "Script step '{}': '{}' not found. Searched: {}",
509 node.name, run, searched
510 ),
511 hint: None,
512 });
513 }
514 Ok(resolved) => {
515 #[cfg(unix)]
516 if let Some(err) = check_script_unix_permissions(&node.name, &resolved) {
517 errors.push(err);
518 }
519 #[cfg(not(unix))]
520 {
521 let _ = resolved;
522 }
523 }
524 }
525 }
526
527 errors
528}
529
530#[cfg(unix)]
531fn check_script_unix_permissions(
532 step_name: &str,
533 resolved: &std::path::Path,
534) -> Option<ValidationError> {
535 use std::os::unix::fs::PermissionsExt;
536 match std::fs::metadata(resolved) {
537 Err(e) => Some(ValidationError {
538 message: format!(
539 "Script step '{}': could not read metadata for '{}': {}",
540 step_name,
541 resolved.display(),
542 e,
543 ),
544 hint: None,
545 }),
546 Ok(meta) => {
547 let mode = meta.permissions().mode();
548 if mode & 0o111 == 0 {
549 Some(ValidationError {
550 message: format!(
551 "Script step '{}': '{}' is not executable (mode {:04o})",
552 step_name,
553 resolved.display(),
554 mode & 0o777,
555 ),
556 hint: Some(format!("Run: chmod +x {}", resolved.display())),
557 })
558 } else {
559 None
560 }
561 }
562 }
563}
564
565fn collect_script_nodes(nodes: &[WorkflowNode]) -> Vec<&ScriptNode> {
566 let mut out = Vec::new();
567 for node in nodes {
568 match node {
569 WorkflowNode::Script(s) => out.push(s),
570 WorkflowNode::If(n) => out.extend(collect_script_nodes(&n.body)),
571 WorkflowNode::Unless(n) => out.extend(collect_script_nodes(&n.body)),
572 WorkflowNode::While(n) => out.extend(collect_script_nodes(&n.body)),
573 WorkflowNode::DoWhile(n) => out.extend(collect_script_nodes(&n.body)),
574 WorkflowNode::Do(n) => out.extend(collect_script_nodes(&n.body)),
575 WorkflowNode::Always(n) => out.extend(collect_script_nodes(&n.body)),
576 WorkflowNode::Call(_)
577 | WorkflowNode::CallWorkflow(_)
578 | WorkflowNode::Gate(_)
579 | WorkflowNode::Parallel(_)
580 | WorkflowNode::ForEach(_) => {}
581 }
582 }
583 out
584}
585
586#[cfg(test)]
587mod tests {
588 use std::any::Any;
589 use std::collections::HashMap;
590
591 use super::{validate_script_steps, validate_workflow_semantics, ValidationContext};
592 use crate::dsl::parse_workflow_str;
593 use crate::engine_error::EngineError;
594 use crate::traits::item_provider::{
595 FanOutItem, ItemProvider, ItemProviderRegistry, ProviderInfo,
596 };
597 use crate::traits::run_context::RunContext;
598
599 fn no_loader(name: &str) -> Result<crate::dsl::WorkflowDef, String> {
600 Err(format!("sub-workflow '{}' not found", name))
601 }
602
603 fn always_resolve_ok(run: &str) -> Result<std::path::PathBuf, String> {
604 Ok(std::path::PathBuf::from(run))
605 }
606
607 fn always_resolve_err(run: &str) -> Result<std::path::PathBuf, String> {
608 Err(format!("not found: {run}"))
609 }
610
611 const CONDUCTOR_TARGETS: &[&str] = &["worktree", "ticket", "repo", "pr", "workflow_run"];
612
613 fn empty_ctx(registry: &ItemProviderRegistry) -> ValidationContext<'_> {
614 ValidationContext {
615 registry,
616 valid_targets: CONDUCTOR_TARGETS,
617 }
618 }
619
620 #[test]
623 fn valid_simple_workflow_has_no_errors() {
624 let src = r#"
625workflow simple {
626 call my_agent
627}
628"#;
629 let def = parse_workflow_str(src, "test.wf").unwrap();
630 let registry = ItemProviderRegistry::new();
631 let ctx = empty_ctx(®istry);
632 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
633 assert!(
634 report.is_ok(),
635 "expected no errors, got: {:?}",
636 report.errors
637 );
638 }
639
640 #[test]
641 fn if_condition_referencing_unknown_step_is_an_error() {
642 let src = r#"
643workflow wf {
644 if unknown_step.done {
645 call another_agent
646 }
647}
648"#;
649 let def = parse_workflow_str(src, "test.wf").unwrap();
650 let registry = ItemProviderRegistry::new();
651 let ctx = empty_ctx(®istry);
652 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
653 assert!(
654 !report.is_ok(),
655 "expected validation error for unknown step reference"
656 );
657 assert!(
658 report
659 .errors
660 .iter()
661 .any(|e| e.message.contains("unknown_step")),
662 "error should mention the step name; errors: {:?}",
663 report.errors
664 );
665 }
666
667 #[test]
668 fn if_condition_after_producing_step_is_ok() {
669 let src = r#"
670workflow wf {
671 call step1
672 if step1.done {
673 call step2
674 }
675}
676"#;
677 let def = parse_workflow_str(src, "test.wf").unwrap();
678 let registry = ItemProviderRegistry::new();
679 let ctx = empty_ctx(®istry);
680 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
681 assert!(
682 report.is_ok(),
683 "step1 is produced before the if, so no error expected; got: {:?}",
684 report.errors
685 );
686 }
687
688 #[test]
689 fn bool_input_in_if_condition_without_declaration_is_an_error() {
690 let src = r#"
691workflow wf {
692 if undeclared_flag {
693 call agent
694 }
695}
696"#;
697 let def = parse_workflow_str(src, "test.wf").unwrap();
698 let registry = ItemProviderRegistry::new();
699 let ctx = empty_ctx(®istry);
700 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
701 assert!(
702 !report.is_ok(),
703 "undeclared bool input should be flagged as an error"
704 );
705 assert!(
706 report
707 .errors
708 .iter()
709 .any(|e| e.message.contains("undeclared_flag")),
710 "error should mention the input name; errors: {:?}",
711 report.errors
712 );
713 }
714
715 #[test]
716 fn bool_input_declared_in_inputs_block_is_ok() {
717 let src = r#"
718workflow wf {
719 inputs {
720 run_extra boolean
721 }
722 if run_extra {
723 call optional_agent
724 }
725}
726"#;
727 let def = parse_workflow_str(src, "test.wf").unwrap();
728 let registry = ItemProviderRegistry::new();
729 let ctx = empty_ctx(®istry);
730 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
731 assert!(
732 report.is_ok(),
733 "declared boolean input in if condition should be valid; got: {:?}",
734 report.errors
735 );
736 }
737
738 #[test]
739 fn invalid_target_produces_error() {
740 let src = r#"
741workflow wf {
742 meta {
743 targets = ["invalid_target"]
744 }
745 call agent
746}
747"#;
748 let def = parse_workflow_str(src, "test.wf").unwrap();
749 let registry = ItemProviderRegistry::new();
750 let ctx = empty_ctx(®istry);
751 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
752 assert!(!report.is_ok(), "invalid target should produce an error");
753 assert!(
754 report
755 .errors
756 .iter()
757 .any(|e| e.message.contains("invalid_target")),
758 "error should mention the bad target; errors: {:?}",
759 report.errors
760 );
761 }
762
763 #[test]
764 fn workflow_with_no_body_is_valid() {
765 let src = r#"
766workflow empty {
767}
768"#;
769 let def = parse_workflow_str(src, "test.wf").unwrap();
770 let registry = ItemProviderRegistry::new();
771 let ctx = empty_ctx(®istry);
772 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
773 assert!(
774 report.is_ok(),
775 "empty workflow body should be valid; got: {:?}",
776 report.errors
777 );
778 }
779
780 #[test]
783 fn script_with_template_variable_skips_path_check() {
784 let src = r#"
785workflow wf {
786 script my_script {
787 run = "{{scripts_dir}}/check.sh"
788 }
789}
790"#;
791 let def = parse_workflow_str(src, "test.wf").unwrap();
792 let errors = validate_script_steps(&def, &always_resolve_err);
794 assert!(
795 errors.is_empty(),
796 "script with template variable should skip path check; got: {:?}",
797 errors
798 );
799 }
800
801 #[test]
802 fn script_path_not_found_produces_error() {
803 let src = r#"
804workflow wf {
805 script lint {
806 run = "/nonexistent/script.sh"
807 }
808}
809"#;
810 let def = parse_workflow_str(src, "test.wf").unwrap();
811 let errors = validate_script_steps(&def, &always_resolve_err);
812 assert!(
813 !errors.is_empty(),
814 "missing script path should produce a validation error"
815 );
816 assert!(
817 errors.iter().any(|e| e.message.contains("lint")),
818 "error should mention the step name; errors: {:?}",
819 errors
820 );
821 }
822
823 #[test]
824 fn script_path_resolved_ok_has_no_errors() {
825 let src = r#"
826workflow wf {
827 script check {
828 run = "some_script.sh"
829 }
830}
831"#;
832 let def = parse_workflow_str(src, "test.wf").unwrap();
833 #[cfg(not(unix))]
835 {
836 let errors = validate_script_steps(&def, &always_resolve_ok);
837 assert!(
838 errors.is_empty(),
839 "successfully resolved script should produce no errors on non-unix; got: {:?}",
840 errors
841 );
842 }
843 #[cfg(unix)]
846 {
847 let errors = validate_script_steps(&def, &always_resolve_ok);
848 let _ = errors;
851 }
852 }
853
854 #[test]
855 fn workflow_with_multiple_scripts_checks_each() {
856 let src = r#"
857workflow wf {
858 script step_a {
859 run = "/missing/a.sh"
860 }
861 script step_b {
862 run = "/missing/b.sh"
863 }
864}
865"#;
866 let def = parse_workflow_str(src, "test.wf").unwrap();
867 let errors = validate_script_steps(&def, &always_resolve_err);
868 assert_eq!(
869 errors.len(),
870 2,
871 "each unresolvable script should generate one error; got: {:?}",
872 errors
873 );
874 }
875
876 struct BasicProvider {
879 provider_name: &'static str,
880 ordered: bool,
881 }
882
883 impl ItemProvider for BasicProvider {
884 fn name(&self) -> &str {
885 self.provider_name
886 }
887
888 fn supports_ordered(&self) -> bool {
889 self.ordered
890 }
891
892 fn items(
893 &self,
894 _ctx: &dyn RunContext,
895 _info: &ProviderInfo,
896 _scope: Option<&dyn Any>,
897 _filter: &HashMap<String, String>,
898 ) -> Result<Vec<FanOutItem>, EngineError> {
899 Ok(vec![])
900 }
901 }
902
903 #[test]
904 fn foreach_unregistered_provider_mentions_provider_name() {
905 let src = r#"
906workflow wf {
907 foreach fan {
908 over = unknown_provider
909 max_parallel = 2
910 workflow = child_wf
911 }
912}
913"#;
914 let def = parse_workflow_str(src, "test.wf").unwrap();
915 let registry = ItemProviderRegistry::new();
916 let ctx = empty_ctx(®istry);
917 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
918 assert!(!report.is_ok(), "unregistered provider should fail");
919 assert!(
920 report
921 .errors
922 .iter()
923 .any(|e| e.message.contains("unknown_provider")),
924 "error should mention provider name; errors: {:?}",
925 report.errors
926 );
927 }
928
929 #[test]
930 fn foreach_ordered_with_unsupporting_provider_is_error() {
931 let src = r#"
932workflow wf {
933 foreach fan {
934 over = simple_provider
935 max_parallel = 2
936 workflow = child_wf
937 ordered = true
938 }
939}
940"#;
941 let def = parse_workflow_str(src, "test.wf").unwrap();
942 let mut registry = ItemProviderRegistry::new();
943 registry.register(BasicProvider {
944 provider_name: "simple_provider",
945 ordered: false,
946 });
947 let ctx = empty_ctx(®istry);
948 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
949 assert!(
950 !report.is_ok(),
951 "ordered=true with unsupporting provider should fail"
952 );
953 assert!(
954 report.errors.iter().any(|e| e.message.contains("ordered")),
955 "error should mention ordered; errors: {:?}",
956 report.errors
957 );
958 }
959
960 #[test]
963 fn quality_gate_source_step_exists_no_error() {
964 let src = r#"
965workflow wf {
966 call analyzer
967 gate quality_gate {
968 source = analyzer
969 threshold = 70
970 }
971}
972"#;
973 let def = parse_workflow_str(src, "test.wf").unwrap();
974 let registry = ItemProviderRegistry::new();
975 let ctx = empty_ctx(®istry);
976 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
977 assert!(
978 report.is_ok(),
979 "quality gate referencing a prior step should have no errors; got: {:?}",
980 report.errors
981 );
982 }
983
984 #[test]
985 fn quality_gate_source_step_not_in_workflow_is_error() {
986 let src = r#"
987workflow wf {
988 gate quality_gate {
989 source = nonexistent_step
990 threshold = 70
991 }
992}
993"#;
994 let def = parse_workflow_str(src, "test.wf").unwrap();
995 let registry = ItemProviderRegistry::new();
996 let ctx = empty_ctx(®istry);
997 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
998 assert!(
999 !report.is_ok(),
1000 "quality gate with missing source step should fail"
1001 );
1002 assert!(
1003 report
1004 .errors
1005 .iter()
1006 .any(|e| e.message.contains("nonexistent_step")),
1007 "error should mention the missing step; errors: {:?}",
1008 report.errors
1009 );
1010 }
1011
1012 #[test]
1015 fn deeply_nested_workflow_tracks_produce_set_correctly() {
1016 let src = r#"
1017workflow wf {
1018 call outer_step
1019 if outer_step.done {
1020 while outer_step.done {
1021 max_iterations = 3
1022 call inner_step
1023 }
1024 if inner_step.ready {
1025 call leaf_step
1026 }
1027 }
1028}
1029"#;
1030 let def = parse_workflow_str(src, "test.wf").unwrap();
1031 let registry = ItemProviderRegistry::new();
1032 let ctx = empty_ctx(®istry);
1033 let report = validate_workflow_semantics(&def, &no_loader, &ctx);
1034 assert!(
1035 report.is_ok(),
1036 "deeply nested workflow with valid produce-set should have no errors; got: {:?}",
1037 report.errors
1038 );
1039 }
1040}