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).
146pub 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            // Build a temporary StepConfig from the step's config map so we can
157            // reuse the same API as the rest of the engine.
158            let values: std::collections::HashMap<String, serde_json::Value> = step
159                .config
160                .iter()
161                .filter_map(|(k, v)| {
162                    // Convert serde_yaml::Value -> serde_json::Value
163                    serde_json::to_value(v).ok().map(|jv| (k.clone(), jv))
164                })
165                .collect();
166            let config = StepConfig { values };
167
168            // Check required fields
169            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}