Skip to main content

rustant_core/workflow/
parser.rs

1//! YAML DSL parser and validator for workflow definitions.
2
3use crate::error::WorkflowError;
4use crate::workflow::templates::extract_references;
5use crate::workflow::types::WorkflowDefinition;
6use std::collections::HashSet;
7
8/// Parse a workflow definition from a YAML string.
9pub fn parse_workflow(yaml: &str) -> Result<WorkflowDefinition, WorkflowError> {
10    serde_yaml::from_str::<WorkflowDefinition>(yaml).map_err(|e| WorkflowError::ParseError {
11        message: e.to_string(),
12    })
13}
14
15/// Validate a parsed workflow definition for structural correctness.
16///
17/// Checks:
18/// - At least one step exists
19/// - No duplicate step IDs
20/// - All template step references point to earlier steps
21pub fn validate_workflow(workflow: &WorkflowDefinition) -> Result<(), WorkflowError> {
22    // Must have at least one step
23    if workflow.steps.is_empty() {
24        return Err(WorkflowError::ValidationFailed {
25            message: "Workflow must have at least one step".to_string(),
26        });
27    }
28
29    // Check for duplicate step IDs
30    let mut seen_ids = HashSet::new();
31    for step in &workflow.steps {
32        if !seen_ids.insert(&step.id) {
33            return Err(WorkflowError::ValidationFailed {
34                message: format!("Duplicate step ID: '{}'", step.id),
35            });
36        }
37    }
38
39    // Validate that template references point to known steps
40    let step_ids: HashSet<&str> = workflow.steps.iter().map(|s| s.id.as_str()).collect();
41    let input_names: HashSet<&str> = workflow.inputs.iter().map(|i| i.name.as_str()).collect();
42
43    for (idx, step) in workflow.steps.iter().enumerate() {
44        let earlier_steps: HashSet<&str> = workflow.steps[..idx]
45            .iter()
46            .map(|s| s.id.as_str())
47            .collect();
48
49        // Check param templates
50        for value in step.params.values() {
51            if let Some(s) = value.as_str() {
52                check_template_refs(s, &earlier_steps, &input_names, &step.id)?;
53            }
54        }
55
56        // Check condition template
57        if let Some(cond) = &step.condition {
58            check_template_refs(cond, &earlier_steps, &input_names, &step.id)?;
59        }
60
61        // Check gate preview/message templates
62        if let Some(msg) = &step.gate_message {
63            check_template_refs(msg, &earlier_steps, &input_names, &step.id)?;
64        }
65        if let Some(preview) = &step.gate_preview {
66            check_template_refs(preview, &earlier_steps, &input_names, &step.id)?;
67        }
68    }
69
70    // Validate output templates
71    for output in &workflow.outputs {
72        let all_steps: HashSet<&str> = step_ids.iter().copied().collect();
73        check_template_refs(&output.value, &all_steps, &input_names, "output")?;
74    }
75
76    Ok(())
77}
78
79/// Check that all template references in a string point to known steps/inputs.
80fn check_template_refs(
81    template: &str,
82    known_steps: &HashSet<&str>,
83    known_inputs: &HashSet<&str>,
84    context_step: &str,
85) -> Result<(), WorkflowError> {
86    let refs = extract_references(template);
87    for (namespace, name) in refs {
88        match namespace.as_str() {
89            "steps" => {
90                if !known_steps.contains(name.as_str()) {
91                    return Err(WorkflowError::ValidationFailed {
92                        message: format!(
93                            "Step '{}' references unknown step '{}' (steps must reference earlier steps)",
94                            context_step, name
95                        ),
96                    });
97                }
98            }
99            "inputs" => {
100                if !known_inputs.contains(name.as_str()) {
101                    return Err(WorkflowError::ValidationFailed {
102                        message: format!(
103                            "Step '{}' references unknown input '{}'",
104                            context_step, name
105                        ),
106                    });
107                }
108            }
109            _ => {
110                return Err(WorkflowError::ValidationFailed {
111                    message: format!(
112                        "Step '{}' has unknown template namespace '{}'",
113                        context_step, namespace
114                    ),
115                });
116            }
117        }
118    }
119    Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_parse_minimal_workflow() {
128        let yaml = r#"
129name: minimal
130description: A minimal workflow
131steps:
132  - id: step1
133    tool: echo
134    params:
135      text: "hello"
136"#;
137        let wf = parse_workflow(yaml).unwrap();
138        assert_eq!(wf.name, "minimal");
139        assert_eq!(wf.steps.len(), 1);
140        assert_eq!(wf.steps[0].id, "step1");
141        assert_eq!(wf.steps[0].tool, "echo");
142    }
143
144    #[test]
145    fn test_parse_workflow_with_inputs() {
146        let yaml = r#"
147name: with_inputs
148description: Workflow with typed inputs
149inputs:
150  - name: path
151    type: string
152    description: File path to process
153  - name: focus_areas
154    type: "string[]"
155    optional: true
156    default: ["security", "performance"]
157steps:
158  - id: read
159    tool: file_read
160    params:
161      path: "{{ inputs.path }}"
162"#;
163        let wf = parse_workflow(yaml).unwrap();
164        assert_eq!(wf.inputs.len(), 2);
165        assert_eq!(wf.inputs[0].name, "path");
166        assert_eq!(wf.inputs[0].input_type, "string");
167        assert!(wf.inputs[1].optional);
168        assert!(wf.inputs[1].default.is_some());
169    }
170
171    #[test]
172    fn test_parse_workflow_with_gates() {
173        let yaml = r#"
174name: gated
175description: Workflow with approval gates
176steps:
177  - id: review
178    tool: echo
179    params:
180      text: "Review this"
181    gate:
182      type: approval_required
183      message: "Approve this action?"
184      timeout_secs: 300
185"#;
186        let wf = parse_workflow(yaml).unwrap();
187        let gate = wf.steps[0].gate.as_ref().unwrap();
188        assert_eq!(
189            gate.gate_type,
190            super::super::types::GateType::ApprovalRequired
191        );
192        assert_eq!(gate.message, "Approve this action?");
193        assert_eq!(gate.timeout_secs, Some(300));
194    }
195
196    #[test]
197    fn test_parse_workflow_with_conditions() {
198        let yaml = r#"
199name: conditional
200description: Workflow with conditional steps
201inputs:
202  - name: mode
203    type: string
204steps:
205  - id: check
206    tool: echo
207    params:
208      text: "checking"
209  - id: optional_step
210    tool: echo
211    params:
212      text: "conditional"
213    condition: "{{ steps.check.output }} == 'pass'"
214"#;
215        let wf = parse_workflow(yaml).unwrap();
216        assert_eq!(wf.steps.len(), 2);
217        assert!(wf.steps[1].condition.is_some());
218    }
219
220    #[test]
221    fn test_parse_workflow_with_error_handling() {
222        let yaml = r#"
223name: error_handling
224description: Workflow with error handling
225steps:
226  - id: risky
227    tool: shell_exec
228    params:
229      command: "ls"
230    on_error:
231      action: retry
232      max_retries: 3
233"#;
234        let wf = parse_workflow(yaml).unwrap();
235        let on_error = wf.steps[0].on_error.as_ref().unwrap();
236        match on_error {
237            super::super::types::ErrorAction::Retry { max_retries } => {
238                assert_eq!(*max_retries, 3)
239            }
240            _ => panic!("Expected Retry error action"),
241        }
242    }
243
244    #[test]
245    fn test_parse_invalid_yaml_returns_error() {
246        let yaml = "this is not: valid: yaml: {{{}}}";
247        let result = parse_workflow(yaml);
248        assert!(result.is_err());
249        match result.unwrap_err() {
250            WorkflowError::ParseError { .. } => {}
251            other => panic!("Expected ParseError, got: {:?}", other),
252        }
253    }
254
255    #[test]
256    fn test_validate_workflow_missing_steps() {
257        let yaml = r#"
258name: empty
259description: No steps
260steps: []
261"#;
262        let wf = parse_workflow(yaml).unwrap();
263        let result = validate_workflow(&wf);
264        assert!(result.is_err());
265        assert!(
266            result
267                .unwrap_err()
268                .to_string()
269                .contains("at least one step")
270        );
271    }
272
273    #[test]
274    fn test_validate_workflow_duplicate_step_ids() {
275        let yaml = r#"
276name: dupes
277description: Duplicate step IDs
278steps:
279  - id: step1
280    tool: echo
281    params:
282      text: "first"
283  - id: step1
284    tool: echo
285    params:
286      text: "second"
287"#;
288        let wf = parse_workflow(yaml).unwrap();
289        let result = validate_workflow(&wf);
290        assert!(result.is_err());
291        assert!(
292            result
293                .unwrap_err()
294                .to_string()
295                .contains("Duplicate step ID")
296        );
297    }
298
299    #[test]
300    fn test_validate_workflow_unknown_step_reference() {
301        let yaml = r#"
302name: bad_ref
303description: References unknown step
304steps:
305  - id: step1
306    tool: echo
307    params:
308      text: "{{ steps.nonexistent.output }}"
309"#;
310        let wf = parse_workflow(yaml).unwrap();
311        let result = validate_workflow(&wf);
312        assert!(result.is_err());
313        assert!(result.unwrap_err().to_string().contains("unknown step"));
314    }
315
316    #[test]
317    fn test_validate_workflow_valid_passes() {
318        let yaml = r#"
319name: valid
320description: A valid workflow
321inputs:
322  - name: path
323    type: string
324steps:
325  - id: read
326    tool: file_read
327    params:
328      path: "{{ inputs.path }}"
329  - id: process
330    tool: echo
331    params:
332      text: "{{ steps.read.output }}"
333outputs:
334  - name: result
335    value: "{{ steps.process.output }}"
336"#;
337        let wf = parse_workflow(yaml).unwrap();
338        let result = validate_workflow(&wf);
339        assert!(result.is_ok());
340    }
341}