clnrm_core/template/
context.rs

1//! Template context for Tera rendering
2//!
3//! Provides structured context with vars, matrix, and otel namespaces
4//! for template variable access.
5//!
6//! ## Variable Resolution Precedence (PRD v1.0)
7//!
8//! Variables are resolved with the following priority:
9//! 1. Template vars (user-provided)
10//! 2. Environment variables
11//! 3. Default values
12//!
13//! This enables flexible configuration without requiring environment variable prefixes.
14
15use crate::error::Result;
16use serde_json::Value;
17use std::collections::HashMap;
18use tera::Context;
19
20/// Template context with vars, matrix, otel namespaces
21///
22/// Provides structured access to template variables:
23/// - `vars.*` - User-defined variables
24/// - `matrix.*` - Matrix testing parameters
25/// - `otel.*` - OpenTelemetry configuration
26#[derive(Debug, Clone, Default)]
27pub struct TemplateContext {
28    /// User-defined variables
29    pub vars: HashMap<String, Value>,
30    /// Matrix testing parameters
31    pub matrix: HashMap<String, Value>,
32    /// OpenTelemetry configuration
33    pub otel: HashMap<String, Value>,
34}
35
36impl TemplateContext {
37    /// Create new empty template context
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Create context with default PRD v1.0 variables resolved via precedence
43    ///
44    /// Resolves standard variables following precedence:
45    /// - svc: SERVICE_NAME → "clnrm"
46    /// - env: ENV → "ci"
47    /// - endpoint: OTEL_ENDPOINT → "http://localhost:4318"
48    /// - exporter: OTEL_TRACES_EXPORTER → "otlp"
49    /// - image: CLNRM_IMAGE → "registry/clnrm:1.0.0"
50    /// - freeze_clock: FREEZE_CLOCK → "2025-01-01T00:00:00Z"
51    /// - token: OTEL_TOKEN → ""
52    pub fn with_defaults() -> Self {
53        let mut ctx = Self::new();
54
55        // Resolve standard variables using precedence
56        ctx.add_var_with_precedence("svc", "SERVICE_NAME", "clnrm");
57        ctx.add_var_with_precedence("env", "ENV", "ci");
58        ctx.add_var_with_precedence("endpoint", "OTEL_ENDPOINT", "http://localhost:4318");
59        ctx.add_var_with_precedence("exporter", "OTEL_TRACES_EXPORTER", "otlp");
60        ctx.add_var_with_precedence("image", "CLNRM_IMAGE", "registry/clnrm:1.0.0");
61        ctx.add_var_with_precedence("freeze_clock", "FREEZE_CLOCK", "2025-01-01T00:00:00Z");
62        ctx.add_var_with_precedence("token", "OTEL_TOKEN", "");
63
64        ctx
65    }
66
67    /// Add variable with precedence resolution
68    ///
69    /// Resolves value with priority: existing var → ENV → default
70    ///
71    /// # Arguments
72    ///
73    /// * `key` - Variable name
74    /// * `env_key` - Environment variable name
75    /// * `default` - Default value if not found
76    pub fn add_var_with_precedence(&mut self, key: &str, env_key: &str, default: &str) {
77        // Check if variable already exists (highest priority)
78        if self.vars.contains_key(key) {
79            return;
80        }
81
82        // Try environment variable (second priority)
83        if let Ok(env_value) = std::env::var(env_key) {
84            self.vars.insert(key.to_string(), Value::String(env_value));
85            return;
86        }
87
88        // Use default (lowest priority)
89        self.vars
90            .insert(key.to_string(), Value::String(default.to_string()));
91    }
92
93    /// Set user-defined variables
94    pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
95        self.vars = vars;
96        self
97    }
98
99    /// Set matrix testing parameters
100    pub fn with_matrix(mut self, matrix: HashMap<String, Value>) -> Self {
101        self.matrix = matrix;
102        self
103    }
104
105    /// Set OpenTelemetry configuration
106    pub fn with_otel(mut self, otel: HashMap<String, Value>) -> Self {
107        self.otel = otel;
108        self
109    }
110
111    /// Convert to Tera context for rendering
112    ///
113    /// Injects variables at both top-level (no prefix) and nested [vars] for authoring.
114    /// This matches PRD v1.0 requirements for no-prefix template variables.
115    pub fn to_tera_context(&self) -> Result<Context> {
116        let mut ctx = Context::new();
117
118        // Top-level injection (no prefix) - allows {{ svc }}, {{ env }}, etc.
119        for (key, value) in &self.vars {
120            ctx.insert(key, value);
121        }
122
123        // Nested injection for authoring - allows {{ vars.svc }}, etc.
124        ctx.insert("vars", &self.vars);
125        ctx.insert("matrix", &self.matrix);
126        ctx.insert("otel", &self.otel);
127
128        Ok(ctx)
129    }
130
131    /// Add a variable to the vars namespace
132    pub fn add_var(&mut self, key: String, value: Value) {
133        self.vars.insert(key, value);
134    }
135
136    /// Add a matrix parameter
137    pub fn add_matrix_param(&mut self, key: String, value: Value) {
138        self.matrix.insert(key, value);
139    }
140
141    /// Add an OTEL configuration value
142    pub fn add_otel_config(&mut self, key: String, value: Value) {
143        self.otel.insert(key, value);
144    }
145
146    /// Merge user-provided variables with defaults
147    ///
148    /// User variables take precedence over defaults (implements precedence chain)
149    pub fn merge_user_vars(&mut self, user_vars: HashMap<String, Value>) {
150        for (key, value) in user_vars {
151            self.vars.insert(key, value);
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    #![allow(
159        clippy::unwrap_used,
160        clippy::expect_used,
161        clippy::indexing_slicing,
162        clippy::panic
163    )]
164
165    use super::*;
166    use serde_json::json;
167
168    #[test]
169    fn test_context_creation() {
170        let context = TemplateContext::new();
171        assert!(context.vars.is_empty());
172        assert!(context.matrix.is_empty());
173        assert!(context.otel.is_empty());
174    }
175
176    #[test]
177    fn test_context_with_vars() {
178        let mut vars = HashMap::new();
179        vars.insert("key".to_string(), json!("value"));
180
181        let context = TemplateContext::new().with_vars(vars.clone());
182        assert_eq!(context.vars.get("key"), Some(&json!("value")));
183    }
184
185    #[test]
186    fn test_context_with_matrix() {
187        let mut matrix = HashMap::new();
188        matrix.insert("version".to_string(), json!("1.0"));
189
190        let context = TemplateContext::new().with_matrix(matrix.clone());
191        assert_eq!(context.matrix.get("version"), Some(&json!("1.0")));
192    }
193
194    #[test]
195    fn test_context_with_otel() {
196        let mut otel = HashMap::new();
197        otel.insert("enabled".to_string(), json!(true));
198
199        let context = TemplateContext::new().with_otel(otel.clone());
200        assert_eq!(context.otel.get("enabled"), Some(&json!(true)));
201    }
202
203    #[test]
204    fn test_to_tera_context() {
205        let mut context = TemplateContext::new();
206        context.add_var("name".to_string(), json!("test"));
207        context.add_matrix_param("env".to_string(), json!("prod"));
208        context.add_otel_config("trace".to_string(), json!(true));
209
210        let tera_ctx = context.to_tera_context().unwrap();
211        assert!(tera_ctx.get("vars").is_some());
212        assert!(tera_ctx.get("matrix").is_some());
213        assert!(tera_ctx.get("otel").is_some());
214    }
215
216    #[test]
217    fn test_add_methods() {
218        let mut context = TemplateContext::new();
219
220        context.add_var("var1".to_string(), json!("value1"));
221        context.add_matrix_param("param1".to_string(), json!(42));
222        context.add_otel_config("config1".to_string(), json!(false));
223
224        assert_eq!(context.vars.get("var1"), Some(&json!("value1")));
225        assert_eq!(context.matrix.get("param1"), Some(&json!(42)));
226        assert_eq!(context.otel.get("config1"), Some(&json!(false)));
227    }
228
229    #[test]
230    fn test_chaining() {
231        let mut vars = HashMap::new();
232        vars.insert("a".to_string(), json!(1));
233
234        let mut matrix = HashMap::new();
235        matrix.insert("b".to_string(), json!(2));
236
237        let mut otel = HashMap::new();
238        otel.insert("c".to_string(), json!(3));
239
240        let context = TemplateContext::new()
241            .with_vars(vars)
242            .with_matrix(matrix)
243            .with_otel(otel);
244
245        assert_eq!(context.vars.get("a"), Some(&json!(1)));
246        assert_eq!(context.matrix.get("b"), Some(&json!(2)));
247        assert_eq!(context.otel.get("c"), Some(&json!(3)));
248    }
249
250    #[test]
251    fn test_with_defaults_creates_standard_vars() {
252        let context = TemplateContext::with_defaults();
253
254        // Verify all standard variables are present
255        assert!(context.vars.contains_key("svc"));
256        assert!(context.vars.contains_key("env"));
257        assert!(context.vars.contains_key("endpoint"));
258        assert!(context.vars.contains_key("exporter"));
259        assert!(context.vars.contains_key("image"));
260        assert!(context.vars.contains_key("freeze_clock"));
261        assert!(context.vars.contains_key("token"));
262    }
263
264    #[test]
265    fn test_with_defaults_uses_default_values() {
266        // Clear environment to ensure defaults are used
267        std::env::remove_var("SERVICE_NAME");
268        std::env::remove_var("ENV");
269
270        let context = TemplateContext::with_defaults();
271
272        assert_eq!(context.vars.get("svc").unwrap().as_str().unwrap(), "clnrm");
273        assert_eq!(context.vars.get("env").unwrap().as_str().unwrap(), "ci");
274        assert_eq!(
275            context.vars.get("endpoint").unwrap().as_str().unwrap(),
276            "http://localhost:4318"
277        );
278    }
279
280    #[test]
281    fn test_precedence_env_over_default() {
282        // Set environment variable
283        std::env::set_var("SERVICE_NAME", "my-service");
284
285        let context = TemplateContext::with_defaults();
286
287        // ENV should override default
288        assert_eq!(
289            context.vars.get("svc").unwrap().as_str().unwrap(),
290            "my-service"
291        );
292
293        // Cleanup
294        std::env::remove_var("SERVICE_NAME");
295    }
296
297    #[test]
298    fn test_precedence_template_var_over_env() {
299        // Set environment variable
300        std::env::set_var("SERVICE_NAME", "env-service");
301
302        let mut context = TemplateContext::new();
303        // Add template variable first (highest priority)
304        context.add_var("svc".to_string(), json!("template-service"));
305
306        // Now try to add with precedence (should not override)
307        context.add_var_with_precedence("svc", "SERVICE_NAME", "default-service");
308
309        // Template var should win
310        assert_eq!(
311            context.vars.get("svc").unwrap().as_str().unwrap(),
312            "template-service"
313        );
314
315        // Cleanup
316        std::env::remove_var("SERVICE_NAME");
317    }
318
319    #[test]
320    fn test_merge_user_vars() {
321        let mut context = TemplateContext::with_defaults();
322
323        let mut user_vars = HashMap::new();
324        user_vars.insert("svc".to_string(), json!("user-override"));
325        user_vars.insert("custom".to_string(), json!("custom-value"));
326
327        context.merge_user_vars(user_vars);
328
329        // User var should override default
330        assert_eq!(
331            context.vars.get("svc").unwrap().as_str().unwrap(),
332            "user-override"
333        );
334        // Custom var should be added
335        assert_eq!(
336            context.vars.get("custom").unwrap().as_str().unwrap(),
337            "custom-value"
338        );
339    }
340
341    #[test]
342    fn test_to_tera_context_top_level_injection() {
343        let mut context = TemplateContext::new();
344        context.add_var("name".to_string(), json!("test"));
345
346        let tera_ctx = context.to_tera_context().unwrap();
347
348        // Should be available at top level (no prefix)
349        assert!(tera_ctx.get("name").is_some());
350        // Should also be available in vars namespace
351        assert!(tera_ctx.get("vars").is_some());
352    }
353
354    #[test]
355    fn test_full_precedence_chain() {
356        // Setup: default → ENV → template var
357        std::env::set_var("TEST_VAR_PRECEDENCE", "from-env");
358
359        let mut context = TemplateContext::new();
360
361        // 1. Apply with precedence (ENV wins over default)
362        context.add_var_with_precedence("test_key", "TEST_VAR_PRECEDENCE", "from-default");
363        assert_eq!(
364            context.vars.get("test_key").unwrap().as_str().unwrap(),
365            "from-env"
366        );
367
368        // 2. User vars win over everything
369        let mut user_vars = HashMap::new();
370        user_vars.insert("test_key".to_string(), json!("from-user"));
371        context.merge_user_vars(user_vars);
372
373        assert_eq!(
374            context.vars.get("test_key").unwrap().as_str().unwrap(),
375            "from-user"
376        );
377
378        // Cleanup
379        std::env::remove_var("TEST_VAR_PRECEDENCE");
380    }
381}