standout_render/template/renderer.rs
1//! Pre-compiled template renderer.
2//!
3//! This module provides [`Renderer`], a high-level interface for template
4//! rendering that supports both inline and file-based templates.
5//!
6//! # File-Based Templates
7//!
8//! Templates can be loaded from directories on the filesystem:
9//!
10//! ```rust,ignore
11//! use standout_render::{Renderer, Theme};
12//!
13//! let mut renderer = Renderer::new(Theme::new())?;
14//! renderer.add_template_dir("./templates")?;
15//!
16//! // Renders templates/todos/list.jinja
17//! let output = renderer.render("todos/list", &data)?;
18//! ```
19//!
20//! See [`Renderer::add_template_dir`] for details on template resolution
21//! and the [`super::registry`] module for the underlying mechanism.
22//!
23//! # Development vs Release
24//!
25//! In development mode (`debug_assertions` enabled):
26//! - Template content is re-read from disk on each render
27//! - This enables hot reloading without recompilation
28//!
29//! In release mode:
30//! - Templates can be embedded at compile time for deployment
31//! - Use [`Renderer::with_embedded`] to load pre-embedded templates
32
33use std::collections::HashMap;
34use std::path::Path;
35
36use serde::Serialize;
37use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
38
39use super::engine::{MiniJinjaEngine, TemplateEngine};
40use super::registry::{walk_template_dir, ResolvedTemplate, TemplateRegistry};
41use crate::error::RenderError;
42use crate::output::OutputMode;
43use crate::style::Styles;
44use crate::theme::Theme;
45use crate::EmbeddedTemplates;
46
47/// A renderer with pre-registered templates.
48///
49/// Use this when your application has multiple templates that are rendered
50/// repeatedly. Templates are compiled once and reused.
51///
52/// # Template Sources
53///
54/// Templates can come from multiple sources:
55///
56/// 1. Inline strings via [`add_template`](Self::add_template) - highest priority
57/// 2. Filesystem directories via [`add_template_dir`](Self::add_template_dir)
58/// 3. Embedded content via [`with_embedded`](Self::with_embedded)
59///
60/// When the same name exists in multiple sources, inline templates take
61/// precedence over file-based templates.
62///
63/// Note: File-based templates must have unique names across all registered
64/// directories. If the same name exists in multiple directories, it is treated
65/// as a collision error.
66///
67/// # Example: Inline Templates
68///
69/// ```rust
70/// use standout_render::{Renderer, Theme};
71/// use console::Style;
72/// use serde::Serialize;
73///
74/// let theme = Theme::new()
75/// .add("title", Style::new().bold())
76/// .add("count", Style::new().cyan());
77///
78/// let mut renderer = Renderer::new(theme).unwrap();
79/// renderer.add_template("header", r#"[title]{{ title }}[/title]"#).unwrap();
80/// renderer.add_template("stats", r#"Count: [count]{{ n }}[/count]"#).unwrap();
81///
82/// #[derive(Serialize)]
83/// struct Header { title: String }
84///
85/// #[derive(Serialize)]
86/// struct Stats { n: usize }
87///
88/// let h = renderer.render("header", &Header { title: "Report".into() }).unwrap();
89/// let s = renderer.render("stats", &Stats { n: 42 }).unwrap();
90/// ```
91///
92/// # Example: File-Based Templates
93///
94/// ```rust,ignore
95/// use standout_render::{Renderer, Theme};
96///
97/// let mut renderer = Renderer::new(Theme::new())?;
98///
99/// // Register template directory
100/// renderer.add_template_dir("./templates")?;
101///
102/// // Templates are resolved by relative path:
103/// // "config" -> ./templates/config.jinja
104/// // "todos/list" -> ./templates/todos/list.jinja
105/// let output = renderer.render("config", &data)?;
106/// ```
107///
108/// # Hot Reloading (Development)
109///
110/// In debug builds, file-based templates are re-read from disk on each render.
111/// This enables editing templates without recompiling:
112///
113/// ```bash
114/// # Edit template
115/// vim templates/todos/list.jinja
116///
117/// # Re-run - changes are picked up immediately
118/// cargo run -- todos list
119/// ```
120pub struct Renderer {
121 engine: Box<dyn TemplateEngine>,
122 /// Registry for file-based template resolution
123 registry: TemplateRegistry,
124 /// Whether the registry has been initialized from directories
125 registry_initialized: bool,
126 /// Registered template directories (for lazy initialization)
127 template_dirs: Vec<std::path::PathBuf>,
128 /// Resolved styles for BBParser post-processing
129 styles: Styles,
130 /// Output mode for BBParser transform selection
131 output_mode: OutputMode,
132}
133
134impl Renderer {
135 /// Creates a new renderer with automatic color detection.
136 ///
137 /// Color mode is detected automatically from the OS settings.
138 /// Styles are resolved for the detected mode.
139 ///
140 /// # Errors
141 ///
142 /// Returns an error if any style aliases are invalid (dangling or cyclic).
143 /// Returns an error if any style aliases are invalid (dangling or cyclic).
144 pub fn new(theme: Theme) -> Result<Self, RenderError> {
145 Self::with_output(theme, OutputMode::Auto)
146 }
147
148 /// Creates a new renderer with explicit output mode.
149 ///
150 /// Color mode is detected automatically from the OS settings.
151 /// Styles are resolved for the detected mode.
152 ///
153 /// # Errors
154 ///
155 /// Returns an error if any style aliases are invalid (dangling or cyclic).
156 pub fn with_output(theme: Theme, mode: OutputMode) -> Result<Self, RenderError> {
157 Self::with_output_and_engine(theme, mode, Box::new(MiniJinjaEngine::new()))
158 }
159
160 /// Creates a new renderer with explicit output mode and template engine.
161 ///
162 /// This allows injecting a custom template engine implementation.
163 pub fn with_output_and_engine(
164 theme: Theme,
165 mode: OutputMode,
166 engine: Box<dyn TemplateEngine>,
167 ) -> Result<Self, RenderError> {
168 // Validate style aliases before creating the renderer
169 theme
170 .validate()
171 .map_err(|e| RenderError::StyleError(e.to_string()))?;
172
173 // Detect color mode and resolve styles for that mode
174 let color_mode = super::super::theme::detect_color_mode();
175 let styles = theme.resolve_styles(Some(color_mode));
176
177 Ok(Self {
178 engine,
179 registry: TemplateRegistry::new(),
180 registry_initialized: false,
181 template_dirs: Vec::new(),
182 styles,
183 output_mode: mode,
184 })
185 }
186
187 /// Registers a named inline template.
188 ///
189 /// Inline templates have the highest priority and will shadow any
190 /// file-based templates with the same name.
191 ///
192 /// The template is compiled immediately; errors are returned if syntax is invalid.
193 ///
194 /// # Example
195 ///
196 /// ```rust,ignore
197 /// renderer.add_template("header", r#"[title]{{ title }}[/title]"#)?;
198 /// ```
199 pub fn add_template(&mut self, name: &str, source: &str) -> Result<(), RenderError> {
200 // Add to engine for compilation
201 self.engine.add_template(name, source)?;
202 // Also add to registry for consistency
203 self.registry.add_inline(name, source);
204 Ok(())
205 }
206
207 /// Adds a directory to search for template files.
208 ///
209 /// Templates in the directory are resolved by their relative path without
210 /// extension. For example, with directory `./templates`:
211 ///
212 /// - `"config"` → `./templates/config.jinja`
213 /// - `"todos/list"` → `./templates/todos/list.jinja`
214 ///
215 /// # Extension Priority
216 ///
217 /// Recognized extensions in priority order: `.jinja`, `.jinja2`, `.j2`, `.txt`
218 ///
219 /// If multiple files share the same base name with different extensions,
220 /// the higher-priority extension wins for extensionless lookups.
221 ///
222 /// # Multiple Directories
223 ///
224 /// Multiple directories can be registered. However, template names must be
225 /// unique across all directories.
226 ///
227 /// # Collision Detection
228 ///
229 /// If the same template name exists in multiple directories, an error
230 /// is returned (either immediately or during `refresh()`) with details
231 /// about the conflicting files. Strict uniqueness is enforced to prevent
232 /// ambiguous template resolution.
233 ///
234 /// # Lazy Initialization
235 ///
236 /// Directory walking happens lazily on first render (or explicit [`refresh`](Self::refresh)).
237 /// In development mode, this is automatic. Call `refresh()` if you add
238 /// directories after the first render.
239 ///
240 /// # Errors
241 ///
242 /// Returns an error if the directory doesn't exist or isn't readable.
243 ///
244 /// # Example
245 ///
246 /// ```rust,ignore
247 /// renderer.add_template_dir("./templates")?;
248 /// renderer.add_template_dir("./plugin-templates")?;
249 ///
250 /// // "config" resolves from first directory that has it
251 /// let output = renderer.render("config", &data)?;
252 /// ```
253 pub fn add_template_dir<P: AsRef<Path>>(&mut self, path: P) -> Result<(), RenderError> {
254 let path = path.as_ref();
255
256 // Validate the directory exists
257 if !path.exists() {
258 return Err(RenderError::OperationError(format!(
259 "Template directory does not exist: {}",
260 path.display()
261 )));
262 }
263 if !path.is_dir() {
264 return Err(RenderError::OperationError(format!(
265 "Path is not a directory: {}",
266 path.display()
267 )));
268 }
269
270 self.template_dirs.push(path.to_path_buf());
271 // Mark as needing re-initialization
272 self.registry_initialized = false;
273 Ok(())
274 }
275
276 /// Loads pre-embedded templates for release builds.
277 ///
278 /// Embedded templates are stored directly in memory, avoiding filesystem
279 /// access at runtime. This is useful for deployment where template files
280 /// may not be available.
281 ///
282 /// # Arguments
283 ///
284 /// * `templates` - Map of template name to content
285 ///
286 /// # Example
287 ///
288 /// ```rust,ignore
289 /// // Generated at build time
290 /// let embedded = standout_render::embed_templates!("./templates");
291 ///
292 /// let mut renderer = Renderer::new(theme)?;
293 /// renderer.with_embedded(embedded);
294 /// ```
295 pub fn with_embedded(&mut self, templates: HashMap<String, String>) -> &mut Self {
296 self.registry.add_embedded(templates);
297 self
298 }
299
300 /// Loads templates from an `EmbeddedTemplates` source.
301 ///
302 /// This is the recommended way to use `embed_templates!` with `Renderer`.
303 /// The embedded templates are converted to a registry that supports both
304 /// extensionless and with-extension lookups.
305 ///
306 /// In debug mode, if the source path exists, templates are loaded from disk
307 /// (enabling hot-reload). Otherwise, embedded content is used.
308 ///
309 /// # Example
310 ///
311 /// ```rust,ignore
312 /// use standout_render::{embed_templates, Renderer, Theme};
313 ///
314 /// let mut renderer = Renderer::new(Theme::new())?;
315 /// renderer.with_embedded_source(embed_templates!("./templates"));
316 ///
317 /// // Now you can render any template from the embedded source
318 /// let output = renderer.render("list", &data)?;
319 /// ```
320 pub fn with_embedded_source(&mut self, source: EmbeddedTemplates) -> &mut Self {
321 // Convert EmbeddedTemplates to TemplateRegistry
322 let template_registry = TemplateRegistry::from(source);
323
324 // Add all templates from the registry to both engine and registry
325 // This mirrors the behavior of add_template()
326 for name in template_registry.names() {
327 if let Ok(content) = template_registry.get_content(name) {
328 // Add to engine (required for includes to work)
329 // Ignore errors for duplicate names (e.g., "foo" and "foo.jinja" have same content)
330 let _ = self.engine.add_template(name, &content);
331 // Add to registry for name resolution
332 self.registry.add_inline(name, &content);
333 }
334 }
335 self
336 }
337
338 /// Sets the output mode for subsequent renders.
339 ///
340 /// This allows changing the output mode without creating a new renderer,
341 /// which is useful when the same templates need to be rendered with
342 /// different output modes.
343 ///
344 /// # Example
345 ///
346 /// ```rust,ignore
347 /// let mut renderer = Renderer::new(theme)?;
348 ///
349 /// // Render with terminal colors
350 /// renderer.set_output_mode(OutputMode::Term);
351 /// let colored = renderer.render("list", &data)?;
352 ///
353 /// // Render plain text
354 /// renderer.set_output_mode(OutputMode::Text);
355 /// let plain = renderer.render("list", &data)?;
356 /// ```
357 pub fn set_output_mode(&mut self, mode: OutputMode) {
358 self.output_mode = mode;
359 }
360
361 /// Forces a rebuild of the template resolution map.
362 ///
363 /// This re-walks all registered template directories and rebuilds the
364 /// resolution map. Call this if:
365 ///
366 /// - You've added template directories after the first render
367 /// - Template files have been added/removed from disk
368 ///
369 /// In development mode, this is called automatically on first render.
370 ///
371 /// # Errors
372 ///
373 /// Returns an error if directory walking fails or template collisions are detected.
374 pub fn refresh(&mut self) -> Result<(), RenderError> {
375 self.initialize_registry()
376 }
377
378 /// Initializes the registry from registered template directories.
379 ///
380 /// Called lazily on first render or explicitly via `refresh()`.
381 fn initialize_registry(&mut self) -> Result<(), RenderError> {
382 // Clear existing file-based templates (keep inline)
383 let mut new_registry = TemplateRegistry::new();
384
385 // Walk each directory and collect templates
386 for dir in &self.template_dirs {
387 let files = walk_template_dir(dir).map_err(|e| {
388 RenderError::OperationError(format!(
389 "Failed to walk template directory {}: {}",
390 dir.display(),
391 e
392 ))
393 })?;
394
395 new_registry
396 .add_from_files(files)
397 .map_err(|e| RenderError::OperationError(e.to_string()))?;
398 }
399
400 self.registry = new_registry;
401 self.registry_initialized = true;
402 Ok(())
403 }
404
405 /// Ensures the registry is initialized, doing so lazily if needed.
406 fn ensure_registry_initialized(&mut self) -> Result<(), RenderError> {
407 if !self.registry_initialized && !self.template_dirs.is_empty() {
408 self.initialize_registry()?;
409 }
410 Ok(())
411 }
412
413 /// Renders a registered template with the given data.
414 ///
415 /// Templates are looked up in this order:
416 ///
417 /// 1. Inline templates (added via [`add_template`](Self::add_template))
418 /// 2. File-based templates (from [`add_template_dir`](Self::add_template_dir))
419 ///
420 /// # Hot Reloading (Development)
421 ///
422 /// In debug builds, file-based templates are re-read from disk on each render.
423 /// This enables editing templates without recompiling the application.
424 ///
425 /// # Errors
426 ///
427 /// Returns an error if the template name is not found or rendering fails.
428 ///
429 /// # Example
430 ///
431 /// ```rust,ignore
432 /// let output = renderer.render("todos/list", &data)?;
433 /// ```
434 pub fn render<T: Serialize>(&mut self, name: &str, data: &T) -> Result<String, RenderError> {
435 // First, check if it's an inline template
436 // We check this first to avoid filesystem lookups for known templates.
437 // In debug mode, if it's a file-based template, we want to skip this check
438 // to force a reload from disk.
439
440 let is_inline = self
441 .registry
442 .get(name)
443 .is_ok_and(|t| matches!(t, ResolvedTemplate::Inline(_)));
444
445 // Convert data to serde_json::Value for the engine
446 let data_value = serde_json::to_value(data)?;
447
448 // In release mode: always use engine cache if available.
449 // In debug mode: only use engine cache if it's an inline template (which doesn't change on disk).
450 let template_output = if !cfg!(debug_assertions) || is_inline {
451 // Try to render with the engine's cached template
452 match self.engine.render_named(name, &data_value) {
453 Ok(output) => output,
454 Err(_) => {
455 // Template not in cache, load and render
456 self.ensure_registry_initialized()?;
457 let content = self.get_template_content(name)?;
458 self.engine.add_template(name, &content)?;
459 self.engine.render_named(name, &data_value)?
460 }
461 }
462 } else {
463 // Debug mode with file-based template: always reload
464 self.ensure_registry_initialized()?;
465 let content = self.get_template_content(name)?;
466 self.engine.add_template(name, &content)?;
467 self.engine.render_named(name, &data_value)?
468 };
469
470 // Pass 2: BBParser style tag processing
471 let final_output = self.apply_style_tags(&template_output);
472
473 Ok(final_output)
474 }
475
476 /// Applies BBParser style tag post-processing.
477 fn apply_style_tags(&self, output: &str) -> String {
478 let transform = match self.output_mode {
479 OutputMode::Auto => {
480 if self.output_mode.should_use_color() {
481 TagTransform::Apply
482 } else {
483 TagTransform::Remove
484 }
485 }
486 OutputMode::Term => TagTransform::Apply,
487 OutputMode::Text => TagTransform::Remove,
488 OutputMode::TermDebug => TagTransform::Keep,
489 OutputMode::Json | OutputMode::Yaml | OutputMode::Xml | OutputMode::Csv => {
490 TagTransform::Remove
491 }
492 };
493
494 let resolved_styles = self.styles.to_resolved_map();
495 let parser = BBParser::new(resolved_styles, transform)
496 .unknown_behavior(UnknownTagBehavior::Passthrough);
497 parser.parse(output)
498 }
499
500 /// Gets template content, re-reading from disk in debug mode.
501 fn get_template_content(&self, name: &str) -> Result<String, RenderError> {
502 let resolved = self
503 .registry
504 .get(name)
505 .map_err(|e| RenderError::TemplateNotFound(e.to_string()))?;
506
507 match resolved {
508 ResolvedTemplate::Inline(content) => Ok(content),
509 ResolvedTemplate::File(path) => {
510 // In debug mode, always re-read for hot reloading
511 // In release mode, we still read (could optimize with caching)
512 std::fs::read_to_string(&path).map_err(|e| {
513 RenderError::IoError(std::io::Error::other(format!(
514 "Failed to read template {}: {}",
515 path.display(),
516 e
517 )))
518 })
519 }
520 }
521 }
522
523 /// Returns the number of registered templates.
524 ///
525 /// This includes both inline and file-based templates.
526 /// Note: File-based templates are counted with both extensionless and
527 /// with-extension names, so this may be higher than the number of files.
528 pub fn template_count(&self) -> usize {
529 self.registry.len()
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use console::Style;
537 use serde::Serialize;
538 use std::io::Write;
539 use tempfile::TempDir;
540
541 #[derive(Serialize)]
542 struct SimpleData {
543 message: String,
544 }
545
546 #[test]
547 fn test_renderer_add_and_render() {
548 let theme = Theme::new().add("ok", Style::new().green());
549 let mut renderer = Renderer::with_output(theme, OutputMode::Text).unwrap();
550
551 renderer
552 .add_template("test", r#"[ok]{{ message }}[/ok]"#)
553 .unwrap();
554
555 let output = renderer
556 .render(
557 "test",
558 &SimpleData {
559 message: "hi".into(),
560 },
561 )
562 .unwrap();
563 assert_eq!(output, "hi");
564 }
565
566 #[test]
567 fn test_renderer_unknown_template_error() {
568 let theme = Theme::new();
569 let mut renderer = Renderer::with_output(theme, OutputMode::Text).unwrap();
570
571 let result = renderer.render(
572 "nonexistent",
573 &SimpleData {
574 message: "x".into(),
575 },
576 );
577 assert!(result.is_err());
578 }
579
580 #[test]
581 fn test_renderer_multiple_templates() {
582 let theme = Theme::new()
583 .add("a", Style::new().red())
584 .add("b", Style::new().blue());
585
586 let mut renderer = Renderer::with_output(theme, OutputMode::Text).unwrap();
587 renderer
588 .add_template("tmpl_a", r#"A: [a]{{ message }}[/a]"#)
589 .unwrap();
590 renderer
591 .add_template("tmpl_b", r#"B: [b]{{ message }}[/b]"#)
592 .unwrap();
593
594 let data = SimpleData {
595 message: "test".into(),
596 };
597
598 assert_eq!(renderer.render("tmpl_a", &data).unwrap(), "A: test");
599 assert_eq!(renderer.render("tmpl_b", &data).unwrap(), "B: test");
600 }
601
602 #[test]
603 fn test_renderer_fails_with_invalid_theme() {
604 let theme = Theme::new().add("orphan", "missing");
605 let result = Renderer::new(theme);
606 assert!(result.is_err());
607 }
608
609 #[test]
610 fn test_renderer_succeeds_with_valid_aliases() {
611 let theme = Theme::new()
612 .add("base", Style::new().bold())
613 .add("alias", "base");
614
615 let result = Renderer::new(theme);
616 assert!(result.is_ok());
617 }
618
619 // =========================================================================
620 // File-based template tests
621 // =========================================================================
622
623 fn create_template_file(dir: &Path, relative_path: &str, content: &str) {
624 let full_path = dir.join(relative_path);
625 if let Some(parent) = full_path.parent() {
626 std::fs::create_dir_all(parent).unwrap();
627 }
628 let mut file = std::fs::File::create(&full_path).unwrap();
629 file.write_all(content.as_bytes()).unwrap();
630 }
631
632 #[test]
633 fn test_renderer_add_template_dir() {
634 let temp_dir = TempDir::new().unwrap();
635 create_template_file(temp_dir.path(), "config.jinja", "Config: {{ value }}");
636
637 let mut renderer = Renderer::new(Theme::new()).unwrap();
638 renderer.add_template_dir(temp_dir.path()).unwrap();
639
640 #[derive(Serialize)]
641 struct Data {
642 value: String,
643 }
644
645 let output = renderer
646 .render(
647 "config",
648 &Data {
649 value: "test".into(),
650 },
651 )
652 .unwrap();
653 assert_eq!(output, "Config: test");
654 }
655
656 #[test]
657 fn test_renderer_nested_template_dir() {
658 let temp_dir = TempDir::new().unwrap();
659 create_template_file(temp_dir.path(), "todos/list.jinja", "List: {{ count }}");
660 create_template_file(temp_dir.path(), "todos/detail.jinja", "Detail: {{ id }}");
661
662 let mut renderer = Renderer::new(Theme::new()).unwrap();
663 renderer.add_template_dir(temp_dir.path()).unwrap();
664
665 #[derive(Serialize)]
666 struct ListData {
667 count: usize,
668 }
669
670 #[derive(Serialize)]
671 struct DetailData {
672 id: usize,
673 }
674
675 let list_output = renderer
676 .render("todos/list", &ListData { count: 5 })
677 .unwrap();
678 assert_eq!(list_output, "List: 5");
679
680 let detail_output = renderer
681 .render("todos/detail", &DetailData { id: 42 })
682 .unwrap();
683 assert_eq!(detail_output, "Detail: 42");
684 }
685
686 #[test]
687 fn test_renderer_template_with_extension() {
688 let temp_dir = TempDir::new().unwrap();
689 create_template_file(temp_dir.path(), "config.jinja", "Content");
690
691 let mut renderer = Renderer::new(Theme::new()).unwrap();
692 renderer.add_template_dir(temp_dir.path()).unwrap();
693
694 #[derive(Serialize)]
695 struct Empty {}
696
697 // Both with and without extension should work
698 assert!(renderer.render("config", &Empty {}).is_ok());
699 assert!(renderer.render("config.jinja", &Empty {}).is_ok());
700 }
701
702 #[test]
703 fn test_renderer_inline_shadows_file() {
704 let temp_dir = TempDir::new().unwrap();
705 create_template_file(temp_dir.path(), "config.jinja", "From file");
706
707 let mut renderer = Renderer::new(Theme::new()).unwrap();
708 renderer.add_template_dir(temp_dir.path()).unwrap();
709
710 // Add inline template with same name (should shadow file)
711 renderer.add_template("config", "From inline").unwrap();
712
713 #[derive(Serialize)]
714 struct Empty {}
715
716 let output = renderer.render("config", &Empty {}).unwrap();
717 assert_eq!(output, "From inline");
718 }
719
720 #[test]
721 fn test_renderer_nonexistent_dir_error() {
722 let mut renderer = Renderer::new(Theme::new()).unwrap();
723 let result = renderer.add_template_dir("/nonexistent/path/that/does/not/exist");
724 assert!(result.is_err());
725 }
726
727 #[test]
728 fn test_renderer_hot_reload() {
729 let temp_dir = TempDir::new().unwrap();
730 create_template_file(temp_dir.path(), "hot.jinja", "Version 1");
731
732 let mut renderer = Renderer::new(Theme::new()).unwrap();
733 renderer.add_template_dir(temp_dir.path()).unwrap();
734
735 #[derive(Serialize)]
736 struct Empty {}
737
738 // First render
739 let output1 = renderer.render("hot", &Empty {}).unwrap();
740 assert_eq!(output1, "Version 1");
741
742 // Modify the file
743 create_template_file(temp_dir.path(), "hot.jinja", "Version 2");
744
745 // Second render should see the change (hot reload)
746 let output2 = renderer.render("hot", &Empty {}).unwrap();
747 assert_eq!(output2, "Version 2");
748 }
749
750 #[test]
751 fn test_renderer_extension_priority() {
752 let temp_dir = TempDir::new().unwrap();
753 // Create files with different extensions
754 create_template_file(temp_dir.path(), "config.j2", "From j2");
755 create_template_file(temp_dir.path(), "config.jinja", "From jinja");
756
757 let mut renderer = Renderer::new(Theme::new()).unwrap();
758 renderer.add_template_dir(temp_dir.path()).unwrap();
759
760 #[derive(Serialize)]
761 struct Empty {}
762
763 // Extensionless should resolve to .jinja (higher priority)
764 let output = renderer.render("config", &Empty {}).unwrap();
765 assert_eq!(output, "From jinja");
766 }
767
768 #[test]
769 fn test_renderer_with_embedded() {
770 let mut renderer = Renderer::new(Theme::new()).unwrap();
771
772 let mut embedded = HashMap::new();
773 embedded.insert("embedded".to_string(), "Embedded: {{ val }}".to_string());
774 renderer.with_embedded(embedded);
775
776 #[derive(Serialize)]
777 struct Data {
778 val: String,
779 }
780
781 let output = renderer
782 .render("embedded", &Data { val: "ok".into() })
783 .unwrap();
784 assert_eq!(output, "Embedded: ok");
785 }
786
787 #[test]
788 fn test_renderer_set_output_mode() {
789 use console::Style;
790
791 // Use force_styling(true) to ensure ANSI codes are output even in tests
792 let theme = Theme::new().add("highlight", Style::new().green().force_styling(true));
793 let mut renderer = Renderer::with_output(theme, OutputMode::Term).unwrap();
794 renderer
795 .add_template("test", "[highlight]hello[/highlight]")
796 .unwrap();
797
798 #[derive(Serialize)]
799 struct Empty {}
800
801 // With Term mode, should have ANSI codes
802 let term_output = renderer.render("test", &Empty {}).unwrap();
803 assert!(
804 term_output.contains("\x1b["),
805 "Expected ANSI codes in Term mode, got: {:?}",
806 term_output
807 );
808
809 // Switch to Text mode
810 renderer.set_output_mode(OutputMode::Text);
811 let text_output = renderer.render("test", &Empty {}).unwrap();
812 assert_eq!(text_output, "hello", "Expected plain text in Text mode");
813 }
814
815 #[test]
816 fn test_renderer_with_embedded_source() {
817 use crate::{EmbeddedSource, TemplateResource};
818
819 // Create an EmbeddedTemplates source (simulating embed_templates! output)
820 static ENTRIES: &[(&str, &str)] = &[
821 ("greeting.jinja", "Hello, {{ name }}!"),
822 ("_partial.jinja", "PARTIAL"),
823 (
824 "with_include.jinja",
825 "Before {% include '_partial' %} After",
826 ),
827 ];
828 let source: EmbeddedSource<TemplateResource> =
829 EmbeddedSource::new(ENTRIES, "/nonexistent/path");
830
831 let mut renderer = Renderer::new(Theme::new()).unwrap();
832 renderer.with_embedded_source(source);
833
834 #[derive(Serialize)]
835 struct Data {
836 name: String,
837 }
838
839 // Test basic rendering
840 let output = renderer
841 .render(
842 "greeting",
843 &Data {
844 name: "World".into(),
845 },
846 )
847 .unwrap();
848 assert_eq!(output, "Hello, World!");
849
850 // Test extensionless access
851 let output2 = renderer
852 .render(
853 "greeting.jinja",
854 &Data {
855 name: "Test".into(),
856 },
857 )
858 .unwrap();
859 assert_eq!(output2, "Hello, Test!");
860
861 // Test includes work with extensionless names
862 #[derive(Serialize)]
863 struct Empty {}
864 let output3 = renderer.render("with_include", &Empty {}).unwrap();
865 assert_eq!(output3, "Before PARTIAL After");
866 }
867 #[test]
868 fn test_renderer_with_custom_engine() {
869 use std::collections::HashMap;
870
871 struct MockEngine {
872 templates: HashMap<String, String>,
873 }
874
875 impl TemplateEngine for MockEngine {
876 fn add_template(&mut self, name: &str, source: &str) -> Result<(), RenderError> {
877 self.templates.insert(name.to_string(), source.to_string());
878 Ok(())
879 }
880
881 fn has_template(&self, name: &str) -> bool {
882 self.templates.contains_key(name)
883 }
884
885 fn render_template(
886 &self,
887 source: &str,
888 data: &serde_json::Value,
889 ) -> Result<String, RenderError> {
890 Ok(format!("Mock Render: {} data={}", source, data))
891 }
892
893 fn render_named(
894 &self,
895 name: &str,
896 data: &serde_json::Value,
897 ) -> Result<String, RenderError> {
898 if let Some(src) = self.templates.get(name) {
899 Ok(format!("Mock Named: {} data={}", src, data))
900 } else {
901 Err(RenderError::TemplateNotFound(name.to_string()))
902 }
903 }
904
905 fn render_with_context(
906 &self,
907 template: &str,
908 data: &serde_json::Value,
909 _context: HashMap<String, serde_json::Value>,
910 ) -> Result<String, RenderError> {
911 self.render_template(template, data)
912 }
913
914 fn supports_includes(&self) -> bool {
915 false
916 }
917 fn supports_filters(&self) -> bool {
918 false
919 }
920 fn supports_control_flow(&self) -> bool {
921 false
922 }
923 }
924
925 let engine = Box::new(MockEngine {
926 templates: HashMap::new(),
927 });
928 let mut renderer =
929 Renderer::with_output_and_engine(Theme::new(), OutputMode::Text, engine).unwrap();
930
931 renderer.add_template("test", "content").unwrap();
932
933 #[derive(Serialize)]
934 struct Data {
935 val: i32,
936 }
937
938 let output = renderer.render("test", &Data { val: 42 }).unwrap();
939 // The mock engine formats as "Mock Render: {}" or "Mock Named: {}"
940 // Since we added it as named template, render() calls render_named logic.
941 // Wait, render() logic:
942 // if debug_assertions || is_inline -> render_named
943 // The MockEngine::render_named returns "Mock Named: content data={...}"
944 assert_eq!(output, "Mock Named: content data={\"val\":42}");
945 }
946
947 #[test]
948 fn test_renderer_with_simple_engine() {
949 use crate::template::SimpleEngine;
950
951 let engine = Box::new(SimpleEngine::new());
952 let mut renderer =
953 Renderer::with_output_and_engine(Theme::new(), OutputMode::Text, engine).unwrap();
954
955 // Add an inline template using SimpleEngine syntax
956 renderer.add_template("welcome", "Hello, {name}!").unwrap();
957
958 #[derive(Serialize)]
959 struct User {
960 name: String,
961 }
962
963 // Render it
964 let output = renderer
965 .render(
966 "welcome",
967 &User {
968 name: "Standout".into(),
969 },
970 )
971 .unwrap();
972 assert_eq!(output, "Hello, Standout!");
973 }
974}