minion_engine/workflow/
validator.rs1use std::collections::HashSet;
2
3use crate::config::StepConfig;
4use crate::plugins::registry::PluginRegistry;
5use crate::workflow::schema::{StepDef, StepType, WorkflowDef};
6
7pub fn validate(workflow: &WorkflowDef) -> Vec<String> {
9 let mut errors = Vec::new();
10
11 validate_steps(&workflow.steps, &workflow.scopes.keys().cloned().collect(), &mut errors);
13
14 let scope_names: HashSet<String> = workflow.scopes.keys().cloned().collect();
16 for (scope_name, scope_def) in &workflow.scopes {
17 let mut seen = HashSet::new();
18 for step in &scope_def.steps {
19 if !seen.insert(&step.name) {
20 errors.push(format!(
21 "Scope '{scope_name}': duplicate step name '{}'",
22 step.name
23 ));
24 }
25 }
26 validate_steps(&scope_def.steps, &scope_names, &mut errors);
27 }
28
29 let mut seen = HashSet::new();
31 for step in &workflow.steps {
32 if !seen.insert(&step.name) {
33 errors.push(format!("Duplicate step name: '{}'", step.name));
34 }
35 }
36
37 for scope_name in workflow.scopes.keys() {
39 let mut visited = HashSet::new();
40 if has_cycle(scope_name, &workflow.scopes, &mut visited) {
41 errors.push(format!("Circular scope reference involving '{scope_name}'"));
42 }
43 }
44
45 errors
46}
47
48fn validate_steps(
49 steps: &[StepDef],
50 scope_names: &HashSet<String>,
51 errors: &mut Vec<String>,
52) {
53 for step in steps {
54 validate_step(step, scope_names, errors);
55 }
56}
57
58fn validate_step(
59 step: &StepDef,
60 scope_names: &HashSet<String>,
61 errors: &mut Vec<String>,
62) {
63 match step.step_type {
64 StepType::Cmd => {
65 if step.run.as_ref().is_none_or(|r| r.trim().is_empty()) {
66 errors.push(format!("Step '{}': cmd step requires 'run' field", step.name));
67 }
68 }
69 StepType::Agent | StepType::Chat => {
70 if step.prompt.as_ref().is_none_or(|p| p.trim().is_empty()) {
71 errors.push(format!(
72 "Step '{}': {} step requires 'prompt' field",
73 step.name, step.step_type
74 ));
75 }
76 }
77 StepType::Gate => {
78 if step.condition.as_ref().is_none_or(|c| c.trim().is_empty()) {
79 errors.push(format!(
80 "Step '{}': gate step requires 'condition' field",
81 step.name
82 ));
83 }
84 }
85 StepType::Repeat | StepType::Map | StepType::Call => {
86 match &step.scope {
87 Some(scope) if !scope_names.contains(scope) => {
88 errors.push(format!(
89 "Step '{}': scope '{}' not found in workflow scopes",
90 step.name, scope
91 ));
92 }
93 None => {
94 errors.push(format!(
95 "Step '{}': {} step requires 'scope' field",
96 step.name, step.step_type
97 ));
98 }
99 _ => {}
100 }
101 if step.step_type == StepType::Repeat {
102 if let Some(max) = step.max_iterations {
103 if max == 0 {
104 errors.push(format!(
105 "Step '{}': max_iterations must be > 0",
106 step.name
107 ));
108 }
109 }
110 }
111 if step.step_type == StepType::Map && step.items.is_none() {
112 errors.push(format!(
113 "Step '{}': map step requires 'items' field",
114 step.name
115 ));
116 }
117 }
118 StepType::Parallel => {
119 if step.steps.as_ref().is_none_or(|s| s.is_empty()) {
120 errors.push(format!(
121 "Step '{}': parallel step requires nested 'steps'",
122 step.name
123 ));
124 }
125 if let Some(nested) = &step.steps {
126 validate_steps(nested, scope_names, errors);
127 }
128 }
129 StepType::Template => {}
130 StepType::Script => {
131 if step.run.as_ref().is_none_or(|r| r.trim().is_empty()) {
132 errors.push(format!("Step '{}': script step requires 'run' field", step.name));
133 }
134 }
135 }
136}
137
138#[allow(dead_code)]
147pub fn validate_plugin_configs(
148 steps: &[StepDef],
149 registry: &PluginRegistry,
150) -> Vec<String> {
151 let mut errors = Vec::new();
152 for step in steps {
153 let type_name = step.step_type.to_string();
154 if let Some(plugin) = registry.get(&type_name) {
155 let schema = plugin.config_schema();
156
157 let values: std::collections::HashMap<String, serde_json::Value> = step
160 .config
161 .iter()
162 .filter_map(|(k, v)| {
163 serde_json::to_value(v).ok().map(|jv| (k.clone(), jv))
165 })
166 .collect();
167 let config = StepConfig { values };
168
169 for field in &schema.required_fields {
171 if config.get_str(field).is_none() && !config.values.contains_key(field.as_str()) {
172 errors.push(format!(
173 "Step '{}' (plugin '{}'): missing required config field '{}'",
174 step.name, type_name, field
175 ));
176 }
177 }
178 }
179 }
180 errors
181}
182
183fn has_cycle(
184 scope_name: &str,
185 scopes: &std::collections::HashMap<String, crate::workflow::schema::ScopeDef>,
186 visited: &mut HashSet<String>,
187) -> bool {
188 if !visited.insert(scope_name.to_string()) {
189 return true;
190 }
191 if let Some(scope_def) = scopes.get(scope_name) {
192 for step in &scope_def.steps {
193 if matches!(step.step_type, StepType::Call | StepType::Repeat | StepType::Map) {
194 if let Some(ref target) = step.scope {
195 if has_cycle(target, scopes, visited) {
196 return true;
197 }
198 }
199 }
200 }
201 }
202 visited.remove(scope_name);
203 false
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::workflow::parser;
210
211 #[test]
212 fn valid_workflow_passes() {
213 let yaml = r#"
214name: test
215steps:
216 - name: hello
217 type: cmd
218 run: "echo hello"
219"#;
220 let wf = parser::parse_str(yaml).unwrap();
221 assert!(validate(&wf).is_empty());
222 }
223
224 #[test]
225 fn missing_run_detected() {
226 let yaml = r#"
227name: test
228steps:
229 - name: broken
230 type: cmd
231"#;
232 let wf = parser::parse_str(yaml).unwrap();
233 let errors = validate(&wf);
234 assert!(errors.iter().any(|e| e.contains("requires 'run'")));
235 }
236
237 #[test]
238 fn missing_scope_detected() {
239 let yaml = r#"
240name: test
241steps:
242 - name: loop
243 type: repeat
244 scope: nonexistent
245 max_iterations: 3
246"#;
247 let wf = parser::parse_str(yaml).unwrap();
248 let errors = validate(&wf);
249 assert!(errors.iter().any(|e| e.contains("not found")));
250 }
251
252 #[test]
253 fn cycle_detected() {
254 let yaml = r#"
255name: test
256scopes:
257 a:
258 steps:
259 - name: call_b
260 type: call
261 scope: b
262 b:
263 steps:
264 - name: call_a
265 type: call
266 scope: a
267steps:
268 - name: start
269 type: call
270 scope: a
271"#;
272 let wf = parser::parse_str(yaml).unwrap();
273 let errors = validate(&wf);
274 assert!(errors.iter().any(|e| e.contains("Circular")));
275 }
276}