ricecoder_hooks/executor/
substitution.rs

1//! Variable substitution engine for hooks
2//!
3//! This module provides variable substitution functionality for hook actions.
4//! It supports placeholder parsing, variable lookup in event context, and nested paths.
5//!
6//! # Examples
7//!
8//! Basic variable substitution:
9//!
10//! ```ignore
11//! use ricecoder_hooks::executor::VariableSubstitutor;
12//! use ricecoder_hooks::types::EventContext;
13//! use serde_json::json;
14//!
15//! let context = EventContext {
16//!     data: json!({
17//!         "file_path": "/path/to/file.rs",
18//!         "size": 1024,
19//!     }),
20//!     metadata: json!({}),
21//! };
22//!
23//! let template = "Processing {{file_path}} ({{size}} bytes)";
24//! let result = VariableSubstitutor::substitute(template, &context)?;
25//! assert_eq!(result, "Processing /path/to/file.rs (1024 bytes)");
26//! ```
27//!
28//! Nested path substitution:
29//!
30//! ```ignore
31//! let context = EventContext {
32//!     data: json!({
33//!         "metadata": {
34//!             "size": 2048,
35//!             "hash": "abc123",
36//!         }
37//!     }),
38//!     metadata: json!({}),
39//! };
40//!
41//! let template = "File size: {{metadata.size}}, hash: {{metadata.hash}}";
42//! let result = VariableSubstitutor::substitute(template, &context)?;
43//! assert_eq!(result, "File size: 2048, hash: abc123");
44//! ```
45
46use crate::error::{HooksError, Result};
47use crate::types::EventContext;
48use regex::Regex;
49use serde_json::Value;
50use std::sync::OnceLock;
51
52/// Variable substitution engine
53///
54/// Provides methods for substituting variables in templates using event context.
55/// Supports:
56/// - Simple variable substitution: `{{variable_name}}`
57/// - Nested path substitution: `{{metadata.size}}`
58/// - Literal text with variables: `"path/{{file_path}}/subdir"`
59/// - Error handling for missing variables
60pub struct VariableSubstitutor;
61
62impl VariableSubstitutor {
63    /// Substitute variables in a template string
64    ///
65    /// Replaces all `{{variable_name}}` placeholders with values from the event context.
66    /// Supports nested paths like `{{metadata.size}}`.
67    ///
68    /// # Arguments
69    ///
70    /// * `template` - Template string with placeholders
71    /// * `context` - Event context containing variables
72    ///
73    /// # Returns
74    ///
75    /// Substituted string or error if variables are missing
76    ///
77    /// # Examples
78    ///
79    /// ```ignore
80    /// let context = EventContext {
81    ///     data: json!({"file_path": "/path/to/file.rs"}),
82    ///     metadata: json!({}),
83    /// };
84    /// let result = VariableSubstitutor::substitute("File: {{file_path}}", &context)?;
85    /// assert_eq!(result, "File: /path/to/file.rs");
86    /// ```
87    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    /// Substitute variables in a JSON value
106    ///
107    /// If the value is a string, performs variable substitution.
108    /// If the value is an object or array, recursively substitutes in all string values.
109    /// Other types are returned unchanged.
110    ///
111    /// # Arguments
112    ///
113    /// * `value` - JSON value to substitute
114    /// * `context` - Event context containing variables
115    ///
116    /// # Returns
117    ///
118    /// Substituted JSON value or error if variables are missing
119    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    /// Look up a variable in the event context
144    ///
145    /// Supports nested paths using dot notation (e.g., `metadata.size`).
146    /// First looks in `context.data`, then in `context.metadata`.
147    ///
148    /// # Arguments
149    ///
150    /// * `var_name` - Variable name (supports dot notation for nested paths)
151    /// * `context` - Event context
152    ///
153    /// # Returns
154    ///
155    /// JSON value or error if variable not found
156    fn lookup_variable(var_name: &str, context: &EventContext) -> Result<Value> {
157        // Try to find in data first
158        if let Some(value) = Self::lookup_in_value(var_name, &context.data) {
159            return Ok(value);
160        }
161
162        // Try to find in metadata
163        if let Some(value) = Self::lookup_in_value(var_name, &context.metadata) {
164            return Ok(value);
165        }
166
167        // Variable not found
168        Err(HooksError::ExecutionFailed(format!(
169            "Variable not found in context: {}",
170            var_name
171        )))
172    }
173
174    /// Look up a variable in a JSON value using dot notation
175    ///
176    /// Supports nested paths like `metadata.size` or `user.profile.name`.
177    ///
178    /// # Arguments
179    ///
180    /// * `path` - Variable path (dot-separated)
181    /// * `value` - JSON value to search in
182    ///
183    /// # Returns
184    ///
185    /// JSON value if found, None otherwise
186    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    /// Convert a JSON value to a string
198    ///
199    /// Handles different JSON types appropriately:
200    /// - Strings: returned as-is
201    /// - Numbers: converted to string representation
202    /// - Booleans: converted to "true" or "false"
203    /// - Null: converted to "null"
204    /// - Objects/Arrays: converted to JSON string
205    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
216/// Get or create the placeholder regex
217///
218/// Uses OnceLock to compile the regex only once for performance.
219fn get_placeholder_regex() -> &'static Regex {
220    static REGEX: OnceLock<Regex> = OnceLock::new();
221    REGEX.get_or_init(|| {
222        // Matches {{variable_name}} or {{nested.path}}
223        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}