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!(result
266            .unwrap_err()
267            .to_string()
268            .contains("at least one step"));
269    }
270
271    #[test]
272    fn test_validate_workflow_duplicate_step_ids() {
273        let yaml = r#"
274name: dupes
275description: Duplicate step IDs
276steps:
277  - id: step1
278    tool: echo
279    params:
280      text: "first"
281  - id: step1
282    tool: echo
283    params:
284      text: "second"
285"#;
286        let wf = parse_workflow(yaml).unwrap();
287        let result = validate_workflow(&wf);
288        assert!(result.is_err());
289        assert!(result
290            .unwrap_err()
291            .to_string()
292            .contains("Duplicate step ID"));
293    }
294
295    #[test]
296    fn test_validate_workflow_unknown_step_reference() {
297        let yaml = r#"
298name: bad_ref
299description: References unknown step
300steps:
301  - id: step1
302    tool: echo
303    params:
304      text: "{{ steps.nonexistent.output }}"
305"#;
306        let wf = parse_workflow(yaml).unwrap();
307        let result = validate_workflow(&wf);
308        assert!(result.is_err());
309        assert!(result.unwrap_err().to_string().contains("unknown step"));
310    }
311
312    #[test]
313    fn test_validate_workflow_valid_passes() {
314        let yaml = r#"
315name: valid
316description: A valid workflow
317inputs:
318  - name: path
319    type: string
320steps:
321  - id: read
322    tool: file_read
323    params:
324      path: "{{ inputs.path }}"
325  - id: process
326    tool: echo
327    params:
328      text: "{{ steps.read.output }}"
329outputs:
330  - name: result
331    value: "{{ steps.process.output }}"
332"#;
333        let wf = parse_workflow(yaml).unwrap();
334        let result = validate_workflow(&wf);
335        assert!(result.is_ok());
336    }
337}