Skip to main content

aperture_cli/batch/
interpolation.rs

1//! Variable interpolation engine for batch operation arguments.
2//!
3//! Replaces `{{variable}}` placeholders in operation argument strings with
4//! values from the variable store. Scalar variables produce their string
5//! value; list variables (from `capture_append`) produce a JSON array literal.
6
7use crate::error::Error;
8use std::collections::HashMap;
9
10/// Combined variable store holding both scalar and list captures.
11#[derive(Debug, Default)]
12pub struct VariableStore {
13    /// Scalar variables captured via `capture`.
14    pub scalars: HashMap<String, String>,
15    /// List variables accumulated via `capture_append`.
16    pub lists: HashMap<String, Vec<String>>,
17}
18
19impl VariableStore {
20    /// Resolves a variable name to its interpolation value.
21    ///
22    /// - Scalar variables return their string value directly.
23    /// - List variables return a JSON array literal (e.g. `["a","b"]`).
24    /// - Returns `None` if the variable is not defined.
25    fn resolve(&self, name: &str) -> Option<String> {
26        if let Some(scalar) = self.scalars.get(name) {
27            return Some(scalar.clone());
28        }
29        if let Some(list) = self.lists.get(name) {
30            let json_array = serde_json::to_string(list)
31                .expect("serializing Vec<String> to JSON should never fail");
32            return Some(json_array);
33        }
34        None
35    }
36}
37
38/// Interpolates `{{variable}}` references in an argument string.
39///
40/// Returns the string with all placeholders replaced, or an error if any
41/// referenced variable is undefined.
42fn interpolate_arg(arg: &str, store: &VariableStore, operation_id: &str) -> Result<String, Error> {
43    let mut result = String::with_capacity(arg.len());
44    let mut remaining = arg;
45
46    while let Some(start) = remaining.find("{{") {
47        result.push_str(&remaining[..start]);
48        let after_open = &remaining[start + 2..];
49
50        let Some(end) = after_open.find("}}") else {
51            // Unclosed brace — treat as literal
52            result.push_str("{{");
53            remaining = after_open;
54            continue;
55        };
56
57        let var_name = &after_open[..end];
58        let value = store
59            .resolve(var_name)
60            .ok_or_else(|| Error::batch_undefined_variable(operation_id, var_name))?;
61
62        result.push_str(&value);
63        remaining = &after_open[end + 2..];
64    }
65
66    result.push_str(remaining);
67    Ok(result)
68}
69
70/// Interpolates `{{variable}}` references in a single string.
71///
72/// # Errors
73///
74/// Returns an error if the string references an undefined variable.
75pub fn interpolate_string(
76    s: &str,
77    store: &VariableStore,
78    operation_id: &str,
79) -> Result<String, Error> {
80    interpolate_arg(s, store, operation_id)
81}
82
83/// Interpolates all `{{variable}}` references in a list of arguments.
84///
85/// # Errors
86///
87/// Returns an error if any argument references an undefined variable.
88pub fn interpolate_args(
89    args: &[String],
90    store: &VariableStore,
91    operation_id: &str,
92) -> Result<Vec<String>, Error> {
93    args.iter()
94        .map(|arg| interpolate_arg(arg, store, operation_id))
95        .collect()
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn store_with_scalar(name: &str, value: &str) -> VariableStore {
103        let mut store = VariableStore::default();
104        store.scalars.insert(name.into(), value.into());
105        store
106    }
107
108    fn store_with_list(name: &str, values: &[&str]) -> VariableStore {
109        let mut store = VariableStore::default();
110        store.lists.insert(
111            name.into(),
112            values.iter().map(|s| (*s).to_string()).collect(),
113        );
114        store
115    }
116
117    #[test]
118    fn scalar_interpolation() {
119        let store = store_with_scalar("user_id", "abc-123");
120        let args = vec!["--user-id".into(), "{{user_id}}".into()];
121        let result = interpolate_args(&args, &store, "test-op").unwrap();
122        assert_eq!(result, vec!["--user-id", "abc-123"]);
123    }
124
125    #[test]
126    fn scalar_embedded_in_string() {
127        let store = store_with_scalar("id", "42");
128        let args = vec!["prefix-{{id}}-suffix".into()];
129        let result = interpolate_args(&args, &store, "test-op").unwrap();
130        assert_eq!(result, vec!["prefix-42-suffix"]);
131    }
132
133    #[test]
134    fn multiple_variables_in_single_arg() {
135        let mut store = VariableStore::default();
136        store.scalars.insert("a".into(), "1".into());
137        store.scalars.insert("b".into(), "2".into());
138        let args = vec!["{{a}}-{{b}}".into()];
139        let result = interpolate_args(&args, &store, "test-op").unwrap();
140        assert_eq!(result, vec!["1-2"]);
141    }
142
143    #[test]
144    fn list_interpolation_as_json_array() {
145        let store = store_with_list("ids", &["id-a", "id-b", "id-c"]);
146        let args = vec!["{\"eventIds\": {{ids}}}".into()];
147        let result = interpolate_args(&args, &store, "test-op").unwrap();
148        assert_eq!(result, vec![r#"{"eventIds": ["id-a","id-b","id-c"]}"#]);
149    }
150
151    #[test]
152    fn list_interpolation_escapes_json_elements() {
153        let store = store_with_list("ids", &["a\"b", "line\nbreak"]);
154        let args = vec!["{\"eventIds\": {{ids}}}".into()];
155
156        let result = interpolate_args(&args, &store, "test-op").unwrap();
157        let parsed: serde_json::Value = serde_json::from_str(&result[0]).unwrap();
158
159        assert_eq!(parsed["eventIds"][0], "a\"b");
160        assert_eq!(parsed["eventIds"][1], "line\nbreak");
161    }
162
163    #[test]
164    fn empty_list_interpolates_as_empty_array() {
165        let store = store_with_list("ids", &[]);
166        let args = vec!["{{ids}}".into()];
167        let result = interpolate_args(&args, &store, "test-op").unwrap();
168        assert_eq!(result, vec!["[]"]);
169    }
170
171    #[test]
172    fn undefined_variable_produces_error() {
173        let store = VariableStore::default();
174        let args = vec!["{{missing}}".into()];
175        let result = interpolate_args(&args, &store, "my-op");
176        assert!(result.is_err());
177        let err = result.unwrap_err().to_string();
178        assert!(err.contains("missing"), "expected var name, got: {err}");
179        assert!(err.contains("my-op"), "expected op id, got: {err}");
180    }
181
182    #[test]
183    fn no_variables_passthrough() {
184        let store = VariableStore::default();
185        let args = vec!["--flag".into(), "value".into()];
186        let result = interpolate_args(&args, &store, "test-op").unwrap();
187        assert_eq!(result, vec!["--flag", "value"]);
188    }
189
190    #[test]
191    fn unclosed_brace_treated_as_literal() {
192        let store = VariableStore::default();
193        let args = vec!["{{unclosed".into()];
194        let result = interpolate_args(&args, &store, "test-op").unwrap();
195        assert_eq!(result, vec!["{{unclosed"]);
196    }
197
198    #[test]
199    fn scalar_takes_precedence_over_list() {
200        let mut store = VariableStore::default();
201        store.scalars.insert("x".into(), "scalar-val".into());
202        store.lists.insert("x".into(), vec!["list-val".into()]);
203        let args = vec!["{{x}}".into()];
204        let result = interpolate_args(&args, &store, "test-op").unwrap();
205        assert_eq!(result, vec!["scalar-val"]);
206    }
207}