1use crate::error::{WorkflowError, WorkflowResult};
4use crate::models::Workflow;
5use std::collections::{HashMap, HashSet};
6
7pub struct WorkflowParser;
9
10impl WorkflowParser {
11 pub fn parse_yaml(yaml_content: &str) -> WorkflowResult<Workflow> {
16 let workflow: Workflow = serde_yaml::from_str(yaml_content)
17 .map_err(|e| WorkflowError::Invalid(format!("Failed to parse YAML: {}", e)))?;
18
19 Self::validate(&workflow)?;
20 Ok(workflow)
21 }
22
23 pub fn parse_json(json_content: &str) -> WorkflowResult<Workflow> {
28 let workflow: Workflow = serde_json::from_str(json_content)
29 .map_err(|e| WorkflowError::Invalid(format!("Failed to parse JSON: {}", e)))?;
30
31 Self::validate(&workflow)?;
32 Ok(workflow)
33 }
34
35 pub fn to_yaml(workflow: &Workflow) -> WorkflowResult<String> {
39 serde_yaml::to_string(workflow)
40 .map_err(|e| WorkflowError::Invalid(format!("Failed to serialize to YAML: {}", e)))
41 }
42
43 pub fn to_json(workflow: &Workflow) -> WorkflowResult<String> {
47 serde_json::to_string_pretty(workflow)
48 .map_err(|e| WorkflowError::Invalid(format!("Failed to serialize to JSON: {}", e)))
49 }
50
51 pub fn validate(workflow: &Workflow) -> WorkflowResult<()> {
59 if workflow.id.is_empty() {
61 return Err(WorkflowError::Invalid(
62 "Workflow id is required".to_string(),
63 ));
64 }
65
66 if workflow.name.is_empty() {
67 return Err(WorkflowError::Invalid(
68 "Workflow name is required".to_string(),
69 ));
70 }
71
72 if workflow.steps.is_empty() {
73 return Err(WorkflowError::Invalid(
74 "Workflow must have at least one step".to_string(),
75 ));
76 }
77
78 Self::validate_parameters(workflow)?;
80
81 let mut step_ids = HashSet::new();
83 for step in &workflow.steps {
84 if step.id.is_empty() {
85 return Err(WorkflowError::Invalid(
86 "Step id cannot be empty".to_string(),
87 ));
88 }
89 if !step_ids.insert(&step.id) {
90 return Err(WorkflowError::Invalid(format!(
91 "Duplicate step id: {}",
92 step.id
93 )));
94 }
95 if step.name.is_empty() {
96 return Err(WorkflowError::Invalid(format!(
97 "Step {} name cannot be empty",
98 step.id
99 )));
100 }
101 }
102
103 for step in &workflow.steps {
105 for dep in &step.dependencies {
106 if !step_ids.contains(dep) {
107 return Err(WorkflowError::Invalid(format!(
108 "Step {} depends on non-existent step {}",
109 step.id, dep
110 )));
111 }
112 }
113 }
114
115 Self::detect_circular_dependencies(workflow)?;
117
118 Ok(())
119 }
120
121 fn detect_circular_dependencies(workflow: &Workflow) -> WorkflowResult<()> {
126 let step_map: HashMap<&String, &crate::models::WorkflowStep> =
127 workflow.steps.iter().map(|s| (&s.id, s)).collect();
128
129 for start_step in &workflow.steps {
131 let mut visited = HashSet::new();
132 let mut rec_stack = HashSet::new();
133
134 Self::dfs_detect_cycle(&step_map, &start_step.id, &mut visited, &mut rec_stack)?;
135 }
136
137 Ok(())
138 }
139
140 fn dfs_detect_cycle(
142 step_map: &HashMap<&String, &crate::models::WorkflowStep>,
143 step_id: &String,
144 visited: &mut HashSet<String>,
145 rec_stack: &mut HashSet<String>,
146 ) -> WorkflowResult<()> {
147 visited.insert(step_id.clone());
148 rec_stack.insert(step_id.clone());
149
150 if let Some(step) = step_map.get(step_id) {
151 for dep in &step.dependencies {
152 if !visited.contains(dep) {
153 Self::dfs_detect_cycle(step_map, dep, visited, rec_stack)?;
154 } else if rec_stack.contains(dep) {
155 return Err(WorkflowError::Invalid(format!(
156 "Circular dependency detected: {} -> {}",
157 step_id, dep
158 )));
159 }
160 }
161 }
162
163 rec_stack.remove(step_id);
164 Ok(())
165 }
166
167 fn validate_parameters(workflow: &Workflow) -> WorkflowResult<()> {
169 let mut seen_names = HashSet::new();
170
171 for param in &workflow.parameters {
172 if !seen_names.insert(¶m.name) {
174 return Err(WorkflowError::Invalid(format!(
175 "Duplicate parameter name: {}",
176 param.name
177 )));
178 }
179
180 if param.name.is_empty() {
182 return Err(WorkflowError::Invalid(
183 "Parameter name cannot be empty".to_string(),
184 ));
185 }
186
187 if !param
189 .name
190 .chars()
191 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
192 {
193 return Err(WorkflowError::Invalid(format!(
194 "Invalid parameter name: {}. Must contain only alphanumeric characters, underscores, and hyphens",
195 param.name
196 )));
197 }
198
199 match param.param_type.as_str() {
201 "string" | "number" | "boolean" | "object" | "array" => {}
202 _ => {
203 return Err(WorkflowError::Invalid(format!(
204 "Invalid parameter type for '{}': {}. Must be one of: string, number, boolean, object, array",
205 param.name, param.param_type
206 )));
207 }
208 }
209
210 if let Some(default) = ¶m.default {
212 Self::validate_parameter_type(¶m.name, ¶m.param_type, default)?;
213 }
214
215 if param.required && param.default.is_some() {
217 return Err(WorkflowError::Invalid(format!(
218 "Required parameter '{}' cannot have a default value",
219 param.name
220 )));
221 }
222 }
223
224 Ok(())
225 }
226
227 fn validate_parameter_type(
229 param_name: &str,
230 param_type: &str,
231 value: &serde_json::Value,
232 ) -> WorkflowResult<()> {
233 let matches = match param_type {
234 "string" => value.is_string(),
235 "number" => value.is_number(),
236 "boolean" => value.is_boolean(),
237 "object" => value.is_object(),
238 "array" => value.is_array(),
239 _ => false,
240 };
241
242 if !matches {
243 return Err(WorkflowError::Invalid(format!(
244 "Default value for parameter '{}' does not match type '{}'",
245 param_name, param_type
246 )));
247 }
248
249 Ok(())
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_parse_valid_yaml() {
259 let yaml = r#"
260id: test-workflow
261name: Test Workflow
262description: A test workflow
263parameters: []
264steps:
265 - id: step1
266 name: Step 1
267 step_type:
268 type: agent
269 agent_id: test-agent
270 task: test-task
271 dependencies: []
272 approval_required: false
273 on_error:
274 action: fail
275 config: {}
276config:
277 timeout_ms: 5000
278"#;
279
280 let result = WorkflowParser::parse_yaml(yaml);
281 assert!(result.is_ok());
282 }
283
284 #[test]
285 fn test_parse_invalid_yaml_missing_id() {
286 let yaml = r#"
287name: Test Workflow
288description: A test workflow
289parameters: []
290steps: []
291config: {}
292"#;
293
294 let result = WorkflowParser::parse_yaml(yaml);
295 assert!(result.is_err());
296 }
297
298 #[test]
299 fn test_validate_missing_dependency() {
300 let yaml = r#"
301id: test-workflow
302name: Test Workflow
303description: A test workflow
304parameters: []
305steps:
306 - id: step1
307 name: Step 1
308 type: agent
309 agent_id: test-agent
310 task: test-task
311 dependencies: [non-existent]
312 approval_required: false
313 on_error:
314 action: fail
315 config: {}
316config: {}
317"#;
318
319 let result = WorkflowParser::parse_yaml(yaml);
320 assert!(result.is_err());
321 }
322
323 #[test]
324 fn test_detect_circular_dependency() {
325 let yaml = r#"
326id: test-workflow
327name: Test Workflow
328description: A test workflow
329parameters: []
330steps:
331 - id: step1
332 name: Step 1
333 type: agent
334 agent_id: test-agent
335 task: test-task
336 dependencies: [step2]
337 approval_required: false
338 on_error:
339 action: fail
340 config: {}
341 - id: step2
342 name: Step 2
343 type: agent
344 agent_id: test-agent
345 task: test-task
346 dependencies: [step1]
347 approval_required: false
348 on_error:
349 action: fail
350 config: {}
351config: {}
352"#;
353
354 let result = WorkflowParser::parse_yaml(yaml);
355 assert!(result.is_err());
356 }
357
358 #[test]
359 fn test_validate_empty_step_id() {
360 let yaml = r#"
361id: test-workflow
362name: Test Workflow
363description: A test workflow
364parameters: []
365steps:
366 - id: ""
367 name: Step 1
368 type: agent
369 agent_id: test-agent
370 task: test-task
371 dependencies: []
372 approval_required: false
373 on_error:
374 action: fail
375 config: {}
376config: {}
377"#;
378
379 let result = WorkflowParser::parse_yaml(yaml);
380 assert!(result.is_err());
381 }
382
383 #[test]
384 fn test_validate_empty_step_name() {
385 let yaml = r#"
386id: test-workflow
387name: Test Workflow
388description: A test workflow
389parameters: []
390steps:
391 - id: step1
392 name: ""
393 type: agent
394 agent_id: test-agent
395 task: test-task
396 dependencies: []
397 approval_required: false
398 on_error:
399 action: fail
400 config: {}
401config: {}
402"#;
403
404 let result = WorkflowParser::parse_yaml(yaml);
405 assert!(result.is_err());
406 }
407}
408
409#[cfg(test)]
410mod property_tests {
411 use super::*;
412 use proptest::prelude::*;
413
414 #[test]
420 fn prop_workflow_parsing_round_trip() {
421 proptest!(|(
422 id in "[a-z0-9_-]{1,20}",
423 name in "[a-zA-Z0-9]{1,30}",
424 )| {
425 let workflow = crate::models::Workflow {
427 id: id.clone(),
428 name: name.clone(),
429 description: "Test workflow".to_string(),
430 parameters: vec![],
431 steps: vec![
432 crate::models::WorkflowStep {
433 id: "step1".to_string(),
434 name: "Step 1".to_string(),
435 step_type: crate::models::StepType::Agent(crate::models::AgentStep {
436 agent_id: "test-agent".to_string(),
437 task: "test-task".to_string(),
438 }),
439 config: crate::models::StepConfig {
440 config: serde_json::json!({}),
441 },
442 dependencies: vec![],
443 approval_required: false,
444 on_error: crate::models::ErrorAction::Fail,
445 risk_score: None,
446 risk_factors: crate::models::RiskFactors::default(),
447 },
448 ],
449 config: crate::models::WorkflowConfig {
450 timeout_ms: Some(5000),
451 max_parallel: None,
452 },
453 };
454
455 let serialized = WorkflowParser::to_yaml(&workflow);
457 prop_assert!(serialized.is_ok(), "Failed to serialize workflow to YAML");
458
459 let serialized = serialized.unwrap();
460
461 let reparsed = WorkflowParser::parse_yaml(&serialized);
463 prop_assert!(reparsed.is_ok(), "Failed to reparse serialized YAML");
464
465 let reparsed = reparsed.unwrap();
466
467 prop_assert_eq!(&workflow.id, &reparsed.id, "Workflow ID mismatch");
469 prop_assert_eq!(&workflow.name, &reparsed.name, "Workflow name mismatch");
470 prop_assert_eq!(workflow.steps.len(), reparsed.steps.len(), "Step count mismatch");
471
472 for (original_step, reparsed_step) in workflow.steps.iter().zip(reparsed.steps.iter()) {
473 prop_assert_eq!(&original_step.id, &reparsed_step.id, "Step ID mismatch");
474 prop_assert_eq!(&original_step.name, &reparsed_step.name, "Step name mismatch");
475 prop_assert_eq!(&original_step.dependencies, &reparsed_step.dependencies, "Dependencies mismatch");
476 }
477 });
478 }
479
480 #[test]
486 fn prop_workflow_validation_rejects_invalid() {
487 proptest!(|(
488 missing_field in 0..3u32,
489 )| {
490 let yaml = match missing_field {
491 0 => r#"
492name: Test Workflow
493description: A test workflow
494steps:
495 - id: step1
496 name: Step 1
497 type: agent
498 agent_id: test-agent
499 task: test-task
500 dependencies: []
501 approval_required: false
502 on_error:
503 action: fail
504 config: {}
505config: {}
506"#,
507 1 => r#"
508id: test-workflow
509description: A test workflow
510steps:
511 - id: step1
512 name: Step 1
513 type: agent
514 agent_id: test-agent
515 task: test-task
516 dependencies: []
517 approval_required: false
518 on_error:
519 action: fail
520 config: {}
521config: {}
522"#,
523 _ => r#"
524id: test-workflow
525name: Test Workflow
526description: A test workflow
527steps: []
528config: {}
529"#,
530 };
531
532 let result = WorkflowParser::parse_yaml(yaml);
533 prop_assert!(result.is_err(), "Should reject invalid workflow");
534 });
535 }
536}