ricecoder_hooks/executor/
substitution.rs1use crate::error::{HooksError, Result};
47use crate::types::EventContext;
48use regex::Regex;
49use serde_json::Value;
50use std::sync::OnceLock;
51
52pub struct VariableSubstitutor;
61
62impl VariableSubstitutor {
63 pub fn substitute(template: &str, context: &EventContext) -> Result<String> {
88 let placeholder_regex = get_placeholder_regex();
89
90 let mut result = template.to_string();
91
92 for cap in placeholder_regex.captures_iter(template) {
93 let full_match = cap.get(0).unwrap().as_str();
94 let var_name = cap.get(1).unwrap().as_str();
95
96 let value = Self::lookup_variable(var_name, context)?;
97 let value_str = Self::value_to_string(&value);
98
99 result = result.replace(full_match, &value_str);
100 }
101
102 Ok(result)
103 }
104
105 pub fn substitute_json(value: &Value, context: &EventContext) -> Result<Value> {
120 match value {
121 Value::String(s) => {
122 let substituted = Self::substitute(s, context)?;
123 Ok(Value::String(substituted))
124 }
125 Value::Object(map) => {
126 let mut result = serde_json::Map::new();
127 for (key, val) in map {
128 result.insert(key.clone(), Self::substitute_json(val, context)?);
129 }
130 Ok(Value::Object(result))
131 }
132 Value::Array(arr) => {
133 let result: Result<Vec<Value>> = arr
134 .iter()
135 .map(|v| Self::substitute_json(v, context))
136 .collect();
137 Ok(Value::Array(result?))
138 }
139 other => Ok(other.clone()),
140 }
141 }
142
143 fn lookup_variable(var_name: &str, context: &EventContext) -> Result<Value> {
157 if let Some(value) = Self::lookup_in_value(var_name, &context.data) {
159 return Ok(value);
160 }
161
162 if let Some(value) = Self::lookup_in_value(var_name, &context.metadata) {
164 return Ok(value);
165 }
166
167 Err(HooksError::ExecutionFailed(format!(
169 "Variable not found in context: {}",
170 var_name
171 )))
172 }
173
174 fn lookup_in_value(path: &str, value: &Value) -> Option<Value> {
187 let parts: Vec<&str> = path.split('.').collect();
188
189 let mut current = value;
190 for part in parts {
191 current = current.get(part)?;
192 }
193
194 Some(current.clone())
195 }
196
197 fn value_to_string(value: &Value) -> String {
206 match value {
207 Value::String(s) => s.clone(),
208 Value::Number(n) => n.to_string(),
209 Value::Bool(b) => b.to_string(),
210 Value::Null => "null".to_string(),
211 Value::Array(_) | Value::Object(_) => value.to_string(),
212 }
213 }
214}
215
216fn get_placeholder_regex() -> &'static Regex {
220 static REGEX: OnceLock<Regex> = OnceLock::new();
221 REGEX.get_or_init(|| {
222 Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_\.]*)\}\}").expect("Invalid regex")
224 })
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use serde_json::json;
231
232 fn create_test_context() -> EventContext {
233 EventContext {
234 data: json!({
235 "file_path": "/path/to/file.rs",
236 "size": 1024,
237 "hash": "abc123def456",
238 "metadata": {
239 "created": "2024-01-01",
240 "modified": "2024-01-02",
241 "nested": {
242 "deep": "value"
243 }
244 }
245 }),
246 metadata: json!({
247 "user": "alice",
248 "project": "my-project"
249 }),
250 }
251 }
252
253 #[test]
254 fn test_substitute_simple_variable() {
255 let context = create_test_context();
256 let template = "File: {{file_path}}";
257 let result = VariableSubstitutor::substitute(template, &context).unwrap();
258 assert_eq!(result, "File: /path/to/file.rs");
259 }
260
261 #[test]
262 fn test_substitute_multiple_variables() {
263 let context = create_test_context();
264 let template = "File {{file_path}} is {{size}} bytes";
265 let result = VariableSubstitutor::substitute(template, &context).unwrap();
266 assert_eq!(result, "File /path/to/file.rs is 1024 bytes");
267 }
268
269 #[test]
270 fn test_substitute_nested_path() {
271 let context = create_test_context();
272 let template = "Created: {{metadata.created}}, Modified: {{metadata.modified}}";
273 let result = VariableSubstitutor::substitute(template, &context).unwrap();
274 assert_eq!(result, "Created: 2024-01-01, Modified: 2024-01-02");
275 }
276
277 #[test]
278 fn test_substitute_deeply_nested_path() {
279 let context = create_test_context();
280 let template = "Deep value: {{metadata.nested.deep}}";
281 let result = VariableSubstitutor::substitute(template, &context).unwrap();
282 assert_eq!(result, "Deep value: value");
283 }
284
285 #[test]
286 fn test_substitute_from_metadata() {
287 let context = create_test_context();
288 let template = "User: {{user}}, Project: {{project}}";
289 let result = VariableSubstitutor::substitute(template, &context).unwrap();
290 assert_eq!(result, "User: alice, Project: my-project");
291 }
292
293 #[test]
294 fn test_substitute_missing_variable() {
295 let context = create_test_context();
296 let template = "File: {{missing_var}}";
297 let result = VariableSubstitutor::substitute(template, &context);
298 assert!(result.is_err());
299 assert!(result.unwrap_err().to_string().contains("not found"));
300 }
301
302 #[test]
303 fn test_substitute_missing_nested_path() {
304 let context = create_test_context();
305 let template = "Value: {{metadata.missing.path}}";
306 let result = VariableSubstitutor::substitute(template, &context);
307 assert!(result.is_err());
308 }
309
310 #[test]
311 fn test_substitute_no_variables() {
312 let context = create_test_context();
313 let template = "No variables here";
314 let result = VariableSubstitutor::substitute(template, &context).unwrap();
315 assert_eq!(result, "No variables here");
316 }
317
318 #[test]
319 fn test_substitute_empty_template() {
320 let context = create_test_context();
321 let template = "";
322 let result = VariableSubstitutor::substitute(template, &context).unwrap();
323 assert_eq!(result, "");
324 }
325
326 #[test]
327 fn test_substitute_number_variable() {
328 let context = create_test_context();
329 let template = "Size: {{size}} bytes";
330 let result = VariableSubstitutor::substitute(template, &context).unwrap();
331 assert_eq!(result, "Size: 1024 bytes");
332 }
333
334 #[test]
335 fn test_substitute_json_string() {
336 let context = create_test_context();
337 let value = json!("File: {{file_path}}");
338 let result = VariableSubstitutor::substitute_json(&value, &context).unwrap();
339 assert_eq!(result, json!("File: /path/to/file.rs"));
340 }
341
342 #[test]
343 fn test_substitute_json_object() {
344 let context = create_test_context();
345 let value = json!({
346 "path": "{{file_path}}",
347 "size": "{{size}}"
348 });
349 let result = VariableSubstitutor::substitute_json(&value, &context).unwrap();
350 assert_eq!(
351 result,
352 json!({
353 "path": "/path/to/file.rs",
354 "size": "1024"
355 })
356 );
357 }
358
359 #[test]
360 fn test_substitute_json_array() {
361 let context = create_test_context();
362 let value = json!(["{{file_path}}", "{{size}}"]);
363 let result = VariableSubstitutor::substitute_json(&value, &context).unwrap();
364 assert_eq!(result, json!(["/path/to/file.rs", "1024"]));
365 }
366
367 #[test]
368 fn test_substitute_json_number_unchanged() {
369 let context = create_test_context();
370 let value = json!(42);
371 let result = VariableSubstitutor::substitute_json(&value, &context).unwrap();
372 assert_eq!(result, json!(42));
373 }
374
375 #[test]
376 fn test_substitute_json_nested_object() {
377 let context = create_test_context();
378 let value = json!({
379 "file": {
380 "path": "{{file_path}}",
381 "size": "{{size}}"
382 }
383 });
384 let result = VariableSubstitutor::substitute_json(&value, &context).unwrap();
385 assert_eq!(
386 result,
387 json!({
388 "file": {
389 "path": "/path/to/file.rs",
390 "size": "1024"
391 }
392 })
393 );
394 }
395
396 #[test]
397 fn test_substitute_same_variable_multiple_times() {
398 let context = create_test_context();
399 let template = "{{file_path}} and {{file_path}} again";
400 let result = VariableSubstitutor::substitute(template, &context).unwrap();
401 assert_eq!(result, "/path/to/file.rs and /path/to/file.rs again");
402 }
403
404 #[test]
405 fn test_substitute_variable_at_start() {
406 let context = create_test_context();
407 let template = "{{file_path}} is a file";
408 let result = VariableSubstitutor::substitute(template, &context).unwrap();
409 assert_eq!(result, "/path/to/file.rs is a file");
410 }
411
412 #[test]
413 fn test_substitute_variable_at_end() {
414 let context = create_test_context();
415 let template = "The file is {{file_path}}";
416 let result = VariableSubstitutor::substitute(template, &context).unwrap();
417 assert_eq!(result, "The file is /path/to/file.rs");
418 }
419
420 #[test]
421 fn test_substitute_variable_only() {
422 let context = create_test_context();
423 let template = "{{file_path}}";
424 let result = VariableSubstitutor::substitute(template, &context).unwrap();
425 assert_eq!(result, "/path/to/file.rs");
426 }
427
428 #[test]
429 fn test_substitute_with_special_characters() {
430 let mut context = create_test_context();
431 context.data = json!({
432 "path": "/path/with/special-chars_123.rs"
433 });
434 let template = "File: {{path}}";
435 let result = VariableSubstitutor::substitute(template, &context).unwrap();
436 assert_eq!(result, "File: /path/with/special-chars_123.rs");
437 }
438
439 #[test]
440 fn test_substitute_boolean_variable() {
441 let mut context = create_test_context();
442 context.data = json!({
443 "enabled": true
444 });
445 let template = "Enabled: {{enabled}}";
446 let result = VariableSubstitutor::substitute(template, &context).unwrap();
447 assert_eq!(result, "Enabled: true");
448 }
449
450 #[test]
451 fn test_substitute_null_variable() {
452 let mut context = create_test_context();
453 context.data = json!({
454 "value": null
455 });
456 let template = "Value: {{value}}";
457 let result = VariableSubstitutor::substitute(template, &context).unwrap();
458 assert_eq!(result, "Value: null");
459 }
460}