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