clnrm_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 std::path::Path;
19use tera::Context;
20
21/// Template context with vars, matrix, otel namespaces
22///
23/// Provides structured access to template variables:
24/// - `vars.*` - User-defined variables
25/// - `matrix.*` - Matrix testing parameters
26/// - `otel.*` - OpenTelemetry configuration
27#[derive(Debug, Clone, Default)]
28pub struct TemplateContext {
29    /// User-defined variables
30    pub vars: HashMap<String, Value>,
31    /// Matrix testing parameters
32    pub matrix: HashMap<String, Value>,
33    /// OpenTelemetry configuration
34    pub otel: HashMap<String, Value>,
35}
36
37impl TemplateContext {
38    /// Create new empty template context
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Create context with default PRD v1.0 variables resolved via precedence
44    ///
45    /// Resolves standard variables following precedence:
46    /// - svc: SERVICE_NAME → "clnrm"
47    /// - env: ENV → "ci"
48    /// - endpoint: OTEL_ENDPOINT → "http://localhost:4318"
49    /// - exporter: OTEL_TRACES_EXPORTER → "otlp"
50    /// - image: CLNRM_IMAGE → "registry/clnrm:1.0.0"
51    /// - freeze_clock: FREEZE_CLOCK → "2025-01-01T00:00:00Z"
52    /// - token: OTEL_TOKEN → ""
53    pub fn with_defaults() -> Self {
54        let mut ctx = Self::new();
55
56        // Resolve standard variables using precedence
57        ctx.add_var_with_precedence("svc", "SERVICE_NAME", "clnrm");
58        ctx.add_var_with_precedence("env", "ENV", "ci");
59        ctx.add_var_with_precedence("endpoint", "OTEL_ENDPOINT", "http://localhost:4318");
60        ctx.add_var_with_precedence("exporter", "OTEL_TRACES_EXPORTER", "otlp");
61        ctx.add_var_with_precedence("image", "CLNRM_IMAGE", "registry/clnrm:1.0.0");
62        ctx.add_var_with_precedence("freeze_clock", "FREEZE_CLOCK", "2025-01-01T00:00:00Z");
63        ctx.add_var_with_precedence("token", "OTEL_TOKEN", "");
64
65        ctx
66    }
67
68    /// Add variable with precedence resolution
69    ///
70    /// Resolves value with priority: existing var → ENV → default
71    ///
72    /// # Arguments
73    ///
74    /// * `key` - Variable name
75    /// * `env_key` - Environment variable name
76    /// * `default` - Default value if not found
77    pub fn add_var_with_precedence(&mut self, key: &str, env_key: &str, default: &str) {
78        // Check if variable already exists (highest priority)
79        if self.vars.contains_key(key) {
80            return;
81        }
82
83        // Try environment variable (second priority)
84        if let Ok(env_value) = std::env::var(env_key) {
85            self.vars.insert(key.to_string(), Value::String(env_value));
86            return;
87        }
88
89        // Use default (lowest priority)
90        self.vars
91            .insert(key.to_string(), Value::String(default.to_string()));
92    }
93
94    /// Set user-defined variables
95    pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
96        self.vars = vars;
97        self
98    }
99
100    /// Set matrix testing parameters
101    pub fn with_matrix(mut self, matrix: HashMap<String, Value>) -> Self {
102        self.matrix = matrix;
103        self
104    }
105
106    /// Set OpenTelemetry configuration
107    pub fn with_otel(mut self, otel: HashMap<String, Value>) -> Self {
108        self.otel = otel;
109        self
110    }
111
112    /// Convert to Tera context for rendering
113    ///
114    /// Injects variables at both top-level (no prefix) and nested [vars] for authoring.
115    /// This matches PRD v1.0 requirements for no-prefix template variables.
116    pub fn to_tera_context(&self) -> Result<Context> {
117        let mut ctx = Context::new();
118
119        // Top-level injection (no prefix) - allows {{ svc }}, {{ env }}, etc.
120        for (key, value) in &self.vars {
121            ctx.insert(key, value);
122        }
123
124        // Nested injection for authoring - allows {{ vars.svc }}, etc.
125        ctx.insert("vars", &self.vars);
126        ctx.insert("matrix", &self.matrix);
127        ctx.insert("otel", &self.otel);
128
129        Ok(ctx)
130    }
131
132    /// Add a variable to the vars namespace
133    pub fn add_var(&mut self, key: String, value: Value) {
134        self.vars.insert(key, value);
135    }
136
137    /// Add a matrix parameter
138    pub fn add_matrix_param(&mut self, key: String, value: Value) {
139        self.matrix.insert(key, value);
140    }
141
142    /// Add an OTEL configuration value
143    pub fn add_otel_config(&mut self, key: String, value: Value) {
144        self.otel.insert(key, value);
145    }
146
147    /// Merge user-provided variables with defaults
148    ///
149    /// User variables take precedence over defaults (implements precedence chain)
150    pub fn merge_user_vars(&mut self, user_vars: HashMap<String, Value>) {
151        for (key, value) in user_vars {
152            self.vars.insert(key, value);
153        }
154    }
155}
156
157/// Fluent API for building template contexts
158///
159/// Provides method chaining for easy context construction:
160///
161/// ```rust
162/// use clnrm_template::TemplateContext;
163///
164/// let context = TemplateContext::builder()
165///     .var("service_name", "my-service")
166///     .var("environment", "production")
167///     .matrix("browser", vec!["chrome", "firefox"])
168///     .otel("endpoint", "http://localhost:4318")
169///     .build();
170/// ```
171pub struct TemplateContextBuilder {
172    context: TemplateContext,
173}
174
175impl TemplateContextBuilder {
176    /// Start building a new template context
177    pub fn new() -> Self {
178        Self {
179            context: TemplateContext::new(),
180        }
181    }
182
183    /// Start with default PRD v1.0 variables
184    pub fn with_defaults() -> Self {
185        Self {
186            context: TemplateContext::with_defaults(),
187        }
188    }
189
190    /// Add a variable to the vars namespace
191    ///
192    /// # Arguments
193    /// * `key` - Variable name
194    /// * `value` - Variable value (string, number, bool, array, or object)
195    pub fn var<K: Into<String>, V: Into<Value>>(mut self, key: K, value: V) -> Self {
196        self.context.vars.insert(key.into(), value.into());
197        self
198    }
199
200    /// Add multiple variables at once
201    pub fn vars<K, V, I>(mut self, vars: I) -> Self
202    where
203        K: Into<String>,
204        V: Into<Value>,
205        I: IntoIterator<Item = (K, V)>,
206    {
207        for (key, value) in vars {
208            self.context.vars.insert(key.into(), value.into());
209        }
210        self
211    }
212
213    /// Add a matrix parameter
214    ///
215    /// # Arguments
216    /// * `key` - Matrix parameter name
217    /// * `value` - Parameter value
218    pub fn matrix<K: Into<String>, V: Into<Value>>(mut self, key: K, value: V) -> Self {
219        self.context.matrix.insert(key.into(), value.into());
220        self
221    }
222
223    /// Add multiple matrix parameters
224    pub fn matrix_params<K, V, I>(mut self, params: I) -> Self
225    where
226        K: Into<String>,
227        V: Into<Value>,
228        I: IntoIterator<Item = (K, V)>,
229    {
230        for (key, value) in params {
231            self.context.matrix.insert(key.into(), value.into());
232        }
233        self
234    }
235
236    /// Add an OpenTelemetry configuration value
237    ///
238    /// # Arguments
239    /// * `key` - OTEL configuration key
240    /// * `value` - Configuration value
241    pub fn otel<K: Into<String>, V: Into<Value>>(mut self, key: K, value: V) -> Self {
242        self.context.otel.insert(key.into(), value.into());
243        self
244    }
245
246    /// Add multiple OTEL configuration values
247    pub fn otel_config<K, V, I>(mut self, config: I) -> Self
248    where
249        K: Into<String>,
250        V: Into<Value>,
251        I: IntoIterator<Item = (K, V)>,
252    {
253        for (key, value) in config {
254            self.context.otel.insert(key.into(), value.into());
255        }
256        self
257    }
258
259    /// Add variable with environment variable precedence
260    ///
261    /// Sets variable with priority: existing vars → ENV → default
262    pub fn var_with_env(mut self, key: &str, env_key: &str, default: &str) -> Self {
263        self.context.add_var_with_precedence(key, env_key, default);
264        self
265    }
266
267    /// Merge user-provided variables (highest precedence)
268    pub fn merge_vars(mut self, user_vars: HashMap<String, Value>) -> Self {
269        self.context.merge_user_vars(user_vars);
270        self
271    }
272
273    /// Load variables from a JSON file
274    ///
275    /// # Arguments
276    /// * `path` - Path to JSON file containing variables
277    pub fn load_vars_from_file<P: AsRef<Path>>(mut self, path: P) -> Result<Self> {
278        let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
279            crate::error::TemplateError::IoError(format!("Failed to read vars file: {}", e))
280        })?;
281
282        let vars: HashMap<String, Value> = serde_json::from_str(&content).map_err(|e| {
283            crate::error::TemplateError::ConfigError(format!("Invalid JSON in vars file: {}", e))
284        })?;
285
286        self.context.merge_user_vars(vars);
287        Ok(self)
288    }
289
290    /// Load matrix parameters from a TOML file
291    ///
292    /// # Arguments
293    /// * `path` - Path to TOML file containing matrix parameters
294    pub fn load_matrix_from_file<P: AsRef<Path>>(mut self, path: P) -> Result<Self> {
295        let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
296            crate::error::TemplateError::IoError(format!("Failed to read matrix file: {}", e))
297        })?;
298
299        let matrix: HashMap<String, Value> = toml::from_str(&content).map_err(|e| {
300            crate::error::TemplateError::ConfigError(format!("Invalid TOML in matrix file: {}", e))
301        })?;
302
303        self.context.matrix = matrix;
304        Ok(self)
305    }
306
307    /// Build the final template context
308    pub fn build(self) -> TemplateContext {
309        self.context
310    }
311}
312
313impl Default for TemplateContextBuilder {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319/// Convenience functions for common context patterns
320pub mod patterns {
321    use super::*;
322
323    /// Create context for test scenario
324    pub fn test_scenario() -> TemplateContextBuilder {
325        TemplateContextBuilder::new()
326            .var_with_env("service", "SERVICE_NAME", "test-service")
327            .var_with_env("environment", "ENV", "test")
328            .var("timestamp", Value::String(chrono::Utc::now().to_rfc3339()))
329    }
330
331    /// Create context for CI/CD pipeline
332    pub fn ci_pipeline() -> TemplateContextBuilder {
333        TemplateContextBuilder::new()
334            .var_with_env("service", "SERVICE_NAME", "pipeline-service")
335            .var_with_env("environment", "ENV", "ci")
336            .var_with_env("branch", "BRANCH", "main")
337            .var_with_env("commit", "COMMIT_SHA", "unknown")
338            .var("build_id", Value::String(uuid::Uuid::new_v4().to_string()))
339    }
340
341    /// Create context for production deployment
342    pub fn production() -> TemplateContextBuilder {
343        TemplateContextBuilder::new()
344            .var_with_env("service", "SERVICE_NAME", "production-service")
345            .var_with_env("environment", "ENV", "production")
346            .var_with_env("region", "AWS_REGION", "us-east-1")
347            .var_with_env("cluster", "K8S_CLUSTER", "production")
348    }
349
350    /// Create context for local development
351    pub fn development() -> TemplateContextBuilder {
352        TemplateContextBuilder::new()
353            .var("service", "dev-service")
354            .var("environment", "development")
355            .var("debug", Value::Bool(true))
356            .var("log_level", "debug")
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_context_builder_fluent_api() {
366        let context = TemplateContext::builder()
367            .var("service", "my-service")
368            .var("version", "1.0.0")
369            .matrix("browsers", vec!["chrome", "firefox"])
370            .otel("endpoint", "http://localhost:4318")
371            .build();
372
373        assert_eq!(
374            context.vars["service"],
375            Value::String("my-service".to_string())
376        );
377        assert_eq!(context.vars["version"], Value::String("1.0.0".to_string()));
378
379        let browsers = context.matrix["browsers"].as_array().unwrap();
380        assert_eq!(browsers.len(), 2);
381        assert_eq!(browsers[0], Value::String("chrome".to_string()));
382
383        assert_eq!(
384            context.otel["endpoint"],
385            Value::String("http://localhost:4318".to_string())
386        );
387    }
388
389    #[test]
390    fn test_context_builder_patterns() {
391        let context = patterns::test_scenario()
392            .var("test_type", "integration")
393            .build();
394
395        assert!(context.vars.contains_key("service"));
396        assert!(context.vars.contains_key("environment"));
397        assert!(context.vars.contains_key("timestamp"));
398        assert_eq!(
399            context.vars["test_type"],
400            Value::String("integration".to_string())
401        );
402    }
403
404    #[test]
405    fn test_context_with_defaults() {
406        let context = TemplateContext::with_defaults();
407
408        // Should have default PRD v1.0 variables
409        assert!(context.vars.contains_key("svc"));
410        assert!(context.vars.contains_key("env"));
411        assert!(context.vars.contains_key("endpoint"));
412        assert!(context.vars.contains_key("exporter"));
413
414        assert_eq!(context.vars["svc"], Value::String("clnrm".to_string()));
415        assert_eq!(context.vars["env"], Value::String("ci".to_string()));
416    }
417}