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_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}