ricecoder_workflows/
parameter_substitution.rs

1//! Parameter substitution in workflow step configurations
2
3use crate::error::{WorkflowError, WorkflowResult};
4use crate::models::{Workflow, WorkflowStep};
5use crate::parameters::ParameterSubstitutor;
6use serde_json::Value;
7use std::collections::HashMap;
8
9/// Handles parameter substitution in workflow step configurations
10pub struct StepConfigSubstitutor;
11
12impl StepConfigSubstitutor {
13    /// Substitute parameters in all step configurations
14    ///
15    /// Replaces all parameter references (${param_name}) in step configurations
16    /// with their provided values. Handles nested parameter references and validates
17    /// all parameters are provided at execution time.
18    pub fn substitute_in_workflow(
19        workflow: &mut Workflow,
20        parameters: &HashMap<String, Value>,
21    ) -> WorkflowResult<()> {
22        // Build final parameters with defaults
23        let final_params = Self::build_final_parameters(workflow, parameters)?;
24
25        // Substitute in each step's configuration
26        for step in &mut workflow.steps {
27            Self::substitute_in_step(step, &final_params)?;
28        }
29
30        Ok(())
31    }
32
33    /// Substitute parameters in a single step's configuration
34    pub fn substitute_in_step(
35        step: &mut WorkflowStep,
36        parameters: &HashMap<String, Value>,
37    ) -> WorkflowResult<()> {
38        // Substitute in the step config
39        let substituted = ParameterSubstitutor::substitute(&step.config.config, parameters)?;
40        step.config.config = substituted;
41
42        Ok(())
43    }
44
45    /// Validate that all required parameters are provided
46    fn validate_all_parameters_provided(
47        workflow: &Workflow,
48        parameters: &HashMap<String, Value>,
49    ) -> WorkflowResult<()> {
50        // Check that all required parameters are provided
51        for param_def in &workflow.parameters {
52            if param_def.required
53                && !parameters.contains_key(&param_def.name)
54                && param_def.default.is_none()
55            {
56                return Err(WorkflowError::Invalid(format!(
57                    "Required parameter '{}' not provided",
58                    param_def.name
59                )));
60            }
61        }
62
63        // Check that no unknown parameters are provided
64        let known_names: std::collections::HashSet<_> =
65            workflow.parameters.iter().map(|p| &p.name).collect();
66
67        for provided_name in parameters.keys() {
68            if !known_names.contains(provided_name) {
69                return Err(WorkflowError::Invalid(format!(
70                    "Unknown parameter: {}",
71                    provided_name
72                )));
73            }
74        }
75
76        Ok(())
77    }
78
79    /// Build final parameter values with defaults
80    ///
81    /// Merges provided values with defaults from parameter definitions
82    pub fn build_final_parameters(
83        workflow: &Workflow,
84        provided: &HashMap<String, Value>,
85    ) -> WorkflowResult<HashMap<String, Value>> {
86        Self::validate_all_parameters_provided(workflow, provided)?;
87
88        let mut final_params = HashMap::new();
89
90        for param_def in &workflow.parameters {
91            if let Some(value) = provided.get(&param_def.name) {
92                final_params.insert(param_def.name.clone(), value.clone());
93            } else if let Some(default) = &param_def.default {
94                final_params.insert(param_def.name.clone(), default.clone());
95            }
96        }
97
98        Ok(final_params)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::models::StepConfig;
106    use serde_json::json;
107
108    fn create_test_workflow() -> Workflow {
109        Workflow {
110            id: "test".to_string(),
111            name: "Test".to_string(),
112            description: "Test workflow".to_string(),
113            parameters: vec![
114                crate::models::WorkflowParameter {
115                    name: "name".to_string(),
116                    param_type: "string".to_string(),
117                    default: Some(json!("default-name")),
118                    required: false,
119                    description: "Name parameter".to_string(),
120                },
121                crate::models::WorkflowParameter {
122                    name: "count".to_string(),
123                    param_type: "number".to_string(),
124                    default: None,
125                    required: true,
126                    description: "Count parameter".to_string(),
127                },
128            ],
129            steps: vec![crate::models::WorkflowStep {
130                id: "step1".to_string(),
131                name: "Step 1".to_string(),
132                step_type: crate::models::StepType::Agent(crate::models::AgentStep {
133                    agent_id: "test-agent".to_string(),
134                    task: "test-task".to_string(),
135                }),
136                config: StepConfig {
137                    config: json!({
138                        "message": "Hello ${name}",
139                        "count": "${count}"
140                    }),
141                },
142                dependencies: vec![],
143                approval_required: false,
144                on_error: crate::models::ErrorAction::Fail,
145                risk_score: None,
146                risk_factors: crate::models::RiskFactors::default(),
147            }],
148            config: crate::models::WorkflowConfig {
149                timeout_ms: None,
150                max_parallel: None,
151            },
152        }
153    }
154
155    #[test]
156    fn test_substitute_in_step_config() {
157        let mut workflow = create_test_workflow();
158        let mut params = HashMap::new();
159        params.insert("name".to_string(), json!("Alice"));
160        params.insert("count".to_string(), json!(42));
161
162        let result = StepConfigSubstitutor::substitute_in_workflow(&mut workflow, &params);
163        assert!(result.is_ok());
164
165        let step = &workflow.steps[0];
166        assert_eq!(
167            step.config.config.get("message").and_then(|v| v.as_str()),
168            Some("Hello Alice")
169        );
170    }
171
172    #[test]
173    fn test_substitute_missing_required_parameter() {
174        let mut workflow = create_test_workflow();
175        let params = HashMap::new();
176
177        let result = StepConfigSubstitutor::substitute_in_workflow(&mut workflow, &params);
178        assert!(result.is_err());
179    }
180
181    #[test]
182    fn test_substitute_unknown_parameter() {
183        let mut workflow = create_test_workflow();
184        let mut params = HashMap::new();
185        params.insert("name".to_string(), json!("Alice"));
186        params.insert("count".to_string(), json!(42));
187        params.insert("unknown".to_string(), json!("value"));
188
189        let result = StepConfigSubstitutor::substitute_in_workflow(&mut workflow, &params);
190        assert!(result.is_err());
191    }
192
193    #[test]
194    fn test_build_final_parameters_with_defaults() {
195        let workflow = create_test_workflow();
196        let mut provided = HashMap::new();
197        provided.insert("count".to_string(), json!(42));
198
199        let result = StepConfigSubstitutor::build_final_parameters(&workflow, &provided);
200        assert!(result.is_ok());
201
202        let final_params = result.unwrap();
203        assert_eq!(final_params.get("name"), Some(&json!("default-name")));
204        assert_eq!(final_params.get("count"), Some(&json!(42)));
205    }
206
207    #[test]
208    fn test_substitute_nested_parameters() {
209        let mut workflow = create_test_workflow();
210        workflow.steps[0].config.config = json!({
211            "nested": {
212                "message": "Hello ${name}",
213                "items": ["${name}", "world"]
214            }
215        });
216
217        let mut params = HashMap::new();
218        params.insert("name".to_string(), json!("Alice"));
219        params.insert("count".to_string(), json!(42));
220
221        let result = StepConfigSubstitutor::substitute_in_workflow(&mut workflow, &params);
222        assert!(result.is_ok());
223
224        let step = &workflow.steps[0];
225        assert_eq!(
226            step.config
227                .config
228                .get("nested")
229                .and_then(|v| v.get("message"))
230                .and_then(|v| v.as_str()),
231            Some("Hello Alice")
232        );
233    }
234
235    #[test]
236    fn test_substitute_multiple_references_in_string() {
237        let mut workflow = create_test_workflow();
238        workflow.steps[0].config.config = json!({
239            "message": "${name} has ${count} items"
240        });
241
242        let mut params = HashMap::new();
243        params.insert("name".to_string(), json!("Alice"));
244        params.insert("count".to_string(), json!(42));
245
246        let result = StepConfigSubstitutor::substitute_in_workflow(&mut workflow, &params);
247        assert!(result.is_ok());
248
249        let step = &workflow.steps[0];
250        assert_eq!(
251            step.config.config.get("message").and_then(|v| v.as_str()),
252            Some("Alice has 42 items")
253        );
254    }
255
256    #[test]
257    fn test_substitute_with_default_parameter() {
258        let mut workflow = create_test_workflow();
259        let mut params = HashMap::new();
260        params.insert("count".to_string(), json!(42));
261        // name is not provided, should use default
262
263        let result = StepConfigSubstitutor::substitute_in_workflow(&mut workflow, &params);
264        assert!(result.is_ok());
265
266        let step = &workflow.steps[0];
267        assert_eq!(
268            step.config.config.get("message").and_then(|v| v.as_str()),
269            Some("Hello default-name")
270        );
271    }
272}