rustant_core/workflow/
parser.rs1use crate::error::WorkflowError;
4use crate::workflow::templates::extract_references;
5use crate::workflow::types::WorkflowDefinition;
6use std::collections::HashSet;
7
8pub 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
15pub fn validate_workflow(workflow: &WorkflowDefinition) -> Result<(), WorkflowError> {
22 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 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 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 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 if let Some(cond) = &step.condition {
58 check_template_refs(cond, &earlier_steps, &input_names, &step.id)?;
59 }
60
61 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 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
79fn 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}