aperture_cli/batch/
interpolation.rs1use crate::error::Error;
8use std::collections::HashMap;
9
10#[derive(Debug, Default)]
12pub struct VariableStore {
13 pub scalars: HashMap<String, String>,
15 pub lists: HashMap<String, Vec<String>>,
17}
18
19impl VariableStore {
20 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
38fn 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 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
70pub 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
83pub 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}