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!(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}