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::{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 minijinja::{Environment, Error};
37use serde::Serialize;
38use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
39
40use super::filters::register_filters;
41use super::registry::{walk_template_dir, ResolvedTemplate, TemplateRegistry};
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::{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::{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 env: Environment<'static>,
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 pub fn new(theme: Theme) -> Result<Self, Error> {
144 Self::with_output(theme, OutputMode::Auto)
145 }
146
147 /// Creates a new renderer with explicit output mode.
148 ///
149 /// Color mode is detected automatically from the OS settings.
150 /// Styles are resolved for the detected mode.
151 ///
152 /// # Errors
153 ///
154 /// Returns an error if any style aliases are invalid (dangling or cyclic).
155 pub fn with_output(theme: Theme, mode: OutputMode) -> Result<Self, Error> {
156 // Validate style aliases before creating the renderer
157 theme
158 .validate()
159 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
160
161 // Detect color mode and resolve styles for that mode
162 let color_mode = super::super::theme::detect_color_mode();
163 let styles = theme.resolve_styles(Some(color_mode));
164
165 let mut env = Environment::new();
166 register_filters(&mut env);
167 Ok(Self {
168 env,
169 registry: TemplateRegistry::new(),
170 registry_initialized: false,
171 template_dirs: Vec::new(),
172 styles,
173 output_mode: mode,
174 })
175 }
176
177 /// Registers a named inline template.
178 ///
179 /// Inline templates have the highest priority and will shadow any
180 /// file-based templates with the same name.
181 ///
182 /// The template is compiled immediately; errors are returned if syntax is invalid.
183 ///
184 /// # Example
185 ///
186 /// ```rust,ignore
187 /// renderer.add_template("header", r#"[title]{{ title }}[/title]"#)?;
188 /// ```
189 pub fn add_template(&mut self, name: &str, source: &str) -> Result<(), Error> {
190 // Add to minijinja environment for compilation
191 self.env
192 .add_template_owned(name.to_string(), source.to_string())?;
193 // Also add to registry for consistency
194 self.registry.add_inline(name, source);
195 Ok(())
196 }
197
198 /// Adds a directory to search for template files.
199 ///
200 /// Templates in the directory are resolved by their relative path without
201 /// extension. For example, with directory `./templates`:
202 ///
203 /// - `"config"` → `./templates/config.jinja`
204 /// - `"todos/list"` → `./templates/todos/list.jinja`
205 ///
206 /// # Extension Priority
207 ///
208 /// Recognized extensions in priority order: `.jinja`, `.jinja2`, `.j2`, `.txt`
209 ///
210 /// If multiple files share the same base name with different extensions,
211 /// the higher-priority extension wins for extensionless lookups.
212 ///
213 /// # Multiple Directories
214 ///
215 /// Multiple directories can be registered. However, template names must be
216 /// unique across all directories.
217 ///
218 /// # Collision Detection
219 ///
220 /// If the same template name exists in multiple directories, an error
221 /// is returned (either immediately or during `refresh()`) with details
222 /// about the conflicting files. Strict uniqueness is enforced to prevent
223 /// ambiguous template resolution.
224 ///
225 /// # Lazy Initialization
226 ///
227 /// Directory walking happens lazily on first render (or explicit [`refresh`](Self::refresh)).
228 /// In development mode, this is automatic. Call `refresh()` if you add
229 /// directories after the first render.
230 ///
231 /// # Errors
232 ///
233 /// Returns an error if the directory doesn't exist or isn't readable.
234 ///
235 /// # Example
236 ///
237 /// ```rust,ignore
238 /// renderer.add_template_dir("./templates")?;
239 /// renderer.add_template_dir("./plugin-templates")?;
240 ///
241 /// // "config" resolves from first directory that has it
242 /// let output = renderer.render("config", &data)?;
243 /// ```
244 pub fn add_template_dir<P: AsRef<Path>>(&mut self, path: P) -> Result<(), Error> {
245 let path = path.as_ref();
246
247 // Validate the directory exists
248 if !path.exists() {
249 return Err(Error::new(
250 minijinja::ErrorKind::InvalidOperation,
251 format!("Template directory does not exist: {}", path.display()),
252 ));
253 }
254 if !path.is_dir() {
255 return Err(Error::new(
256 minijinja::ErrorKind::InvalidOperation,
257 format!("Path is not a directory: {}", path.display()),
258 ));
259 }
260
261 self.template_dirs.push(path.to_path_buf());
262 // Mark as needing re-initialization
263 self.registry_initialized = false;
264 Ok(())
265 }
266
267 /// Loads pre-embedded templates for release builds.
268 ///
269 /// Embedded templates are stored directly in memory, avoiding filesystem
270 /// access at runtime. This is useful for deployment where template files
271 /// may not be available.
272 ///
273 /// # Arguments
274 ///
275 /// * `templates` - Map of template name to content
276 ///
277 /// # Example
278 ///
279 /// ```rust,ignore
280 /// // Generated at build time
281 /// let embedded = standout::embed_templates!("./templates");
282 ///
283 /// let mut renderer = Renderer::new(theme)?;
284 /// renderer.with_embedded(embedded);
285 /// ```
286 pub fn with_embedded(&mut self, templates: HashMap<String, String>) -> &mut Self {
287 self.registry.add_embedded(templates);
288 self
289 }
290
291 /// Loads templates from an `EmbeddedTemplates` source.
292 ///
293 /// This is the recommended way to use `embed_templates!` with `Renderer`.
294 /// The embedded templates are converted to a registry that supports both
295 /// extensionless and with-extension lookups.
296 ///
297 /// In debug mode, if the source path exists, templates are loaded from disk
298 /// (enabling hot-reload). Otherwise, embedded content is used.
299 ///
300 /// # Example
301 ///
302 /// ```rust,ignore
303 /// use standout::{embed_templates, Renderer, Theme};
304 ///
305 /// let mut renderer = Renderer::new(Theme::new())?;
306 /// renderer.with_embedded_source(embed_templates!("./templates"));
307 ///
308 /// // Now you can render any template from the embedded source
309 /// let output = renderer.render("list", &data)?;
310 /// ```
311 pub fn with_embedded_source(&mut self, source: EmbeddedTemplates) -> &mut Self {
312 // Convert EmbeddedTemplates to TemplateRegistry
313 let template_registry = TemplateRegistry::from(source);
314
315 // Add all templates from the registry to both env and registry
316 // This mirrors the behavior of add_template()
317 for name in template_registry.names() {
318 if let Ok(content) = template_registry.get_content(name) {
319 // Add to minijinja environment (required for includes to work)
320 // Ignore errors for duplicate names (e.g., "foo" and "foo.jinja" have same content)
321 let _ = self
322 .env
323 .add_template_owned(name.to_string(), content.clone());
324 // Add to registry for name resolution
325 self.registry.add_inline(name, &content);
326 }
327 }
328 self
329 }
330
331 /// Sets the output mode for subsequent renders.
332 ///
333 /// This allows changing the output mode without creating a new renderer,
334 /// which is useful when the same templates need to be rendered with
335 /// different output modes.
336 ///
337 /// # Example
338 ///
339 /// ```rust,ignore
340 /// let mut renderer = Renderer::new(theme)?;
341 ///
342 /// // Render with terminal colors
343 /// renderer.set_output_mode(OutputMode::Term);
344 /// let colored = renderer.render("list", &data)?;
345 ///
346 /// // Render plain text
347 /// renderer.set_output_mode(OutputMode::Text);
348 /// let plain = renderer.render("list", &data)?;
349 /// ```
350 pub fn set_output_mode(&mut self, mode: OutputMode) {
351 self.output_mode = mode;
352 }
353
354 /// Forces a rebuild of the template resolution map.
355 ///
356 /// This re-walks all registered template directories and rebuilds the
357 /// resolution map. Call this if:
358 ///
359 /// - You've added template directories after the first render
360 /// - Template files have been added/removed from disk
361 ///
362 /// In development mode, this is called automatically on first render.
363 ///
364 /// # Errors
365 ///
366 /// Returns an error if directory walking fails or template collisions are detected.
367 pub fn refresh(&mut self) -> Result<(), Error> {
368 self.initialize_registry()
369 }
370
371 /// Initializes the registry from registered template directories.
372 ///
373 /// Called lazily on first render or explicitly via `refresh()`.
374 fn initialize_registry(&mut self) -> Result<(), Error> {
375 // Clear existing file-based templates (keep inline)
376 let mut new_registry = TemplateRegistry::new();
377
378 // Walk each directory and collect templates
379 for dir in &self.template_dirs {
380 let files = walk_template_dir(dir).map_err(|e| {
381 Error::new(
382 minijinja::ErrorKind::InvalidOperation,
383 format!("Failed to walk template directory {}: {}", dir.display(), e),
384 )
385 })?;
386
387 new_registry
388 .add_from_files(files)
389 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
390 }
391
392 self.registry = new_registry;
393 self.registry_initialized = true;
394 Ok(())
395 }
396
397 /// Ensures the registry is initialized, doing so lazily if needed.
398 fn ensure_registry_initialized(&mut self) -> Result<(), Error> {
399 if !self.registry_initialized && !self.template_dirs.is_empty() {
400 self.initialize_registry()?;
401 }
402 Ok(())
403 }
404
405 /// Renders a registered template with the given data.
406 ///
407 /// Templates are looked up in this order:
408 ///
409 /// 1. Inline templates (added via [`add_template`](Self::add_template))
410 /// 2. File-based templates (from [`add_template_dir`](Self::add_template_dir))
411 ///
412 /// # Hot Reloading (Development)
413 ///
414 /// In debug builds, file-based templates are re-read from disk on each render.
415 /// This enables editing templates without recompiling the application.
416 ///
417 /// # Errors
418 ///
419 /// Returns an error if the template name is not found or rendering fails.
420 ///
421 /// # Example
422 ///
423 /// ```rust,ignore
424 /// let output = renderer.render("todos/list", &data)?;
425 /// ```
426 pub fn render<T: Serialize>(&mut self, name: &str, data: &T) -> Result<String, Error> {
427 // First, try the minijinja environment (inline templates)
428 // We check this first to avoid filesystem lookups for known templates.
429 // In debug mode, if it's a file-based template, we want to skip this check
430 // to force a reload from disk.
431 //
432 // NOTE: We can't easily distinguish inline vs file in the env, so we rely on
433 // the registry. Inline templates are added to both env and registry.
434 //
435 // If it's inline, we can use the env cache safely even in debug.
436 // If it's potentially file-based (or not yet known), we proceed.
437
438 let is_inline = self
439 .registry
440 .get(name)
441 .is_ok_and(|t| matches!(t, ResolvedTemplate::Inline(_)));
442
443 // In release mode: always use env cache if available.
444 // In debug mode: only use env cache if it's an inline template (which doesn't change on disk).
445 let minijinja_output =
446 if (!cfg!(debug_assertions) || is_inline) && self.env.get_template(name).is_ok() {
447 let tmpl = self.env.get_template(name)?;
448 tmpl.render(data)?
449 } else {
450 // Ensure registry is initialized for file-based templates
451 self.ensure_registry_initialized()?;
452
453 // Try file-based templates from registry
454 let content = self.get_template_content(name)?;
455
456 // In debug mode, we always re-add to update content (hot reload).
457 // In release mode, we add once and the environment caches it.
458 self.env.add_template_owned(name.to_string(), content)?;
459 let tmpl = self.env.get_template(name)?;
460 tmpl.render(data)?
461 };
462
463 // Pass 2: BBParser style tag processing
464 let final_output = self.apply_style_tags(&minijinja_output);
465
466 Ok(final_output)
467 }
468
469 /// Applies BBParser style tag post-processing.
470 fn apply_style_tags(&self, output: &str) -> String {
471 let transform = match self.output_mode {
472 OutputMode::Auto => {
473 if self.output_mode.should_use_color() {
474 TagTransform::Apply
475 } else {
476 TagTransform::Remove
477 }
478 }
479 OutputMode::Term => TagTransform::Apply,
480 OutputMode::Text => TagTransform::Remove,
481 OutputMode::TermDebug => TagTransform::Keep,
482 OutputMode::Json | OutputMode::Yaml | OutputMode::Xml | OutputMode::Csv => {
483 TagTransform::Remove
484 }
485 };
486
487 let resolved_styles = self.styles.to_resolved_map();
488 let parser = BBParser::new(resolved_styles, transform)
489 .unknown_behavior(UnknownTagBehavior::Passthrough);
490 parser.parse(output)
491 }
492
493 /// Gets template content, re-reading from disk in debug mode.
494 fn get_template_content(&self, name: &str) -> Result<String, Error> {
495 let resolved = self
496 .registry
497 .get(name)
498 .map_err(|e| Error::new(minijinja::ErrorKind::TemplateNotFound, e.to_string()))?;
499
500 match resolved {
501 ResolvedTemplate::Inline(content) => Ok(content),
502 ResolvedTemplate::File(path) => {
503 // In debug mode, always re-read for hot reloading
504 // In release mode, we still read (could optimize with caching)
505 std::fs::read_to_string(&path).map_err(|e| {
506 Error::new(
507 minijinja::ErrorKind::InvalidOperation,
508 format!("Failed to read template {}: {}", path.display(), e),
509 )
510 })
511 }
512 }
513 }
514
515 /// Returns the number of registered templates.
516 ///
517 /// This includes both inline and file-based templates.
518 /// Note: File-based templates are counted with both extensionless and
519 /// with-extension names, so this may be higher than the number of files.
520 pub fn template_count(&self) -> usize {
521 self.registry.len()
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use console::Style;
529 use serde::Serialize;
530 use std::io::Write;
531 use tempfile::TempDir;
532
533 #[derive(Serialize)]
534 struct SimpleData {
535 message: String,
536 }
537
538 #[test]
539 fn test_renderer_add_and_render() {
540 let theme = Theme::new().add("ok", Style::new().green());
541 let mut renderer = Renderer::with_output(theme, OutputMode::Text).unwrap();
542
543 renderer
544 .add_template("test", r#"[ok]{{ message }}[/ok]"#)
545 .unwrap();
546
547 let output = renderer
548 .render(
549 "test",
550 &SimpleData {
551 message: "hi".into(),
552 },
553 )
554 .unwrap();
555 assert_eq!(output, "hi");
556 }
557
558 #[test]
559 fn test_renderer_unknown_template_error() {
560 let theme = Theme::new();
561 let mut renderer = Renderer::with_output(theme, OutputMode::Text).unwrap();
562
563 let result = renderer.render(
564 "nonexistent",
565 &SimpleData {
566 message: "x".into(),
567 },
568 );
569 assert!(result.is_err());
570 }
571
572 #[test]
573 fn test_renderer_multiple_templates() {
574 let theme = Theme::new()
575 .add("a", Style::new().red())
576 .add("b", Style::new().blue());
577
578 let mut renderer = Renderer::with_output(theme, OutputMode::Text).unwrap();
579 renderer
580 .add_template("tmpl_a", r#"A: [a]{{ message }}[/a]"#)
581 .unwrap();
582 renderer
583 .add_template("tmpl_b", r#"B: [b]{{ message }}[/b]"#)
584 .unwrap();
585
586 let data = SimpleData {
587 message: "test".into(),
588 };
589
590 assert_eq!(renderer.render("tmpl_a", &data).unwrap(), "A: test");
591 assert_eq!(renderer.render("tmpl_b", &data).unwrap(), "B: test");
592 }
593
594 #[test]
595 fn test_renderer_fails_with_invalid_theme() {
596 let theme = Theme::new().add("orphan", "missing");
597 let result = Renderer::new(theme);
598 assert!(result.is_err());
599 }
600
601 #[test]
602 fn test_renderer_succeeds_with_valid_aliases() {
603 let theme = Theme::new()
604 .add("base", Style::new().bold())
605 .add("alias", "base");
606
607 let result = Renderer::new(theme);
608 assert!(result.is_ok());
609 }
610
611 // =========================================================================
612 // File-based template tests
613 // =========================================================================
614
615 fn create_template_file(dir: &Path, relative_path: &str, content: &str) {
616 let full_path = dir.join(relative_path);
617 if let Some(parent) = full_path.parent() {
618 std::fs::create_dir_all(parent).unwrap();
619 }
620 let mut file = std::fs::File::create(&full_path).unwrap();
621 file.write_all(content.as_bytes()).unwrap();
622 }
623
624 #[test]
625 fn test_renderer_add_template_dir() {
626 let temp_dir = TempDir::new().unwrap();
627 create_template_file(temp_dir.path(), "config.jinja", "Config: {{ value }}");
628
629 let mut renderer = Renderer::new(Theme::new()).unwrap();
630 renderer.add_template_dir(temp_dir.path()).unwrap();
631
632 #[derive(Serialize)]
633 struct Data {
634 value: String,
635 }
636
637 let output = renderer
638 .render(
639 "config",
640 &Data {
641 value: "test".into(),
642 },
643 )
644 .unwrap();
645 assert_eq!(output, "Config: test");
646 }
647
648 #[test]
649 fn test_renderer_nested_template_dir() {
650 let temp_dir = TempDir::new().unwrap();
651 create_template_file(temp_dir.path(), "todos/list.jinja", "List: {{ count }}");
652 create_template_file(temp_dir.path(), "todos/detail.jinja", "Detail: {{ id }}");
653
654 let mut renderer = Renderer::new(Theme::new()).unwrap();
655 renderer.add_template_dir(temp_dir.path()).unwrap();
656
657 #[derive(Serialize)]
658 struct ListData {
659 count: usize,
660 }
661
662 #[derive(Serialize)]
663 struct DetailData {
664 id: usize,
665 }
666
667 let list_output = renderer
668 .render("todos/list", &ListData { count: 5 })
669 .unwrap();
670 assert_eq!(list_output, "List: 5");
671
672 let detail_output = renderer
673 .render("todos/detail", &DetailData { id: 42 })
674 .unwrap();
675 assert_eq!(detail_output, "Detail: 42");
676 }
677
678 #[test]
679 fn test_renderer_template_with_extension() {
680 let temp_dir = TempDir::new().unwrap();
681 create_template_file(temp_dir.path(), "config.jinja", "Content");
682
683 let mut renderer = Renderer::new(Theme::new()).unwrap();
684 renderer.add_template_dir(temp_dir.path()).unwrap();
685
686 #[derive(Serialize)]
687 struct Empty {}
688
689 // Both with and without extension should work
690 assert!(renderer.render("config", &Empty {}).is_ok());
691 assert!(renderer.render("config.jinja", &Empty {}).is_ok());
692 }
693
694 #[test]
695 fn test_renderer_inline_shadows_file() {
696 let temp_dir = TempDir::new().unwrap();
697 create_template_file(temp_dir.path(), "config.jinja", "From file");
698
699 let mut renderer = Renderer::new(Theme::new()).unwrap();
700 renderer.add_template_dir(temp_dir.path()).unwrap();
701
702 // Add inline template with same name (should shadow file)
703 renderer.add_template("config", "From inline").unwrap();
704
705 #[derive(Serialize)]
706 struct Empty {}
707
708 let output = renderer.render("config", &Empty {}).unwrap();
709 assert_eq!(output, "From inline");
710 }
711
712 #[test]
713 fn test_renderer_nonexistent_dir_error() {
714 let mut renderer = Renderer::new(Theme::new()).unwrap();
715 let result = renderer.add_template_dir("/nonexistent/path/that/does/not/exist");
716 assert!(result.is_err());
717 }
718
719 #[test]
720 fn test_renderer_hot_reload() {
721 let temp_dir = TempDir::new().unwrap();
722 create_template_file(temp_dir.path(), "hot.jinja", "Version 1");
723
724 let mut renderer = Renderer::new(Theme::new()).unwrap();
725 renderer.add_template_dir(temp_dir.path()).unwrap();
726
727 #[derive(Serialize)]
728 struct Empty {}
729
730 // First render
731 let output1 = renderer.render("hot", &Empty {}).unwrap();
732 assert_eq!(output1, "Version 1");
733
734 // Modify the file
735 create_template_file(temp_dir.path(), "hot.jinja", "Version 2");
736
737 // Second render should see the change (hot reload)
738 let output2 = renderer.render("hot", &Empty {}).unwrap();
739 assert_eq!(output2, "Version 2");
740 }
741
742 #[test]
743 fn test_renderer_extension_priority() {
744 let temp_dir = TempDir::new().unwrap();
745 // Create files with different extensions
746 create_template_file(temp_dir.path(), "config.j2", "From j2");
747 create_template_file(temp_dir.path(), "config.jinja", "From jinja");
748
749 let mut renderer = Renderer::new(Theme::new()).unwrap();
750 renderer.add_template_dir(temp_dir.path()).unwrap();
751
752 #[derive(Serialize)]
753 struct Empty {}
754
755 // Extensionless should resolve to .jinja (higher priority)
756 let output = renderer.render("config", &Empty {}).unwrap();
757 assert_eq!(output, "From jinja");
758 }
759
760 #[test]
761 fn test_renderer_with_embedded() {
762 let mut renderer = Renderer::new(Theme::new()).unwrap();
763
764 let mut embedded = HashMap::new();
765 embedded.insert("embedded".to_string(), "Embedded: {{ val }}".to_string());
766 renderer.with_embedded(embedded);
767
768 #[derive(Serialize)]
769 struct Data {
770 val: String,
771 }
772
773 let output = renderer
774 .render("embedded", &Data { val: "ok".into() })
775 .unwrap();
776 assert_eq!(output, "Embedded: ok");
777 }
778
779 #[test]
780 fn test_renderer_set_output_mode() {
781 use console::Style;
782
783 // Use force_styling(true) to ensure ANSI codes are output even in tests
784 let theme = Theme::new().add("highlight", Style::new().green().force_styling(true));
785 let mut renderer = Renderer::with_output(theme, OutputMode::Term).unwrap();
786 renderer
787 .add_template("test", "[highlight]hello[/highlight]")
788 .unwrap();
789
790 #[derive(Serialize)]
791 struct Empty {}
792
793 // With Term mode, should have ANSI codes
794 let term_output = renderer.render("test", &Empty {}).unwrap();
795 assert!(
796 term_output.contains("\x1b["),
797 "Expected ANSI codes in Term mode, got: {:?}",
798 term_output
799 );
800
801 // Switch to Text mode
802 renderer.set_output_mode(OutputMode::Text);
803 let text_output = renderer.render("test", &Empty {}).unwrap();
804 assert_eq!(text_output, "hello", "Expected plain text in Text mode");
805 }
806
807 #[test]
808 fn test_renderer_with_embedded_source() {
809 use crate::{EmbeddedSource, TemplateResource};
810
811 // Create an EmbeddedTemplates source (simulating embed_templates! output)
812 static ENTRIES: &[(&str, &str)] = &[
813 ("greeting.jinja", "Hello, {{ name }}!"),
814 ("_partial.jinja", "PARTIAL"),
815 (
816 "with_include.jinja",
817 "Before {% include '_partial' %} After",
818 ),
819 ];
820 let source: EmbeddedSource<TemplateResource> =
821 EmbeddedSource::new(ENTRIES, "/nonexistent/path");
822
823 let mut renderer = Renderer::new(Theme::new()).unwrap();
824 renderer.with_embedded_source(source);
825
826 #[derive(Serialize)]
827 struct Data {
828 name: String,
829 }
830
831 // Test basic rendering
832 let output = renderer
833 .render(
834 "greeting",
835 &Data {
836 name: "World".into(),
837 },
838 )
839 .unwrap();
840 assert_eq!(output, "Hello, World!");
841
842 // Test extensionless access
843 let output2 = renderer
844 .render(
845 "greeting.jinja",
846 &Data {
847 name: "Test".into(),
848 },
849 )
850 .unwrap();
851 assert_eq!(output2, "Hello, Test!");
852
853 // Test includes work with extensionless names
854 #[derive(Serialize)]
855 struct Empty {}
856 let output3 = renderer.render("with_include", &Empty {}).unwrap();
857 assert_eq!(output3, "Before PARTIAL After");
858 }
859}