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
138pub fn validate_plugin_configs(
147 steps: &[StepDef],
148 registry: &PluginRegistry,
149) -> Vec<String> {
150 let mut errors = Vec::new();
151 for step in steps {
152 let type_name = step.step_type.to_string();
153 if let Some(plugin) = registry.get(&type_name) {
154 let schema = plugin.config_schema();
155
156 let values: std::collections::HashMap<String, serde_json::Value> = step
159 .config
160 .iter()
161 .filter_map(|(k, v)| {
162 serde_json::to_value(v).ok().map(|jv| (k.clone(), jv))
164 })
165 .collect();
166 let config = StepConfig { values };
167
168 for field in &schema.required_fields {
170 if config.get_str(field).is_none() && !config.values.contains_key(field.as_str()) {
171 errors.push(format!(
172 "Step '{}' (plugin '{}'): missing required config field '{}'",
173 step.name, type_name, field
174 ));
175 }
176 }
177 }
178 }
179 errors
180}
181
182fn has_cycle(
183 scope_name: &str,
184 scopes: &std::collections::HashMap<String, crate::workflow::schema::ScopeDef>,
185 visited: &mut HashSet<String>,
186) -> bool {
187 if !visited.insert(scope_name.to_string()) {
188 return true;
189 }
190 if let Some(scope_def) = scopes.get(scope_name) {
191 for step in &scope_def.steps {
192 if matches!(step.step_type, StepType::Call | StepType::Repeat | StepType::Map) {
193 if let Some(ref target) = step.scope {
194 if has_cycle(target, scopes, visited) {
195 return true;
196 }
197 }
198 }
199 }
200 }
201 visited.remove(scope_name);
202 false
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::workflow::parser;
209
210 #[test]
211 fn valid_workflow_passes() {
212 let yaml = r#"
213name: test
214steps:
215 - name: hello
216 type: cmd
217 run: "echo hello"
218"#;
219 let wf = parser::parse_str(yaml).unwrap();
220 assert!(validate(&wf).is_empty());
221 }
222
223 #[test]
224 fn missing_run_detected() {
225 let yaml = r#"
226name: test
227steps:
228 - name: broken
229 type: cmd
230"#;
231 let wf = parser::parse_str(yaml).unwrap();
232 let errors = validate(&wf);
233 assert!(errors.iter().any(|e| e.contains("requires 'run'")));
234 }
235
236 #[test]
237 fn missing_scope_detected() {
238 let yaml = r#"
239name: test
240steps:
241 - name: loop
242 type: repeat
243 scope: nonexistent
244 max_iterations: 3
245"#;
246 let wf = parser::parse_str(yaml).unwrap();
247 let errors = validate(&wf);
248 assert!(errors.iter().any(|e| e.contains("not found")));
249 }
250
251 #[test]
252 fn cycle_detected() {
253 let yaml = r#"
254name: test
255scopes:
256 a:
257 steps:
258 - name: call_b
259 type: call
260 scope: b
261 b:
262 steps:
263 - name: call_a
264 type: call
265 scope: a
266steps:
267 - name: start
268 type: call
269 scope: a
270"#;
271 let wf = parser::parse_str(yaml).unwrap();
272 let errors = validate(&wf);
273 assert!(errors.iter().any(|e| e.contains("Circular")));
274 }
275}