Skip to main content

minion_engine/workflow/
validator.rs

1use std::collections::HashSet;
2
3use crate::config::StepConfig;
4use crate::plugins::registry::PluginRegistry;
5use crate::workflow::schema::{StepDef, StepType, WorkflowDef};
6
7/// Validate a parsed workflow, returning all errors found
8pub fn validate(workflow: &WorkflowDef) -> Vec<String> {
9    let mut errors = Vec::new();
10
11    // Check main steps
12    validate_steps(&workflow.steps, &workflow.scopes.keys().cloned().collect(), &mut errors);
13
14    // Check scope steps
15    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    // Check for unique step names in main pipeline
30    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    // Check for circular scope references
38    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/// Validate plugin step configurations against each plugin's declared schema.
139///
140/// For each step whose type matches a registered plugin:
141///   - Ensures all `required_fields` are present in the step config
142///   - Reports missing required fields as errors
143///   - Applies default values for missing optional fields (mutates the steps)
144///
145/// Returns a list of validation error messages (empty means all ok).
146#[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            // Build a temporary StepConfig from the step's config map so we can
158            // reuse the same API as the rest of the engine.
159            let values: std::collections::HashMap<String, serde_json::Value> = step
160                .config
161                .iter()
162                .filter_map(|(k, v)| {
163                    // Convert serde_yaml::Value -> serde_json::Value
164                    serde_json::to_value(v).ok().map(|jv| (k.clone(), jv))
165                })
166                .collect();
167            let config = StepConfig { values };
168
169            // Check required fields
170            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}