ricecoder_workflows/
parser.rs

1//! Workflow definition parser
2
3use crate::error::{WorkflowError, WorkflowResult};
4use crate::models::Workflow;
5use std::collections::{HashMap, HashSet};
6
7/// Parses and validates workflow definitions
8pub struct WorkflowParser;
9
10impl WorkflowParser {
11    /// Parse a workflow from YAML string
12    ///
13    /// Parses YAML into a Workflow struct, handling nested step configurations
14    /// and resolving step references and dependencies.
15    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    /// Parse a workflow from JSON string
24    ///
25    /// Parses JSON into a Workflow struct, handling nested step configurations
26    /// and resolving step references and dependencies.
27    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    /// Serialize a workflow to YAML string
36    ///
37    /// Converts a Workflow struct back to YAML format for round-trip testing.
38    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    /// Serialize a workflow to JSON string
44    ///
45    /// Converts a Workflow struct back to JSON format for round-trip testing.
46    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    /// Validate a workflow definition
52    ///
53    /// Validates:
54    /// - Required fields (id, name, steps)
55    /// - Step dependencies (no missing references)
56    /// - Circular dependencies in step execution order
57    /// - Parameter references
58    pub fn validate(workflow: &Workflow) -> WorkflowResult<()> {
59        // Validate required fields
60        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        // Validate parameters
79        Self::validate_parameters(workflow)?;
80
81        // Validate step IDs are unique
82        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        // Validate step dependencies
104        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        // Detect circular dependencies
116        Self::detect_circular_dependencies(workflow)?;
117
118        Ok(())
119    }
120
121    /// Detect circular dependencies in workflow steps
122    ///
123    /// Uses depth-first search to detect cycles in the dependency graph.
124    /// Returns an error if any circular dependency is found.
125    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 each step, perform DFS to detect cycles
130        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    /// Depth-first search to detect cycles in dependency graph
141    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    /// Validate workflow parameters
168    fn validate_parameters(workflow: &Workflow) -> WorkflowResult<()> {
169        let mut seen_names = HashSet::new();
170
171        for param in &workflow.parameters {
172            // Check for duplicate names
173            if !seen_names.insert(&param.name) {
174                return Err(WorkflowError::Invalid(format!(
175                    "Duplicate parameter name: {}",
176                    param.name
177                )));
178            }
179
180            // Validate parameter name
181            if param.name.is_empty() {
182                return Err(WorkflowError::Invalid(
183                    "Parameter name cannot be empty".to_string(),
184                ));
185            }
186
187            // Check that parameter name is valid (alphanumeric, underscore, hyphen)
188            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            // Validate parameter type
200            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            // Validate default value matches type if provided
211            if let Some(default) = &param.default {
212                Self::validate_parameter_type(&param.name, &param.param_type, default)?;
213            }
214
215            // Required parameters should not have defaults
216            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    /// Validate that a value matches a parameter type
228    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    /// **Feature: ricecoder-workflows, Property 1: Workflow Parsing Round Trip**
415    /// **Validates: Requirements 1.1**
416    ///
417    /// For any valid YAML workflow definition, parsing the definition and then
418    /// serializing the resulting Workflow object SHALL produce an equivalent definition.
419    #[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            // Create a workflow programmatically to ensure it's valid
426            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            // Serialize to YAML
456            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            // Parse the serialized YAML
462            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            // Verify the reparsed workflow matches the original
468            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    /// **Feature: ricecoder-workflows, Property 6: Workflow Validation**
481    /// **Validates: Requirements 1.6**
482    ///
483    /// For any invalid workflow definition, the validation system SHALL identify
484    /// and report all syntax errors before execution begins.
485    #[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}