clnrm_core/template/
mod.rs

1//! Tera template rendering for .clnrm.toml files
2//!
3//! This module provides template rendering capabilities for test configuration files,
4//! enabling dynamic test generation with custom Tera functions.
5
6pub mod context;
7pub mod determinism;
8pub mod functions;
9
10use crate::error::{CleanroomError, Result};
11use std::path::Path;
12use tera::Tera;
13
14pub use context::TemplateContext;
15pub use determinism::DeterminismConfig;
16
17/// Template renderer with Tera engine
18///
19/// Provides template rendering with custom functions for:
20/// - Environment variable access
21/// - Deterministic timestamps
22/// - SHA-256 hashing
23/// - TOML encoding
24/// - Macro library for common TOML patterns
25pub struct TemplateRenderer {
26    tera: Tera,
27    context: TemplateContext,
28}
29
30/// Macro library content embedded at compile time
31const MACRO_LIBRARY: &str = include_str!("_macros.toml.tera");
32
33impl TemplateRenderer {
34    /// Create new template renderer with custom functions and macro library
35    pub fn new() -> Result<Self> {
36        let mut tera = Tera::default();
37
38        // Register custom functions
39        functions::register_functions(&mut tera)?;
40
41        // Add macro library template
42        tera.add_raw_template("_macros.toml.tera", MACRO_LIBRARY)
43            .map_err(|e| {
44                CleanroomError::template_error(format!("Failed to load macro library: {}", e))
45            })?;
46
47        Ok(Self {
48            tera,
49            context: TemplateContext::new(),
50        })
51    }
52
53    /// Create renderer with default PRD v1.0 variable resolution
54    ///
55    /// Initializes context with standard variables resolved via precedence:
56    /// template vars → ENV → defaults
57    pub fn with_defaults() -> Result<Self> {
58        let mut tera = Tera::default();
59
60        // Register custom functions
61        functions::register_functions(&mut tera)?;
62
63        // Add macro library template
64        tera.add_raw_template("_macros.toml.tera", MACRO_LIBRARY)
65            .map_err(|e| {
66                CleanroomError::template_error(format!("Failed to load macro library: {}", e))
67            })?;
68
69        Ok(Self {
70            tera,
71            context: TemplateContext::with_defaults(),
72        })
73    }
74
75    /// Set template context variables
76    pub fn with_context(mut self, context: TemplateContext) -> Self {
77        self.context = context;
78        self
79    }
80
81    /// Merge user-provided variables into context (respects precedence)
82    ///
83    /// User variables take highest priority in the precedence chain
84    pub fn merge_user_vars(
85        &mut self,
86        user_vars: std::collections::HashMap<String, serde_json::Value>,
87    ) {
88        self.context.merge_user_vars(user_vars);
89    }
90
91    /// Render template file to TOML string
92    pub fn render_file(&mut self, path: &Path) -> Result<String> {
93        let template_str = std::fs::read_to_string(path)
94            .map_err(|e| CleanroomError::config_error(format!("Failed to read template: {}", e)))?;
95
96        // Convert path to string with proper error handling
97        let path_str = path.to_str().ok_or_else(|| {
98            CleanroomError::validation_error(format!(
99                "Template path contains invalid UTF-8 characters: {}",
100                path.display()
101            ))
102        })?;
103
104        self.render_str(&template_str, path_str)
105    }
106
107    /// Render template string to TOML
108    pub fn render_str(&mut self, template: &str, name: &str) -> Result<String> {
109        // Build Tera context
110        let tera_ctx = self.context.to_tera_context()?;
111
112        // Render template
113        self.tera.render_str(template, &tera_ctx).map_err(|e| {
114            CleanroomError::template_error(format!(
115                "Template rendering failed in '{}': {}",
116                name, e
117            ))
118        })
119    }
120
121    /// Render template from glob pattern
122    ///
123    /// Useful for rendering multiple templates with shared context
124    pub fn render_from_glob(&mut self, glob_pattern: &str, template_name: &str) -> Result<String> {
125        // Add templates matching glob pattern
126        self.tera
127            .add_template_files(vec![(glob_pattern, Some(template_name))])
128            .map_err(|e| {
129                CleanroomError::template_error(format!("Failed to add template files: {}", e))
130            })?;
131
132        // Build Tera context
133        let tera_ctx = self.context.to_tera_context()?;
134
135        // Render template
136        self.tera.render(template_name, &tera_ctx).map_err(|e| {
137            CleanroomError::template_error(format!(
138                "Template rendering failed for '{}': {}",
139                template_name, e
140            ))
141        })
142    }
143}
144
145// Note: Default implementation removed to avoid panic risk.
146// Use TemplateRenderer::new() instead, which returns Result for proper error handling.
147
148/// Render template with user variables and PRD v1.0 defaults
149///
150/// This is the main entrypoint matching PRD v1.0 requirements:
151/// 1. Resolve inputs with precedence: template vars → ENV → defaults
152/// 2. Render template using Tera with no-prefix variables
153/// 3. Return flat TOML string
154///
155/// # Arguments
156///
157/// * `template_content` - Template string with Tera syntax
158/// * `user_vars` - User-provided variables (highest precedence)
159///
160/// # Returns
161///
162/// Rendered TOML string ready for parsing
163///
164/// # Example
165///
166/// ```rust,no_run
167/// use clnrm_core::template::render_template;
168/// use std::collections::HashMap;
169///
170/// let user_vars = HashMap::new();
171/// let template = r#"
172/// [meta]
173/// name = "{{ svc }}_test"
174/// "#;
175///
176/// let rendered = render_template(template, user_vars).unwrap();
177/// ```
178pub fn render_template(
179    template_content: &str,
180    user_vars: std::collections::HashMap<String, serde_json::Value>,
181) -> Result<String> {
182    // Create renderer with defaults
183    let mut renderer = TemplateRenderer::with_defaults()?;
184
185    // Merge user variables (highest precedence)
186    renderer.merge_user_vars(user_vars);
187
188    // Render template
189    renderer.render_str(template_content, "template")
190}
191
192/// Render template file with user variables and PRD v1.0 defaults
193///
194/// File-based variant of `render_template`
195///
196/// # Arguments
197///
198/// * `template_path` - Path to template file
199/// * `user_vars` - User-provided variables (highest precedence)
200///
201/// # Returns
202///
203/// Rendered TOML string ready for parsing
204pub fn render_template_file(
205    template_path: &Path,
206    user_vars: std::collections::HashMap<String, serde_json::Value>,
207) -> Result<String> {
208    // Read template file
209    let template_content = std::fs::read_to_string(template_path).map_err(|e| {
210        CleanroomError::config_error(format!("Failed to read template file: {}", e))
211    })?;
212
213    // Render with user vars
214    render_template(&template_content, user_vars)
215}
216
217/// Check if file content should be treated as a template
218///
219/// Detects Tera template syntax:
220/// - `{{ variable }}` - variable substitution
221/// - `{% for x in list %}` - control structures
222/// - `{# comment #}` - comments
223pub fn is_template(content: &str) -> bool {
224    content.contains("{{") || content.contains("{%") || content.contains("{#")
225}
226
227#[cfg(test)]
228mod tests {
229    #![allow(
230        clippy::unwrap_used,
231        clippy::expect_used,
232        clippy::indexing_slicing,
233        clippy::panic
234    )]
235
236    use super::*;
237
238    #[test]
239    fn test_template_detection() {
240        assert!(is_template("{{ var }}"));
241        assert!(is_template("{% for x in list %}"));
242        assert!(is_template("{# comment #}"));
243        assert!(!is_template("plain text"));
244        assert!(!is_template("[test]\nname = \"value\""));
245    }
246
247    #[test]
248    fn test_renderer_creation() {
249        let renderer = TemplateRenderer::new();
250        assert!(renderer.is_ok());
251    }
252
253    #[test]
254    fn test_basic_rendering() {
255        let mut renderer = TemplateRenderer::new().unwrap();
256        let result = renderer.render_str("Hello {{ name }}", "test");
257        // Will fail without context, but tests error handling
258        assert!(result.is_err());
259    }
260
261    #[test]
262    fn test_rendering_with_context() {
263        let mut renderer = TemplateRenderer::new().unwrap();
264        let mut context = TemplateContext::new();
265        context.vars.insert(
266            "name".to_string(),
267            serde_json::Value::String("World".to_string()),
268        );
269        renderer = renderer.with_context(context);
270
271        let result = renderer.render_str("Hello {{ vars.name }}", "test");
272        assert!(result.is_ok());
273        assert_eq!(result.unwrap(), "Hello World");
274    }
275
276    #[test]
277    fn test_error_handling_invalid_template() {
278        let mut renderer = TemplateRenderer::new().unwrap();
279        let result = renderer.render_str("{{ unclosed", "test");
280        assert!(result.is_err());
281        let err = result.unwrap_err();
282        assert!(matches!(err.kind, crate::error::ErrorKind::TemplateError));
283    }
284
285    #[test]
286    fn test_macro_library_loaded() {
287        // Arrange & Act
288        let renderer = TemplateRenderer::new().unwrap();
289
290        // Assert - macro library should be available
291        assert!(renderer
292            .tera
293            .get_template_names()
294            .any(|n| n == "_macros.toml.tera"));
295    }
296
297    #[test]
298    fn test_span_macro_basic() {
299        // Arrange
300        let mut renderer = TemplateRenderer::new().unwrap();
301        let template = r#"
302{% import "_macros.toml.tera" as m %}
303{{ m::span("test.span") }}
304"#;
305
306        // Act
307        let result = renderer.render_str(template, "test_span_macro_basic");
308
309        // Assert
310        assert!(result.is_ok());
311        let output = result.unwrap();
312        assert!(output.contains("[[expect.span]]"));
313        assert!(output.contains("name = \"test.span\""));
314    }
315
316    #[test]
317    fn test_span_macro_with_parent() {
318        // Arrange
319        let mut renderer = TemplateRenderer::new().unwrap();
320        let template = r#"
321{% import "_macros.toml.tera" as m %}
322{{ m::span("child.span", parent="parent.span") }}
323"#;
324
325        // Act
326        let result = renderer.render_str(template, "test_span_macro_with_parent");
327
328        // Assert
329        assert!(result.is_ok());
330        let output = result.unwrap();
331        assert!(output.contains("[[expect.span]]"));
332        assert!(output.contains("name = \"child.span\""));
333        assert!(output.contains("parent = \"parent.span\""));
334    }
335
336    #[test]
337    fn test_span_macro_with_attrs() {
338        // Arrange
339        let mut renderer = TemplateRenderer::new().unwrap();
340        let template = r#"
341{% import "_macros.toml.tera" as m %}
342{{ m::span("http.request", attrs={"http.method": "GET", "http.status": "200"}) }}
343"#;
344
345        // Act
346        let result = renderer.render_str(template, "test_span_macro_with_attrs");
347
348        // Assert
349        assert!(result.is_ok());
350        let output = result.unwrap();
351        assert!(output.contains("[[expect.span]]"));
352        assert!(output.contains("name = \"http.request\""));
353        assert!(output.contains("attrs.all = {"));
354        assert!(output.contains("\"http.method\" = \"GET\""));
355        assert!(output.contains("\"http.status\" = \"200\""));
356    }
357
358    #[test]
359    fn test_span_macro_with_parent_and_attrs() {
360        // Arrange
361        let mut renderer = TemplateRenderer::new().unwrap();
362        let template = r#"
363{% import "_macros.toml.tera" as m %}
364{{ m::span("db.query", parent="http.request", attrs={"db.system": "postgres"}) }}
365"#;
366
367        // Act
368        let result = renderer.render_str(template, "test_span_macro_with_parent_and_attrs");
369
370        // Assert
371        assert!(result.is_ok());
372        let output = result.unwrap();
373        assert!(output.contains("[[expect.span]]"));
374        assert!(output.contains("name = \"db.query\""));
375        assert!(output.contains("parent = \"http.request\""));
376        assert!(output.contains("attrs.all = {"));
377        assert!(output.contains("\"db.system\" = \"postgres\""));
378    }
379
380    #[test]
381    fn test_service_macro_basic() {
382        // Arrange
383        let mut renderer = TemplateRenderer::new().unwrap();
384        let template = r#"
385{% import "_macros.toml.tera" as m %}
386{{ m::service("postgres", "postgres:15") }}
387"#;
388
389        // Act
390        let result = renderer.render_str(template, "test_service_macro_basic");
391
392        // Assert
393        assert!(result.is_ok());
394        let output = result.unwrap();
395        assert!(output.contains("[service.postgres]"));
396        assert!(output.contains("plugin = \"generic_container\""));
397        assert!(output.contains("image = \"postgres:15\""));
398    }
399
400    #[test]
401    fn test_service_macro_with_args() {
402        // Arrange
403        let mut renderer = TemplateRenderer::new().unwrap();
404        let template = r#"
405{% import "_macros.toml.tera" as m %}
406{{ m::service("api", "nginx:alpine", args=["nginx", "-g", "daemon off;"]) }}
407"#;
408
409        // Act
410        let result = renderer.render_str(template, "test_service_macro_with_args");
411
412        // Assert
413        assert!(result.is_ok());
414        let output = result.unwrap();
415        assert!(output.contains("[service.api]"));
416        assert!(output.contains("plugin = \"generic_container\""));
417        assert!(output.contains("image = \"nginx:alpine\""));
418        assert!(output.contains("args = [\"nginx\", \"-g\", \"daemon off;\"]"));
419    }
420
421    #[test]
422    fn test_service_macro_with_env() {
423        // Arrange
424        let mut renderer = TemplateRenderer::new().unwrap();
425        let template = r#"
426{% import "_macros.toml.tera" as m %}
427{{ m::service("redis", "redis:7", env={"REDIS_PASSWORD": "secret", "DEBUG": "true"}) }}
428"#;
429
430        // Act
431        let result = renderer.render_str(template, "test_service_macro_with_env");
432
433        // Assert
434        assert!(result.is_ok());
435        let output = result.unwrap();
436        assert!(output.contains("[service.redis]"));
437        assert!(output.contains("plugin = \"generic_container\""));
438        assert!(output.contains("image = \"redis:7\""));
439        assert!(output.contains("env.REDIS_PASSWORD = \"secret\""));
440        assert!(output.contains("env.DEBUG = \"true\""));
441    }
442
443    #[test]
444    fn test_service_macro_with_args_and_env() {
445        // Arrange
446        let mut renderer = TemplateRenderer::new().unwrap();
447        let template = r#"
448{% import "_macros.toml.tera" as m %}
449{{ m::service("web", "myapp:latest", args=["--port", "8080"], env={"DEBUG": "true"}) }}
450"#;
451
452        // Act
453        let result = renderer.render_str(template, "test_service_macro_with_args_and_env");
454
455        // Assert
456        assert!(result.is_ok());
457        let output = result.unwrap();
458        assert!(output.contains("[service.web]"));
459        assert!(output.contains("plugin = \"generic_container\""));
460        assert!(output.contains("image = \"myapp:latest\""));
461        assert!(output.contains("args = [\"--port\", \"8080\"]"));
462        assert!(output.contains("env.DEBUG = \"true\""));
463    }
464
465    #[test]
466    fn test_scenario_macro_basic() {
467        // Arrange
468        let mut renderer = TemplateRenderer::new().unwrap();
469        let template = r#"
470{% import "_macros.toml.tera" as m %}
471{{ m::scenario("check_health", "api", "curl localhost:8080/health") }}
472"#;
473
474        // Act
475        let result = renderer.render_str(template, "test_scenario_macro_basic");
476
477        // Assert
478        assert!(result.is_ok());
479        let output = result.unwrap();
480        assert!(output.contains("[[scenario]]"));
481        assert!(output.contains("name = \"check_health\""));
482        assert!(output.contains("service = \"api\""));
483        assert!(output.contains("run = \"curl localhost:8080/health\""));
484        assert!(output.contains("expect_success = true"));
485    }
486
487    #[test]
488    fn test_scenario_macro_expect_failure() {
489        // Arrange
490        let mut renderer = TemplateRenderer::new().unwrap();
491        let template = r#"
492{% import "_macros.toml.tera" as m %}
493{{ m::scenario("fail_test", "app", "exit 1", expect_success=false) }}
494"#;
495
496        // Act
497        let result = renderer.render_str(template, "test_scenario_macro_expect_failure");
498
499        // Assert
500        assert!(result.is_ok());
501        let output = result.unwrap();
502        assert!(output.contains("[[scenario]]"));
503        assert!(output.contains("name = \"fail_test\""));
504        assert!(output.contains("service = \"app\""));
505        assert!(output.contains("run = \"exit 1\""));
506        assert!(output.contains("expect_success = false"));
507    }
508
509    #[test]
510    fn test_complete_template_with_all_macros() {
511        // Arrange
512        let mut renderer = TemplateRenderer::new().unwrap();
513        let template = r#"
514{% import "_macros.toml.tera" as m %}
515[test.metadata]
516name = "integration-test"
517description = "Full integration test using all macros"
518
519{{ m::service("postgres", "postgres:15", env={"POSTGRES_PASSWORD": "test"}) }}
520
521{{ m::service("api", "nginx:alpine") }}
522
523{{ m::scenario("start_db", "postgres", "pg_isready") }}
524
525{{ m::scenario("test_api", "api", "curl localhost") }}
526
527{{ m::span("test.root") }}
528
529{{ m::span("db.connect", parent="test.root", attrs={"db.system": "postgres"}) }}
530
531{{ m::span("http.request", parent="test.root", attrs={"http.method": "GET"}) }}
532"#;
533
534        // Act
535        let result = renderer.render_str(template, "test_complete_template_with_all_macros");
536
537        // Assert
538        assert!(result.is_ok());
539        let output = result.unwrap();
540
541        // Verify test metadata
542        assert!(output.contains("[test.metadata]"));
543        assert!(output.contains("name = \"integration-test\""));
544
545        // Verify services
546        assert!(output.contains("[service.postgres]"));
547        assert!(output.contains("[service.api]"));
548
549        // Verify scenarios
550        assert!(output.contains("[[scenario]]"));
551        assert!(output.contains("name = \"start_db\""));
552        assert!(output.contains("name = \"test_api\""));
553
554        // Verify spans
555        assert!(output.contains("[[expect.span]]"));
556        assert!(output.contains("name = \"test.root\""));
557        assert!(output.contains("name = \"db.connect\""));
558        assert!(output.contains("name = \"http.request\""));
559    }
560
561    #[test]
562    fn test_multiple_spans_same_template() {
563        // Arrange
564        let mut renderer = TemplateRenderer::new().unwrap();
565        let template = r#"
566{% import "_macros.toml.tera" as m %}
567{{ m::span("span1") }}
568{{ m::span("span2") }}
569{{ m::span("span3") }}
570"#;
571
572        // Act
573        let result = renderer.render_str(template, "test_multiple_spans_same_template");
574
575        // Assert
576        assert!(result.is_ok());
577        let output = result.unwrap();
578
579        // Count occurrences of [[expect.span]]
580        let span_count = output.matches("[[expect.span]]").count();
581        assert_eq!(span_count, 3);
582        assert!(output.contains("name = \"span1\""));
583        assert!(output.contains("name = \"span2\""));
584        assert!(output.contains("name = \"span3\""));
585    }
586
587    #[test]
588    fn test_macro_with_loop() {
589        // Arrange
590        let mut renderer = TemplateRenderer::new().unwrap();
591        let template = r#"
592{% import "_macros.toml.tera" as m %}
593{% set services = ["postgres", "redis", "nginx"] %}
594{% for svc in services %}
595{{ m::service(svc, "alpine:latest") }}
596{% endfor %}
597"#;
598
599        // Act
600        let result = renderer.render_str(template, "test_macro_with_loop");
601
602        // Assert
603        assert!(result.is_ok());
604        let output = result.unwrap();
605        assert!(output.contains("[service.postgres]"));
606        assert!(output.contains("[service.redis]"));
607        assert!(output.contains("[service.nginx]"));
608    }
609}