1use std::collections::{HashMap, HashSet};
2
3use regex::Regex;
4
5use crate::error::ZigError;
6use crate::workflow::model::{
7 FailurePolicy, StepCommand, StorageKind, VarType, Variable, Workflow,
8};
9
10pub fn validate(workflow: &Workflow) -> Result<(), Vec<ZigError>> {
22 let mut errors = Vec::new();
23
24 if workflow.steps.is_empty() {
25 errors.push(ZigError::Validation(
26 "workflow must have at least one step".into(),
27 ));
28 return Err(errors);
29 }
30
31 let step_names: HashSet<&str> = workflow.steps.iter().map(|s| s.name.as_str()).collect();
32 let var_names: HashSet<&str> = workflow.vars.keys().map(|k| k.as_str()).collect();
33 let role_names: HashSet<&str> = workflow.roles.keys().map(|k| k.as_str()).collect();
34 let storage_names: HashSet<&str> = workflow.storage.keys().map(|k| k.as_str()).collect();
35
36 for (name, spec) in &workflow.storage {
38 if spec.path.trim().is_empty() {
39 errors.push(ZigError::Validation(format!(
40 "storage '{name}' has an empty path"
41 )));
42 }
43 if matches!(spec.kind, StorageKind::File) && !spec.files.is_empty() {
44 errors.push(ZigError::Validation(format!(
45 "storage '{name}' has type = \"file\" but also declares 'files' hints \
46 (the 'files' subtable is only valid for type = \"folder\")"
47 )));
48 }
49 for file in &spec.files {
50 if file.name.contains('/') || file.name.contains('\\') {
51 errors.push(ZigError::Validation(format!(
52 "storage '{name}' file hint '{}' must be a bare filename, not a path",
53 file.name
54 )));
55 }
56 }
57 }
58
59 let mut seen_names = HashSet::new();
61 for step in &workflow.steps {
62 if !seen_names.insert(&step.name) {
63 errors.push(ZigError::Validation(format!(
64 "duplicate step name: '{}'",
65 step.name
66 )));
67 }
68 }
69
70 for step in &workflow.steps {
71 for dep in &step.depends_on {
73 if !step_names.contains(dep.as_str()) {
74 errors.push(ZigError::Validation(format!(
75 "step '{}' depends on unknown step '{dep}'",
76 step.name
77 )));
78 }
79 if dep == &step.name {
80 errors.push(ZigError::Validation(format!(
81 "step '{}' depends on itself",
82 step.name
83 )));
84 }
85 }
86
87 if let Some(next) = &step.next {
89 if !step_names.contains(next.as_str()) {
90 errors.push(ZigError::Validation(format!(
91 "step '{}' references unknown next step '{next}'",
92 step.name
93 )));
94 }
95 }
96
97 for var_ref in extract_var_refs(&step.prompt) {
99 if !var_names.contains(var_ref.as_str()) {
100 errors.push(ZigError::Validation(format!(
101 "step '{}' prompt references unknown variable '${{{var_ref}}}'",
102 step.name
103 )));
104 }
105 }
106
107 if let Some(system_prompt) = &step.system_prompt {
109 for var_ref in extract_var_refs(system_prompt) {
110 if !var_names.contains(var_ref.as_str()) {
111 errors.push(ZigError::Validation(format!(
112 "step '{}' system_prompt references unknown variable '${{{var_ref}}}'",
113 step.name
114 )));
115 }
116 }
117 }
118
119 if let Some(scope) = &step.storage {
121 for name in scope {
122 if !storage_names.contains(name.as_str()) {
123 errors.push(ZigError::Validation(format!(
124 "step '{}' storage scope references unknown storage '{name}'",
125 step.name
126 )));
127 }
128 }
129 }
130
131 if step.role.is_some() && step.system_prompt.is_some() {
133 errors.push(ZigError::Validation(format!(
134 "step '{}' sets both 'role' and 'system_prompt' (they are mutually exclusive)",
135 step.name
136 )));
137 }
138
139 if let Some(role_ref) = &step.role {
141 let var_refs = extract_var_refs(role_ref);
142 if var_refs.is_empty() {
143 if !role_names.contains(role_ref.as_str()) {
145 errors.push(ZigError::Validation(format!(
146 "step '{}' role references unknown role '{role_ref}'",
147 step.name
148 )));
149 }
150 } else {
151 for var_ref in var_refs {
153 if !var_names.contains(var_ref.as_str()) {
154 errors.push(ZigError::Validation(format!(
155 "step '{}' role references unknown variable '${{{var_ref}}}'",
156 step.name
157 )));
158 }
159 }
160 }
161 }
162
163 for var_name in step.saves.keys() {
165 if !var_names.contains(var_name.as_str()) {
166 errors.push(ZigError::Validation(format!(
167 "step '{}' saves to unknown variable '{var_name}'",
168 step.name
169 )));
170 }
171 }
172
173 if let Some(cond) = &step.condition {
175 for var_ref in extract_condition_vars(cond) {
176 if !var_names.contains(var_ref.as_str()) && !step_names.contains(var_ref.as_str()) {
177 errors.push(ZigError::Validation(format!(
178 "step '{}' condition references unknown variable '{var_ref}'",
179 step.name
180 )));
181 }
182 }
183 }
184
185 if step.retry_model.is_some() && step.on_failure.as_ref() != Some(&FailurePolicy::Retry) {
187 errors.push(ZigError::Validation(format!(
188 "step '{}' sets retry_model but on_failure is not 'retry'",
189 step.name
190 )));
191 }
192
193 if step.mcp_config.is_some() {
196 let effective_provider = step
197 .provider
198 .as_ref()
199 .or(workflow.workflow.provider.as_ref());
200 if let Some(provider) = effective_provider {
201 if provider != "claude" {
202 errors.push(ZigError::Validation(format!(
203 "step '{}' sets mcp_config but provider is '{}' \
204 (mcp_config is only supported with the claude provider)",
205 step.name, provider
206 )));
207 }
208 }
209 }
210
211 if let Some(ref output) = step.output {
213 let valid_formats = ["text", "json", "json-pretty", "stream-json", "native-json"];
214 if !valid_formats.contains(&output.as_str()) {
215 errors.push(ZigError::Validation(format!(
216 "step '{}' has invalid output format '{}' \
217 (must be one of: text, json, json-pretty, stream-json, native-json)",
218 step.name, output
219 )));
220 }
221 }
222
223 let is_review = step.command.as_ref() == Some(&StepCommand::Review);
225 if !is_review {
226 for (field, set) in [("uncommitted", step.uncommitted)] {
227 if set {
228 errors.push(ZigError::Validation(format!(
229 "step '{}' sets '{}' but command is not 'review'",
230 step.name, field
231 )));
232 }
233 }
234 for (field, set) in [
235 ("base", step.base.is_some()),
236 ("commit", step.commit.is_some()),
237 ("title", step.title.is_some()),
238 ] {
239 if set {
240 errors.push(ZigError::Validation(format!(
241 "step '{}' sets '{}' but command is not 'review'",
242 step.name, field
243 )));
244 }
245 }
246 }
247
248 let is_plan = step.command.as_ref() == Some(&StepCommand::Plan);
250 if !is_plan {
251 for (field, set) in [
252 ("plan_output", step.plan_output.is_some()),
253 ("instructions", step.instructions.is_some()),
254 ] {
255 if set {
256 errors.push(ZigError::Validation(format!(
257 "step '{}' sets '{}' but command is not 'plan'",
258 step.name, field
259 )));
260 }
261 }
262 }
263
264 if let Some(ref cmd) = step.command {
266 match cmd {
267 StepCommand::Pipe | StepCommand::Collect | StepCommand::Summary => {
268 if step.depends_on.is_empty() {
269 errors.push(ZigError::Validation(format!(
270 "step '{}' uses command '{}' but has no depends_on \
271 (pipe/collect/summary operate on prior session outputs)",
272 step.name,
273 match cmd {
274 StepCommand::Pipe => "pipe",
275 StepCommand::Collect => "collect",
276 StepCommand::Summary => "summary",
277 _ => unreachable!(),
278 }
279 )));
280 }
281 }
282 _ => {}
283 }
284 }
285
286 if step.interactive {
290 if step.race_group.is_some() {
291 errors.push(ZigError::Validation(format!(
292 "step '{}' is interactive and also sets race_group \
293 (interactive steps cannot race — one human, one tty)",
294 step.name
295 )));
296 }
297 if !step.saves.is_empty() {
298 errors.push(ZigError::Validation(format!(
299 "step '{}' is interactive but also sets saves \
300 (interactive steps stream directly to the terminal — \
301 nothing is captured to extract from)",
302 step.name
303 )));
304 }
305 if step.on_failure.as_ref() == Some(&FailurePolicy::Retry) {
306 errors.push(ZigError::Validation(format!(
307 "step '{}' is interactive but also sets on_failure = \"retry\" \
308 (interactive steps cannot be retried — human input can't be replayed)",
309 step.name
310 )));
311 }
312 if step.max_retries.is_some() {
313 errors.push(ZigError::Validation(format!(
314 "step '{}' is interactive but also sets max_retries \
315 (interactive steps cannot be retried)",
316 step.name
317 )));
318 }
319 if step.json {
320 errors.push(ZigError::Validation(format!(
321 "step '{}' is interactive but also sets json = true \
322 (json mode forces non-interactive execution in zag)",
323 step.name
324 )));
325 }
326 if step.output.is_some() {
327 errors.push(ZigError::Validation(format!(
328 "step '{}' is interactive but also sets output \
329 (an output format forces non-interactive execution in zag)",
330 step.name
331 )));
332 }
333 if step.json_schema.is_some() {
334 errors.push(ZigError::Validation(format!(
335 "step '{}' is interactive but also sets json_schema \
336 (json_schema implies non-interactive execution)",
337 step.name
338 )));
339 }
340 if let Some(ref cmd) = step.command {
341 let cmd_name = match cmd {
342 StepCommand::Review => "review",
343 StepCommand::Plan => "plan",
344 StepCommand::Pipe => "pipe",
345 StepCommand::Collect => "collect",
346 StepCommand::Summary => "summary",
347 };
348 errors.push(ZigError::Validation(format!(
349 "step '{}' is interactive but also sets command = \"{cmd_name}\" \
350 (only the default run command has an interactive TUI)",
351 step.name
352 )));
353 }
354 }
355 }
356
357 for (role_name, role) in &workflow.roles {
359 if role.system_prompt.is_some() && role.system_prompt_file.is_some() {
361 errors.push(ZigError::Validation(format!(
362 "role '{role_name}' sets both 'system_prompt' and 'system_prompt_file' \
363 (they are mutually exclusive)"
364 )));
365 }
366
367 if let Some(ref sp) = role.system_prompt {
369 for var_ref in extract_var_refs(sp) {
370 if !var_names.contains(var_ref.as_str()) {
371 errors.push(ZigError::Validation(format!(
372 "role '{role_name}' system_prompt references unknown variable \
373 '${{{var_ref}}}'"
374 )));
375 }
376 }
377 }
378 }
379
380 let mut race_groups: HashMap<&str, Vec<&str>> = HashMap::new();
382 for step in &workflow.steps {
383 if let Some(ref group) = step.race_group {
384 race_groups
385 .entry(group.as_str())
386 .or_default()
387 .push(step.name.as_str());
388 }
389 }
390 for (group, members) in &race_groups {
391 let member_set: HashSet<&str> = members.iter().copied().collect();
392 for step in &workflow.steps {
393 if step.race_group.as_deref() == Some(*group) {
394 for dep in &step.depends_on {
395 if member_set.contains(dep.as_str()) {
396 errors.push(ZigError::Validation(format!(
397 "step '{}' depends on '{}' but both are in race_group '{}' \
398 (race members must not depend on each other)",
399 step.name, dep, group
400 )));
401 }
402 }
403 }
404 }
405 }
406
407 validate_var_constraints(&workflow.vars, &mut errors);
409
410 let has_cycle = if let Some(cycle) = detect_cycle(&workflow.steps) {
412 errors.push(ZigError::Validation(format!(
413 "dependency cycle detected: {}",
414 cycle.join(" -> ")
415 )));
416 true
417 } else {
418 false
419 };
420
421 if !has_cycle && workflow.steps.iter().any(|s| s.interactive) {
425 if let Ok(tiers) = crate::run::topological_sort(&workflow.steps) {
426 for tier in &tiers {
427 let has_interactive = tier.iter().any(|s| s.interactive);
428 if has_interactive && tier.len() > 1 {
429 let names: Vec<&str> = tier.iter().map(|s| s.name.as_str()).collect();
430 let interactive_names: Vec<&str> = tier
431 .iter()
432 .filter(|s| s.interactive)
433 .map(|s| s.name.as_str())
434 .collect();
435 errors.push(ZigError::Validation(format!(
436 "interactive step(s) [{}] share a tier with sibling(s) [{}] \
437 (an interactive step must be alone in its tier — add a \
438 depends_on chain so siblings run before or after it)",
439 interactive_names.join(", "),
440 names.join(", ")
441 )));
442 }
443 }
444 }
445 }
446
447 if errors.is_empty() {
448 Ok(())
449 } else {
450 Err(errors)
451 }
452}
453
454fn validate_var_constraints(vars: &HashMap<String, Variable>, errors: &mut Vec<ZigError>) {
456 let mut prompt_bound_count = 0;
457
458 for (name, var) in vars {
459 if var.default.is_some() && var.default_file.is_some() {
461 errors.push(ZigError::Validation(format!(
462 "variable '{name}' sets both 'default' and 'default_file' \
463 (they are mutually exclusive)"
464 )));
465 }
466
467 if let Some(ref from) = var.from {
469 if from != "prompt" {
470 errors.push(ZigError::Validation(format!(
471 "variable '{name}' has unsupported from value '{from}' (only 'prompt' is supported)"
472 )));
473 } else {
474 prompt_bound_count += 1;
475 }
476 }
477
478 if var.var_type != VarType::String {
480 if var.min_length.is_some() {
481 errors.push(ZigError::Validation(format!(
482 "variable '{name}' has min_length but type is '{}' (only valid for 'string')",
483 var.var_type
484 )));
485 }
486 if var.max_length.is_some() {
487 errors.push(ZigError::Validation(format!(
488 "variable '{name}' has max_length but type is '{}' (only valid for 'string')",
489 var.var_type
490 )));
491 }
492 if var.pattern.is_some() {
493 errors.push(ZigError::Validation(format!(
494 "variable '{name}' has pattern but type is '{}' (only valid for 'string')",
495 var.var_type
496 )));
497 }
498 }
499
500 if var.var_type != VarType::Number {
502 if var.min.is_some() {
503 errors.push(ZigError::Validation(format!(
504 "variable '{name}' has min but type is '{}' (only valid for 'number')",
505 var.var_type
506 )));
507 }
508 if var.max.is_some() {
509 errors.push(ZigError::Validation(format!(
510 "variable '{name}' has max but type is '{}' (only valid for 'number')",
511 var.var_type
512 )));
513 }
514 }
515
516 if let (Some(min_len), Some(max_len)) = (var.min_length, var.max_length) {
518 if min_len > max_len {
519 errors.push(ZigError::Validation(format!(
520 "variable '{name}' has min_length ({min_len}) greater than max_length ({max_len})"
521 )));
522 }
523 }
524 if let (Some(min), Some(max)) = (var.min, var.max) {
525 if min > max {
526 errors.push(ZigError::Validation(format!(
527 "variable '{name}' has min ({min}) greater than max ({max})"
528 )));
529 }
530 }
531
532 if let Some(ref pattern) = var.pattern {
534 if Regex::new(pattern).is_err() {
535 errors.push(ZigError::Validation(format!(
536 "variable '{name}' has invalid regex pattern: '{pattern}'"
537 )));
538 }
539 }
540
541 if let Some(ref allowed) = var.allowed_values {
543 for val in allowed {
544 let ok = match var.var_type {
545 VarType::String => val.is_str(),
546 VarType::Number => val.is_integer() || val.is_float(),
547 VarType::Bool => matches!(val, toml::Value::Boolean(_)),
548 VarType::Json => true,
549 };
550 if !ok {
551 errors.push(ZigError::Validation(format!(
552 "variable '{name}' has allowed_values entry {val} incompatible with type '{}'",
553 var.var_type
554 )));
555 }
556 }
557 }
558
559 if let Some(ref default) = var.default {
561 let default_str = toml_value_to_string(default);
562 let constraint_errors = check_value_constraints(name, &default_str, var);
563 for msg in constraint_errors {
564 errors.push(ZigError::Validation(format!(
565 "variable '{name}' default value violates constraint: {msg}"
566 )));
567 }
568 }
569 }
570
571 if prompt_bound_count > 1 {
572 errors.push(ZigError::Validation(
573 "multiple variables have from = \"prompt\" (only one is allowed)".into(),
574 ));
575 }
576}
577
578fn toml_value_to_string(val: &toml::Value) -> String {
580 match val {
581 toml::Value::String(s) => s.clone(),
582 toml::Value::Integer(n) => n.to_string(),
583 toml::Value::Float(f) => f.to_string(),
584 toml::Value::Boolean(b) => b.to_string(),
585 other => other.to_string(),
586 }
587}
588
589fn check_value_constraints(name: &str, value: &str, var: &Variable) -> Vec<String> {
592 let mut violations = Vec::new();
593
594 if var.required && value.is_empty() {
595 violations.push(format!(
596 "variable '{name}' is required but was not provided"
597 ));
598 }
599
600 if value.is_empty() && !var.required {
602 return violations;
603 }
604
605 if let Some(min_len) = var.min_length {
606 let len = value.len() as u32;
607 if len < min_len {
608 violations.push(format!(
609 "variable '{name}' must be at least {min_len} characters (got {len})"
610 ));
611 }
612 }
613
614 if let Some(max_len) = var.max_length {
615 let len = value.len() as u32;
616 if len > max_len {
617 violations.push(format!(
618 "variable '{name}' must be at most {max_len} characters (got {len})"
619 ));
620 }
621 }
622
623 if let Some(min) = var.min {
624 if let Ok(num) = value.parse::<f64>() {
625 if num < min {
626 violations.push(format!(
627 "variable '{name}' must be at least {min} (got {num})"
628 ));
629 }
630 }
631 }
632
633 if let Some(max) = var.max {
634 if let Ok(num) = value.parse::<f64>() {
635 if num > max {
636 violations.push(format!(
637 "variable '{name}' must be at most {max} (got {num})"
638 ));
639 }
640 }
641 }
642
643 if let Some(ref pattern) = var.pattern {
644 if let Ok(re) = Regex::new(pattern) {
645 if !re.is_match(value) {
646 violations.push(format!("variable '{name}' must match pattern '{pattern}'"));
647 }
648 }
649 }
650
651 if let Some(ref allowed) = var.allowed_values {
652 let allowed_strs: Vec<String> = allowed.iter().map(toml_value_to_string).collect();
653 if !allowed_strs.iter().any(|a| a == value) {
654 violations.push(format!(
655 "variable '{name}' must be one of: {}",
656 allowed_strs.join(", ")
657 ));
658 }
659 }
660
661 violations
662}
663
664pub fn validate_var_values(
668 vars: &HashMap<String, String>,
669 declarations: &HashMap<String, Variable>,
670) -> Result<(), Vec<ZigError>> {
671 let mut errors = Vec::new();
672
673 for (name, decl) in declarations {
674 let value = vars.get(name).map(|s| s.as_str()).unwrap_or("");
675 let violations = check_value_constraints(name, value, decl);
676 for msg in violations {
677 errors.push(ZigError::Validation(msg));
678 }
679 }
680
681 if errors.is_empty() {
682 Ok(())
683 } else {
684 Err(errors)
685 }
686}
687
688pub(crate) fn extract_var_refs(template: &str) -> Vec<String> {
690 let mut refs = Vec::new();
691 let mut rest = template;
692 while let Some(start) = rest.find("${") {
693 let after_start = &rest[start + 2..];
694 if let Some(end) = after_start.find('}') {
695 let var_name = &after_start[..end];
696 let root = var_name.split('.').next().unwrap_or(var_name);
698 refs.push(root.to_string());
699 rest = &after_start[end + 1..];
700 } else {
701 break;
702 }
703 }
704 refs
705}
706
707pub(crate) fn extract_condition_vars(condition: &str) -> Vec<String> {
712 let operators = ["==", "!=", "<", ">", "<=", ">=", "&&", "||", "!"];
713 let keywords = ["true", "false"];
714
715 condition
716 .split(|c: char| c.is_whitespace() || c == '(' || c == ')')
717 .filter(|token| {
718 !token.is_empty()
719 && !operators.contains(token)
720 && !keywords.contains(token)
721 && !token.starts_with('"')
722 && !token.starts_with('\'')
723 && token.parse::<f64>().is_err()
724 })
725 .map(|token| {
726 token.split('.').next().unwrap_or(token).to_string()
728 })
729 .collect()
730}
731
732fn detect_cycle(steps: &[crate::workflow::model::Step]) -> Option<Vec<String>> {
735 let adjacency: HashMap<&str, Vec<&str>> = steps
736 .iter()
737 .map(|s| {
738 (
739 s.name.as_str(),
740 s.depends_on.iter().map(|d| d.as_str()).collect(),
741 )
742 })
743 .collect();
744
745 let mut visited = HashSet::new();
746 let mut in_stack = HashSet::new();
747 let mut path = Vec::new();
748
749 for step in steps {
750 if !visited.contains(step.name.as_str())
751 && dfs_cycle(
752 step.name.as_str(),
753 &adjacency,
754 &mut visited,
755 &mut in_stack,
756 &mut path,
757 )
758 {
759 return Some(path);
760 }
761 }
762 None
763}
764
765fn dfs_cycle<'a>(
766 node: &'a str,
767 adjacency: &HashMap<&'a str, Vec<&'a str>>,
768 visited: &mut HashSet<&'a str>,
769 in_stack: &mut HashSet<&'a str>,
770 path: &mut Vec<String>,
771) -> bool {
772 visited.insert(node);
773 in_stack.insert(node);
774 path.push(node.to_string());
775
776 if let Some(neighbors) = adjacency.get(node) {
777 for &neighbor in neighbors {
778 if !visited.contains(neighbor) {
779 if dfs_cycle(neighbor, adjacency, visited, in_stack, path) {
780 return true;
781 }
782 } else if in_stack.contains(neighbor) {
783 path.push(neighbor.to_string());
784 return true;
785 }
786 }
787 }
788
789 in_stack.remove(node);
790 path.pop();
791 false
792}
793
794#[cfg(test)]
795#[path = "validate_tests.rs"]
796mod tests;