Skip to main content

oxigdal_workflow/templates/
parameterization.rs

1//! Template parameterization system.
2
3use crate::error::{Result, WorkflowError};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Parameter type enumeration.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub enum ParameterType {
10    /// String parameter.
11    String,
12    /// Integer parameter.
13    Integer,
14    /// Float parameter.
15    Float,
16    /// Boolean parameter.
17    Boolean,
18    /// Array parameter.
19    Array,
20    /// Object parameter.
21    Object,
22    /// File path parameter.
23    FilePath,
24    /// URL parameter.
25    Url,
26    /// Enum parameter (with allowed values).
27    Enum {
28        /// List of allowed values for this enum.
29        allowed_values: Vec<String>,
30    },
31}
32
33/// Parameter definition.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Parameter {
36    /// Parameter name.
37    pub name: String,
38    /// Parameter type.
39    pub param_type: ParameterType,
40    /// Parameter description.
41    pub description: String,
42    /// Whether the parameter is required.
43    pub required: bool,
44    /// Default value (if not required).
45    pub default_value: Option<ParameterValue>,
46    /// Parameter constraints.
47    pub constraints: Option<ParameterConstraints>,
48}
49
50/// Parameter value.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(untagged)]
53pub enum ParameterValue {
54    /// String value.
55    String(String),
56    /// Integer value.
57    Integer(i64),
58    /// Float value.
59    Float(f64),
60    /// Boolean value.
61    Boolean(bool),
62    /// Array value.
63    Array(Vec<ParameterValue>),
64    /// Object value.
65    Object(HashMap<String, ParameterValue>),
66}
67
68impl ParameterValue {
69    /// Convert to JSON value.
70    pub fn to_json(&self) -> serde_json::Value {
71        match self {
72            Self::String(s) => serde_json::Value::String(s.clone()),
73            Self::Integer(i) => serde_json::Value::Number((*i).into()),
74            Self::Float(f) => serde_json::Number::from_f64(*f)
75                .map(serde_json::Value::Number)
76                .unwrap_or(serde_json::Value::Null),
77            Self::Boolean(b) => serde_json::Value::Bool(*b),
78            Self::Array(arr) => serde_json::Value::Array(arr.iter().map(|v| v.to_json()).collect()),
79            Self::Object(obj) => {
80                let mut map = serde_json::Map::new();
81                for (k, v) in obj {
82                    map.insert(k.clone(), v.to_json());
83                }
84                serde_json::Value::Object(map)
85            }
86        }
87    }
88
89    /// Get as string (if applicable).
90    pub fn as_string(&self) -> Option<&str> {
91        if let Self::String(s) = self {
92            Some(s)
93        } else {
94            None
95        }
96    }
97
98    /// Get as integer (if applicable).
99    pub fn as_integer(&self) -> Option<i64> {
100        if let Self::Integer(i) = self {
101            Some(*i)
102        } else {
103            None
104        }
105    }
106
107    /// Get as float (if applicable).
108    pub fn as_float(&self) -> Option<f64> {
109        if let Self::Float(f) = self {
110            Some(*f)
111        } else {
112            None
113        }
114    }
115
116    /// Get as boolean (if applicable).
117    pub fn as_boolean(&self) -> Option<bool> {
118        if let Self::Boolean(b) = self {
119            Some(*b)
120        } else {
121            None
122        }
123    }
124}
125
126/// Parameter constraints.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ParameterConstraints {
129    /// Minimum value (for numeric types).
130    pub min: Option<f64>,
131    /// Maximum value (for numeric types).
132    pub max: Option<f64>,
133    /// Minimum length (for strings/arrays).
134    pub min_length: Option<usize>,
135    /// Maximum length (for strings/arrays).
136    pub max_length: Option<usize>,
137    /// Regex pattern (for strings).
138    pub pattern: Option<String>,
139}
140
141/// Template parameterizer for applying parameters to templates.
142pub struct TemplateParameterizer {
143    placeholder_prefix: String,
144    placeholder_suffix: String,
145}
146
147impl TemplateParameterizer {
148    /// Create a new template parameterizer.
149    pub fn new() -> Self {
150        Self {
151            placeholder_prefix: "{{".to_string(),
152            placeholder_suffix: "}}".to_string(),
153        }
154    }
155
156    /// Create a parameterizer with custom placeholder markers.
157    pub fn with_markers<S: Into<String>>(prefix: S, suffix: S) -> Self {
158        Self {
159            placeholder_prefix: prefix.into(),
160            placeholder_suffix: suffix.into(),
161        }
162    }
163
164    /// Apply parameters to a template string.
165    pub fn apply_parameters(
166        &self,
167        template: &str,
168        params: &HashMap<String, ParameterValue>,
169    ) -> Result<String> {
170        let mut result = template.to_string();
171
172        for (name, value) in params {
173            let placeholder = format!(
174                "{}{}{}",
175                self.placeholder_prefix, name, self.placeholder_suffix
176            );
177
178            let replacement = match value {
179                ParameterValue::String(s) => s.clone(),
180                ParameterValue::Integer(i) => i.to_string(),
181                ParameterValue::Float(f) => f.to_string(),
182                ParameterValue::Boolean(b) => b.to_string(),
183                ParameterValue::Array(_) | ParameterValue::Object(_) => {
184                    serde_json::to_string(&value.to_json()).map_err(|e| {
185                        WorkflowError::template(format!("Failed to serialize value: {}", e))
186                    })?
187                }
188            };
189
190            result = result.replace(&placeholder, &replacement);
191        }
192
193        // Check for unreplaced placeholders.
194        //
195        // We inspect the *source* template rather than the substituted result: a
196        // blind substring scan of the output false-positives whenever a parameter
197        // value legitimately contains the placeholder markers (e.g. a JSON value
198        // with literal `{{`/`}}`), and scanning the result would also mistake such
199        // literal braces for a dangling placeholder. Extracting the paired
200        // placeholders from the original template and checking them against the
201        // supplied parameters detects genuinely missing values without those
202        // false positives.
203        let missing: Vec<String> = self
204            .extract_placeholders(template)
205            .into_iter()
206            .filter(|name| !params.contains_key(name))
207            .collect();
208        if !missing.is_empty() {
209            return Err(WorkflowError::template(format!(
210                "Template contains unreplaced placeholders: {}",
211                missing.join(", ")
212            )));
213        }
214
215        Ok(result)
216    }
217
218    /// Extract placeholders from a template.
219    pub fn extract_placeholders(&self, template: &str) -> Vec<String> {
220        let mut placeholders = Vec::new();
221        let mut start_pos = 0;
222
223        while let Some(start) = template[start_pos..].find(&self.placeholder_prefix) {
224            let absolute_start = start_pos + start + self.placeholder_prefix.len();
225
226            if let Some(end) = template[absolute_start..].find(&self.placeholder_suffix) {
227                let placeholder = template[absolute_start..absolute_start + end].to_string();
228                if !placeholders.contains(&placeholder) {
229                    placeholders.push(placeholder);
230                }
231                start_pos = absolute_start + end + self.placeholder_suffix.len();
232            } else {
233                break;
234            }
235        }
236
237        placeholders
238    }
239
240    /// Validate that all placeholders can be filled.
241    pub fn validate_coverage(
242        &self,
243        template: &str,
244        params: &HashMap<String, ParameterValue>,
245    ) -> Result<()> {
246        let placeholders = self.extract_placeholders(template);
247
248        for placeholder in placeholders {
249            if !params.contains_key(&placeholder) {
250                return Err(WorkflowError::template(format!(
251                    "Missing parameter value for placeholder '{}'",
252                    placeholder
253                )));
254            }
255        }
256
257        Ok(())
258    }
259}
260
261impl Default for TemplateParameterizer {
262    fn default() -> Self {
263        Self::new()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_parameter_value_conversions() {
273        let string_val = ParameterValue::String("test".to_string());
274        assert_eq!(string_val.as_string(), Some("test"));
275
276        let int_val = ParameterValue::Integer(42);
277        assert_eq!(int_val.as_integer(), Some(42));
278
279        let bool_val = ParameterValue::Boolean(true);
280        assert_eq!(bool_val.as_boolean(), Some(true));
281    }
282
283    #[test]
284    fn test_parameterizer_apply() {
285        let parameterizer = TemplateParameterizer::new();
286        let template = r#"{"name": "{{workflow_name}}", "version": "{{version}}"}"#;
287
288        let mut params = HashMap::new();
289        params.insert(
290            "workflow_name".to_string(),
291            ParameterValue::String("test-workflow".to_string()),
292        );
293        params.insert(
294            "version".to_string(),
295            ParameterValue::String("1.0.0".to_string()),
296        );
297
298        let result = parameterizer
299            .apply_parameters(template, &params)
300            .expect("Failed to apply parameters");
301
302        assert!(result.contains("test-workflow"));
303        assert!(result.contains("1.0.0"));
304    }
305
306    #[test]
307    fn test_extract_placeholders() {
308        let parameterizer = TemplateParameterizer::new();
309        let template = "Hello {{name}}, your age is {{age}}";
310
311        let placeholders = parameterizer.extract_placeholders(template);
312
313        assert_eq!(placeholders.len(), 2);
314        assert!(placeholders.contains(&"name".to_string()));
315        assert!(placeholders.contains(&"age".to_string()));
316    }
317
318    #[test]
319    fn test_validate_coverage() {
320        let parameterizer = TemplateParameterizer::new();
321        let template = "{{param1}} and {{param2}}";
322
323        let mut params = HashMap::new();
324        params.insert(
325            "param1".to_string(),
326            ParameterValue::String("value1".to_string()),
327        );
328
329        // Missing param2
330        assert!(parameterizer.validate_coverage(template, &params).is_err());
331
332        params.insert(
333            "param2".to_string(),
334            ParameterValue::String("value2".to_string()),
335        );
336
337        // Now should be ok
338        assert!(parameterizer.validate_coverage(template, &params).is_ok());
339    }
340
341    #[test]
342    fn test_custom_markers() {
343        let parameterizer = TemplateParameterizer::with_markers("${", "}");
344        let template = "Hello ${name}";
345
346        let mut params = HashMap::new();
347        params.insert(
348            "name".to_string(),
349            ParameterValue::String("World".to_string()),
350        );
351
352        let result = parameterizer
353            .apply_parameters(template, &params)
354            .expect("Failed to apply");
355
356        assert_eq!(result, "Hello World");
357    }
358
359    #[test]
360    fn test_apply_allows_value_with_literal_braces() {
361        // A substituted value that itself contains the placeholder markers must
362        // not be mistaken for an unreplaced placeholder.
363        let parameterizer = TemplateParameterizer::new();
364        let template = "value = {{payload}}";
365
366        let mut params = HashMap::new();
367        params.insert(
368            "payload".to_string(),
369            ParameterValue::String("literal {{brace}} text".to_string()),
370        );
371
372        let result = parameterizer
373            .apply_parameters(template, &params)
374            .expect("value containing braces should not be rejected");
375        assert_eq!(result, "value = literal {{brace}} text");
376    }
377
378    #[test]
379    fn test_apply_detects_missing_placeholder() {
380        // A genuinely unprovided placeholder must still be reported (by name).
381        let parameterizer = TemplateParameterizer::new();
382        let template = "{{provided}} and {{missing}}";
383
384        let mut params = HashMap::new();
385        params.insert(
386            "provided".to_string(),
387            ParameterValue::String("ok".to_string()),
388        );
389
390        let err = parameterizer
391            .apply_parameters(template, &params)
392            .expect_err("missing placeholder should error");
393        assert!(err.to_string().contains("missing"));
394    }
395
396    #[test]
397    fn test_apply_allows_array_value_with_braces() {
398        // JSON-encoded array/object values contain literal braces; they must pass.
399        let parameterizer = TemplateParameterizer::new();
400        let template = r#"{"items": {{items}}}"#;
401
402        let mut params = HashMap::new();
403        params.insert(
404            "items".to_string(),
405            ParameterValue::Array(vec![ParameterValue::Integer(1), ParameterValue::Integer(2)]),
406        );
407
408        let result = parameterizer
409            .apply_parameters(template, &params)
410            .expect("array value should not be rejected");
411        assert!(result.contains("[1,2]"));
412    }
413
414    #[test]
415    fn test_parameter_value_to_json() {
416        let value = ParameterValue::Integer(42);
417        let json = value.to_json();
418        assert_eq!(json, serde_json::json!(42));
419
420        let value = ParameterValue::Boolean(true);
421        let json = value.to_json();
422        assert_eq!(json, serde_json::json!(true));
423    }
424}