1pub 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
17pub struct TemplateRenderer {
26 tera: Tera,
27 context: TemplateContext,
28}
29
30const MACRO_LIBRARY: &str = include_str!("_macros.toml.tera");
32
33impl TemplateRenderer {
34 pub fn new() -> Result<Self> {
36 let mut tera = Tera::default();
37
38 functions::register_functions(&mut tera)?;
40
41 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 pub fn with_defaults() -> Result<Self> {
58 let mut tera = Tera::default();
59
60 functions::register_functions(&mut tera)?;
62
63 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 pub fn with_context(mut self, context: TemplateContext) -> Self {
77 self.context = context;
78 self
79 }
80
81 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 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 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 pub fn render_str(&mut self, template: &str, name: &str) -> Result<String> {
109 let tera_ctx = self.context.to_tera_context()?;
111
112 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 pub fn render_from_glob(&mut self, glob_pattern: &str, template_name: &str) -> Result<String> {
125 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 let tera_ctx = self.context.to_tera_context()?;
134
135 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
145pub fn render_template(
179 template_content: &str,
180 user_vars: std::collections::HashMap<String, serde_json::Value>,
181) -> Result<String> {
182 let mut renderer = TemplateRenderer::with_defaults()?;
184
185 renderer.merge_user_vars(user_vars);
187
188 renderer.render_str(template_content, "template")
190}
191
192pub fn render_template_file(
205 template_path: &Path,
206 user_vars: std::collections::HashMap<String, serde_json::Value>,
207) -> Result<String> {
208 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_template(&template_content, user_vars)
215}
216
217pub 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 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 let renderer = TemplateRenderer::new().unwrap();
289
290 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 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 let result = renderer.render_str(template, "test_span_macro_basic");
308
309 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 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 let result = renderer.render_str(template, "test_span_macro_with_parent");
327
328 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 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 let result = renderer.render_str(template, "test_span_macro_with_attrs");
347
348 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 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 let result = renderer.render_str(template, "test_span_macro_with_parent_and_attrs");
369
370 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 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 let result = renderer.render_str(template, "test_service_macro_basic");
391
392 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 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 let result = renderer.render_str(template, "test_service_macro_with_args");
411
412 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 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 let result = renderer.render_str(template, "test_service_macro_with_env");
432
433 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 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 let result = renderer.render_str(template, "test_service_macro_with_args_and_env");
454
455 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 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 let result = renderer.render_str(template, "test_scenario_macro_basic");
476
477 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 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 let result = renderer.render_str(template, "test_scenario_macro_expect_failure");
498
499 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 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 let result = renderer.render_str(template, "test_complete_template_with_all_macros");
536
537 assert!(result.is_ok());
539 let output = result.unwrap();
540
541 assert!(output.contains("[test.metadata]"));
543 assert!(output.contains("name = \"integration-test\""));
544
545 assert!(output.contains("[service.postgres]"));
547 assert!(output.contains("[service.api]"));
548
549 assert!(output.contains("[[scenario]]"));
551 assert!(output.contains("name = \"start_db\""));
552 assert!(output.contains("name = \"test_api\""));
553
554 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 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 let result = renderer.render_str(template, "test_multiple_spans_same_template");
574
575 assert!(result.is_ok());
577 let output = result.unwrap();
578
579 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 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 let result = renderer.render_str(template, "test_macro_with_loop");
601
602 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}