Skip to main content

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}