Skip to main content

nika_engine/io/
template.rs

1//! Template Resolver - Variable Interpolation for Artifact Paths
2//!
3//! Provides variable interpolation for artifact output paths.
4//! Supports task context, timestamps, and custom formats.
5//!
6//! # Supported Variables
7//!
8//! | Variable | Description | Example |
9//! |----------|-------------|---------|
10//! | `{{task_id}}` | Current task ID | `generate_report` |
11//! | `{{workflow_name}}` | Workflow name | `my-workflow` |
12//! | `{{workflow}}` | Alias for workflow_name | `my-workflow` |
13//! | `{{date}}` | Current date (ISO) | `2024-01-15` |
14//! | `{{time}}` | Current time (ISO) | `14-30-00` |
15//! | `{{timestamp}}` | Unix timestamp | `1705329000` |
16//! | `{{uuid}}` | Random UUID v4 | `550e8400-e29b-41d4-a716-446655440000` |
17//!
18//! # Example
19//!
20//! ```ignore
21//! use nika::io::template::TemplateResolver;
22//!
23//! let resolver = TemplateResolver::new("generate_report", "my-workflow");
24//! let path = resolver.resolve("{{task_id}}/{{date}}/output.json")?;
25//! // Returns: "generate_report/2024-01-15/output.json"
26//! ```
27
28use std::collections::HashMap;
29
30use chrono::{DateTime, Local};
31use uuid::Uuid;
32
33use crate::error::NikaError;
34
35/// Characters that are forbidden in custom variable values
36/// to prevent path traversal attacks
37const FORBIDDEN_VAR_CHARS: &[char] = &['/', '\\', '\0'];
38
39/// Patterns that are forbidden in custom variable values
40const FORBIDDEN_VAR_PATTERNS: &[&str] = &["..", "~"];
41
42/// Validate a custom variable value for security
43///
44/// Rejects values that could be used for path traversal attacks.
45fn validate_var_value(key: &str, value: &str) -> Result<(), NikaError> {
46    // Empty values are allowed (they just produce empty output)
47    if value.is_empty() {
48        return Ok(());
49    }
50
51    // Check for forbidden characters
52    for c in FORBIDDEN_VAR_CHARS {
53        if value.contains(*c) {
54            return Err(NikaError::TemplateError {
55                template: format!("{{{{{}}}}}", key),
56                reason: format!(
57                    "Variable value contains forbidden character '{}': path traversal risk",
58                    c
59                ),
60            });
61        }
62    }
63
64    // Check for forbidden patterns
65    for pattern in FORBIDDEN_VAR_PATTERNS {
66        if value.contains(pattern) {
67            return Err(NikaError::TemplateError {
68                template: format!("{{{{{}}}}}", key),
69                reason: format!(
70                    "Variable value contains forbidden pattern '{}': path traversal risk",
71                    pattern
72                ),
73            });
74        }
75    }
76
77    Ok(())
78}
79
80/// Template variable resolver for artifact paths
81#[derive(Debug)]
82pub struct TemplateResolver {
83    /// Current task ID
84    task_id: String,
85    /// Workflow name
86    workflow_name: String,
87    /// Current timestamp for consistent date/time across all variables
88    timestamp: DateTime<Local>,
89    /// Additional custom variables
90    custom_vars: HashMap<String, String>,
91}
92
93impl TemplateResolver {
94    /// Create a new template resolver
95    pub fn new(task_id: impl Into<String>, workflow_name: impl Into<String>) -> Self {
96        Self {
97            task_id: task_id.into(),
98            workflow_name: workflow_name.into(),
99            timestamp: Local::now(),
100            custom_vars: HashMap::new(),
101        }
102    }
103
104    /// Add a custom variable
105    ///
106    /// # Errors
107    ///
108    /// Returns `NikaError::TemplateError` if the key is empty or if the value
109    /// contains path traversal patterns (e.g., `..`, `/`, `\`).
110    #[cfg(test)]
111    pub fn with_var(
112        mut self,
113        key: impl Into<String>,
114        value: impl Into<String>,
115    ) -> Result<Self, NikaError> {
116        let key = key.into();
117        let value = value.into();
118
119        // Reject empty variable names
120        if key.is_empty() {
121            return Err(NikaError::TemplateError {
122                template: "{{}}".to_string(),
123                reason: "Variable name cannot be empty".to_string(),
124            });
125        }
126
127        // Validate value for path traversal
128        validate_var_value(&key, &value)?;
129
130        self.custom_vars.insert(key, value);
131        Ok(self)
132    }
133
134    /// Add multiple custom variables
135    ///
136    /// # Errors
137    ///
138    /// Returns `NikaError::TemplateError` if any key is empty or if any value
139    /// contains path traversal patterns.
140    pub fn with_vars(mut self, vars: HashMap<String, String>) -> Result<Self, NikaError> {
141        for (key, value) in &vars {
142            if key.is_empty() {
143                return Err(NikaError::TemplateError {
144                    template: "{{}}".to_string(),
145                    reason: "Variable name cannot be empty".to_string(),
146                });
147            }
148            validate_var_value(key, value)?;
149        }
150        self.custom_vars.extend(vars);
151        Ok(self)
152    }
153
154    /// Set a specific timestamp (useful for testing)
155    #[cfg(test)]
156    pub fn with_timestamp(mut self, timestamp: DateTime<Local>) -> Self {
157        self.timestamp = timestamp;
158        self
159    }
160
161    /// Resolve all template variables in a path string
162    ///
163    /// # Arguments
164    ///
165    /// * `template` - Path template with `{{var}}` placeholders
166    ///
167    /// # Returns
168    ///
169    /// Resolved path string with all variables substituted
170    ///
171    /// # Errors
172    ///
173    /// Returns `NikaError::TemplateError` if an unknown variable is referenced
174    pub fn resolve(&self, template: &str) -> Result<String, NikaError> {
175        let mut result = template.to_string();
176        let mut pos = 0;
177
178        while let Some(start) = result[pos..].find("{{") {
179            let start = pos + start;
180            let Some(end) = result[start..].find("}}") else {
181                // Unclosed template, treat as literal
182                break;
183            };
184            let end = start + end + 2;
185
186            let var_name = &result[start + 2..end - 2].trim();
187            let value = self.resolve_variable(var_name)?;
188
189            result.replace_range(start..end, &value);
190            pos = start + value.len();
191        }
192
193        Ok(result)
194    }
195
196    /// Resolve a single variable name to its value
197    fn resolve_variable(&self, var_name: &str) -> Result<String, NikaError> {
198        // Check for date format specifier: date.FORMAT
199        if let Some(format) = var_name.strip_prefix("date.") {
200            return Ok(self.format_date(format));
201        }
202
203        // Check for time format specifier: time.FORMAT
204        if let Some(format) = var_name.strip_prefix("time.") {
205            return Ok(self.format_time(format));
206        }
207
208        // Built-in variables
209        match var_name {
210            "task_id" => Ok(self.task_id.clone()),
211            // "workflow" alias for "workflow_name" (shorter, intuitive)
212            "workflow_name" | "workflow" => Ok(self.workflow_name.clone()),
213            "date" => Ok(self.timestamp.format("%Y-%m-%d").to_string()),
214            "time" => Ok(self.timestamp.format("%H-%M-%S").to_string()),
215            "timestamp" => Ok(self.timestamp.timestamp().to_string()),
216            "uuid" => Ok(Uuid::new_v4().to_string()),
217            _ => {
218                // Check custom variables
219                if let Some(value) = self.custom_vars.get(var_name) {
220                    return Ok(value.clone());
221                }
222
223                Err(NikaError::TemplateError {
224                    template: format!("{{{{{}}}}}", var_name),
225                    reason: format!("Unknown template variable: {}", var_name),
226                })
227            }
228        }
229    }
230
231    /// Format date with custom format string
232    ///
233    /// Supported format specifiers:
234    /// - `YYYY` - 4-digit year
235    /// - `MM` - 2-digit month
236    /// - `DD` - 2-digit day
237    fn format_date(&self, format: &str) -> String {
238        let mut result = format.to_string();
239        result = result.replace("YYYY", &self.timestamp.format("%Y").to_string());
240        result = result.replace("MM", &self.timestamp.format("%m").to_string());
241        result = result.replace("DD", &self.timestamp.format("%d").to_string());
242        result
243    }
244
245    /// Format time with custom format string
246    ///
247    /// Supported format specifiers:
248    /// - `HH` - 2-digit hour (24h)
249    /// - `mm` - 2-digit minute
250    /// - `ss` - 2-digit second
251    fn format_time(&self, format: &str) -> String {
252        let mut result = format.to_string();
253        result = result.replace("HH", &self.timestamp.format("%H").to_string());
254        result = result.replace("mm", &self.timestamp.format("%M").to_string());
255        result = result.replace("ss", &self.timestamp.format("%S").to_string());
256        result
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use chrono::TimeZone;
264
265    fn fixed_resolver() -> TemplateResolver {
266        let ts = Local.with_ymd_and_hms(2024, 1, 15, 14, 30, 45).unwrap();
267        TemplateResolver::new("test_task", "test_workflow").with_timestamp(ts)
268    }
269
270    #[test]
271    fn test_resolve_task_id() {
272        let resolver = fixed_resolver();
273        let result = resolver.resolve("{{task_id}}/output.json").unwrap();
274        assert_eq!(result, "test_task/output.json");
275    }
276
277    #[test]
278    fn test_resolve_workflow_name() {
279        let resolver = fixed_resolver();
280        let result = resolver
281            .resolve("{{workflow_name}}/{{task_id}}.json")
282            .unwrap();
283        assert_eq!(result, "test_workflow/test_task.json");
284    }
285
286    #[test]
287    fn test_resolve_date() {
288        let resolver = fixed_resolver();
289        let result = resolver.resolve("{{date}}/output.json").unwrap();
290        assert_eq!(result, "2024-01-15/output.json");
291    }
292
293    #[test]
294    fn test_resolve_time() {
295        let resolver = fixed_resolver();
296        let result = resolver.resolve("{{time}}.json").unwrap();
297        assert_eq!(result, "14-30-45.json");
298    }
299
300    #[test]
301    fn test_resolve_timestamp() {
302        let resolver = fixed_resolver();
303        let result = resolver.resolve("{{timestamp}}.json").unwrap();
304        // Just verify it's a number
305        assert!(result.ends_with(".json"));
306        let ts_str = result.strip_suffix(".json").unwrap();
307        assert!(ts_str.parse::<i64>().is_ok());
308    }
309
310    #[test]
311    fn test_resolve_uuid() {
312        let resolver = fixed_resolver();
313        let result = resolver.resolve("{{uuid}}.json").unwrap();
314        // Just verify format (UUID v4 is random)
315        assert!(result.ends_with(".json"));
316        let uuid_str = result.strip_suffix(".json").unwrap();
317        assert!(Uuid::parse_str(uuid_str).is_ok());
318    }
319
320    #[test]
321    fn test_resolve_date_format() {
322        let resolver = fixed_resolver();
323        let result = resolver.resolve("{{date.YYYY-MM-DD}}.json").unwrap();
324        assert_eq!(result, "2024-01-15.json");
325    }
326
327    #[test]
328    fn test_resolve_date_format_custom() {
329        let resolver = fixed_resolver();
330        let result = resolver.resolve("{{date.YYYY/MM/DD}}.json").unwrap();
331        assert_eq!(result, "2024/01/15.json");
332    }
333
334    #[test]
335    fn test_resolve_time_format() {
336        let resolver = fixed_resolver();
337        let result = resolver.resolve("{{time.HH-mm-ss}}.json").unwrap();
338        assert_eq!(result, "14-30-45.json");
339    }
340
341    #[test]
342    fn test_resolve_custom_var() {
343        let resolver = fixed_resolver().with_var("entity", "qr-code").unwrap();
344        let result = resolver.resolve("{{entity}}/{{task_id}}.json").unwrap();
345        assert_eq!(result, "qr-code/test_task.json");
346    }
347
348    #[test]
349    fn test_resolve_multiple_vars() {
350        let mut vars = HashMap::new();
351        vars.insert("locale".to_string(), "fr-FR".to_string());
352        vars.insert("version".to_string(), "v1".to_string());
353
354        let resolver = fixed_resolver().with_vars(vars).unwrap();
355        let result = resolver
356            .resolve("{{locale}}/{{version}}/{{task_id}}.json")
357            .unwrap();
358        assert_eq!(result, "fr-FR/v1/test_task.json");
359    }
360
361    #[test]
362    fn test_var_path_traversal_rejected() {
363        let result = fixed_resolver().with_var("entity", "../escape");
364        assert!(result.is_err());
365        let err = result.unwrap_err();
366        if let NikaError::TemplateError { reason, .. } = err {
367            assert!(reason.contains("path traversal"));
368        } else {
369            panic!("Expected TemplateError");
370        }
371    }
372
373    #[test]
374    fn test_var_slash_rejected() {
375        let result = fixed_resolver().with_var("path", "a/b/c");
376        assert!(result.is_err());
377        let err = result.unwrap_err();
378        if let NikaError::TemplateError { reason, .. } = err {
379            assert!(reason.contains("forbidden character"));
380        } else {
381            panic!("Expected TemplateError");
382        }
383    }
384
385    #[test]
386    fn test_empty_var_name_rejected() {
387        let result = fixed_resolver().with_var("", "value");
388        assert!(result.is_err());
389        let err = result.unwrap_err();
390        if let NikaError::TemplateError { reason, .. } = err {
391            assert!(reason.contains("empty"));
392        } else {
393            panic!("Expected TemplateError");
394        }
395    }
396
397    #[test]
398    fn test_empty_var_value_allowed() {
399        let resolver = fixed_resolver().with_var("empty", "").unwrap();
400        let result = resolver.resolve("prefix{{empty}}suffix").unwrap();
401        assert_eq!(result, "prefixsuffix");
402    }
403
404    #[test]
405    fn test_resolve_unknown_var() {
406        let resolver = fixed_resolver();
407        let result = resolver.resolve("{{unknown}}/output.json");
408        assert!(result.is_err());
409        let err = result.unwrap_err();
410        assert!(matches!(err, NikaError::TemplateError { .. }));
411    }
412
413    #[test]
414    fn test_resolve_unclosed_template() {
415        let resolver = fixed_resolver();
416        // Unclosed template is treated as literal
417        let result = resolver.resolve("{{task_id/output.json").unwrap();
418        assert_eq!(result, "{{task_id/output.json");
419    }
420
421    #[test]
422    fn test_resolve_no_templates() {
423        let resolver = fixed_resolver();
424        let result = resolver.resolve("simple/path/output.json").unwrap();
425        assert_eq!(result, "simple/path/output.json");
426    }
427
428    #[test]
429    fn test_resolve_whitespace_in_var() {
430        let resolver = fixed_resolver();
431        let result = resolver.resolve("{{ task_id }}/output.json").unwrap();
432        assert_eq!(result, "test_task/output.json");
433    }
434
435    #[test]
436    fn test_resolve_complex_path() {
437        let resolver = fixed_resolver().with_var("locale", "es-MX").unwrap();
438        let result = resolver
439            .resolve("{{workflow_name}}/{{date}}/{{locale}}/{{task_id}}_{{time}}.json")
440            .unwrap();
441        assert_eq!(
442            result,
443            "test_workflow/2024-01-15/es-MX/test_task_14-30-45.json"
444        );
445    }
446
447    #[test]
448    fn test_resolve_workflow_alias() {
449        // {{workflow}} is an alias for {{workflow_name}}
450        let resolver = fixed_resolver();
451        let result = resolver.resolve("{{workflow}}/{{task_id}}.json").unwrap();
452        assert_eq!(result, "test_workflow/test_task.json");
453    }
454}