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}