Skip to main content

rustant_core/workflow/
templates.rs

1//! Template expression engine for workflow parameter substitution.
2//!
3//! Supports `{{ inputs.name }}` and `{{ steps.step_id.output }}` style
4//! expressions, and simple condition evaluation for conditional steps.
5
6use crate::error::WorkflowError;
7use serde_json::Value;
8use std::collections::HashMap;
9
10/// Context for template rendering, containing available variable values.
11pub struct TemplateContext {
12    pub inputs: HashMap<String, Value>,
13    pub step_outputs: HashMap<String, Value>,
14}
15
16impl TemplateContext {
17    pub fn new(inputs: HashMap<String, Value>, step_outputs: HashMap<String, Value>) -> Self {
18        Self {
19            inputs,
20            step_outputs,
21        }
22    }
23}
24
25/// Render template expressions in a string value, replacing `{{ ... }}` patterns.
26pub fn render_string(template: &str, ctx: &TemplateContext) -> Result<String, WorkflowError> {
27    let mut result = String::new();
28    let mut rest = template;
29
30    while let Some(start) = rest.find("{{") {
31        result.push_str(&rest[..start]);
32        let after_open = &rest[start + 2..];
33        let end = after_open
34            .find("}}")
35            .ok_or_else(|| WorkflowError::TemplateError {
36                message: format!("Unclosed template expression in: {}", template),
37            })?;
38        let expr = after_open[..end].trim();
39        let value = resolve_expression(expr, ctx)?;
40        result.push_str(&value_to_string(&value));
41        rest = &after_open[end + 2..];
42    }
43    result.push_str(rest);
44
45    Ok(result)
46}
47
48/// Render template expressions within a JSON Value, recursively processing
49/// strings, objects, and arrays.
50pub fn render_value(value: &Value, ctx: &TemplateContext) -> Result<Value, WorkflowError> {
51    match value {
52        Value::String(s) => {
53            if s.contains("{{") {
54                let rendered = render_string(s, ctx)?;
55                Ok(Value::String(rendered))
56            } else {
57                Ok(value.clone())
58            }
59        }
60        Value::Object(map) => {
61            let mut new_map = serde_json::Map::new();
62            for (k, v) in map {
63                new_map.insert(k.clone(), render_value(v, ctx)?);
64            }
65            Ok(Value::Object(new_map))
66        }
67        Value::Array(arr) => {
68            let new_arr: Result<Vec<Value>, WorkflowError> =
69                arr.iter().map(|v| render_value(v, ctx)).collect();
70            Ok(Value::Array(new_arr?))
71        }
72        _ => Ok(value.clone()),
73    }
74}
75
76/// Evaluate a condition expression, returning true/false.
77///
78/// Supports simple comparisons: `==`, `!=`
79pub fn evaluate_condition(condition: &str, ctx: &TemplateContext) -> Result<bool, WorkflowError> {
80    let rendered = render_string(condition, ctx)?;
81
82    if let Some((left, right)) = rendered.split_once("!=") {
83        let left = left.trim().trim_matches('\'').trim_matches('"');
84        let right = right.trim().trim_matches('\'').trim_matches('"');
85        return Ok(left != right);
86    }
87
88    if let Some((left, right)) = rendered.split_once("==") {
89        let left = left.trim().trim_matches('\'').trim_matches('"');
90        let right = right.trim().trim_matches('\'').trim_matches('"');
91        return Ok(left == right);
92    }
93
94    // Truthy check: non-empty, non-"false", non-"0" strings are true
95    let trimmed = rendered.trim();
96    Ok(!trimmed.is_empty() && trimmed != "false" && trimmed != "0")
97}
98
99/// Resolve a dotted expression like `inputs.path` or `steps.fetch_pr.output`.
100fn resolve_expression(expr: &str, ctx: &TemplateContext) -> Result<Value, WorkflowError> {
101    let parts: Vec<&str> = expr.splitn(3, '.').collect();
102
103    match parts.first() {
104        Some(&"inputs") => {
105            let key = parts.get(1).ok_or_else(|| WorkflowError::TemplateError {
106                message: format!("Invalid input reference: {}", expr),
107            })?;
108            ctx.inputs
109                .get(*key)
110                .cloned()
111                .ok_or_else(|| WorkflowError::TemplateError {
112                    message: format!("Input '{}' not found", key),
113                })
114        }
115        Some(&"steps") => {
116            let step_id = parts.get(1).ok_or_else(|| WorkflowError::TemplateError {
117                message: format!("Invalid step reference: {}", expr),
118            })?;
119            // Accept both `steps.id.output` and just `steps.id`
120            ctx.step_outputs
121                .get(*step_id)
122                .cloned()
123                .ok_or_else(|| WorkflowError::TemplateError {
124                    message: format!("Step output '{}' not found", step_id),
125                })
126        }
127        _ => Err(WorkflowError::TemplateError {
128            message: format!("Unknown template variable: {}", expr),
129        }),
130    }
131}
132
133/// Convert a JSON Value to its string representation for template insertion.
134fn value_to_string(value: &Value) -> String {
135    match value {
136        Value::String(s) => s.clone(),
137        Value::Null => String::new(),
138        other => other.to_string(),
139    }
140}
141
142/// Extract all template variable references from a string.
143/// Returns pairs like `("inputs", "path")` or `("steps", "fetch_pr")`.
144pub fn extract_references(template: &str) -> Vec<(String, String)> {
145    let mut refs = Vec::new();
146    let mut rest = template;
147
148    while let Some(start) = rest.find("{{") {
149        let after_open = &rest[start + 2..];
150        if let Some(end) = after_open.find("}}") {
151            let expr = after_open[..end].trim();
152            let parts: Vec<&str> = expr.splitn(3, '.').collect();
153            if parts.len() >= 2 {
154                refs.push((parts[0].to_string(), parts[1].to_string()));
155            }
156            rest = &after_open[end + 2..];
157        } else {
158            break;
159        }
160    }
161
162    refs
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn make_ctx(inputs: Vec<(&str, &str)>, step_outputs: Vec<(&str, &str)>) -> TemplateContext {
170        let inputs_map: HashMap<String, Value> = inputs
171            .into_iter()
172            .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
173            .collect();
174        let step_map: HashMap<String, Value> = step_outputs
175            .into_iter()
176            .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
177            .collect();
178        TemplateContext::new(inputs_map, step_map)
179    }
180
181    #[test]
182    fn test_render_simple_substitution() {
183        let ctx = make_ctx(vec![("path", "/home/user/file.rs")], vec![]);
184        let result = render_string("{{ inputs.path }}", &ctx).unwrap();
185        assert_eq!(result, "/home/user/file.rs");
186    }
187
188    #[test]
189    fn test_render_step_output_reference() {
190        let ctx = make_ctx(vec![], vec![("read_file", "file contents here")]);
191        let result = render_string("Content: {{ steps.read_file.output }}", &ctx).unwrap();
192        assert_eq!(result, "Content: file contents here");
193    }
194
195    #[test]
196    fn test_render_no_substitution_needed() {
197        let ctx = make_ctx(vec![], vec![]);
198        let result = render_string("plain text with no templates", &ctx).unwrap();
199        assert_eq!(result, "plain text with no templates");
200    }
201
202    #[test]
203    fn test_render_missing_variable_returns_error() {
204        let ctx = make_ctx(vec![], vec![]);
205        let result = render_string("{{ inputs.missing }}", &ctx);
206        assert!(result.is_err());
207        let err = result.unwrap_err();
208        assert!(err.to_string().contains("not found"));
209    }
210
211    #[test]
212    fn test_render_nested_json_value() {
213        let ctx = make_ctx(vec![("url", "https://example.com")], vec![]);
214        let value = serde_json::json!({
215            "url": "{{ inputs.url }}",
216            "headers": {
217                "host": "{{ inputs.url }}"
218            }
219        });
220        let rendered = render_value(&value, &ctx).unwrap();
221        assert_eq!(rendered["url"].as_str().unwrap(), "https://example.com");
222        assert_eq!(
223            rendered["headers"]["host"].as_str().unwrap(),
224            "https://example.com"
225        );
226    }
227
228    #[test]
229    fn test_evaluate_condition_true() {
230        let ctx = make_ctx(vec![], vec![("check", "pass")]);
231        let result = evaluate_condition("{{ steps.check.output }} == 'pass'", &ctx).unwrap();
232        assert!(result);
233    }
234
235    #[test]
236    fn test_evaluate_condition_false() {
237        let ctx = make_ctx(vec![], vec![("check", "fail")]);
238        let result = evaluate_condition("{{ steps.check.output }} == 'pass'", &ctx).unwrap();
239        assert!(!result);
240    }
241
242    #[test]
243    fn test_evaluate_condition_not_equals() {
244        let ctx = make_ctx(vec![], vec![("check", "fail")]);
245        let result = evaluate_condition("{{ steps.check.output }} != 'pass'", &ctx).unwrap();
246        assert!(result);
247
248        let ctx2 = make_ctx(vec![], vec![("check", "pass")]);
249        let result2 = evaluate_condition("{{ steps.check.output }} != 'pass'", &ctx2).unwrap();
250        assert!(!result2);
251    }
252}