standout_render/template/registry.rs
1//! Template registry for file-based and inline templates.
2//!
3//! This module provides [`TemplateRegistry`], which manages template resolution
4//! from multiple sources: inline strings, filesystem directories, or embedded content.
5//!
6//! # Design
7//!
8//! The registry is a thin wrapper around [`FileRegistry<String>`](crate::file_loader::FileRegistry),
9//! providing template-specific functionality while reusing the generic file loading infrastructure.
10//!
11//! The registry uses a two-phase approach:
12//!
13//! 1. **Collection**: Templates are collected from various sources (inline, directories, embedded)
14//! 2. **Resolution**: A unified map resolves template names to their content or file paths
15//!
16//! This separation enables:
17//! - **Testability**: Resolution logic can be tested without filesystem access
18//! - **Flexibility**: Same resolution rules apply regardless of template source
19//! - **Hot reloading**: File paths can be re-read on each render in development mode
20//!
21//! # Template Resolution
22//!
23//! Templates are resolved by name using these rules:
24//!
25//! 1. **Inline templates** (added via [`TemplateRegistry::add_inline`]) have highest priority
26//! 2. **File templates** are searched in directory registration order (first directory wins)
27//! 3. Names can be specified with or without extension: both `"config"` and `"config.jinja"` resolve
28//!
29//! # Supported Extensions
30//!
31//! Template files are recognized by extension, in priority order:
32//!
33//! | Priority | Extension | Description |
34//! |----------|-----------|-------------|
35//! | 1 (highest) | `.jinja` | Standard Jinja extension |
36//! | 2 | `.jinja2` | Full Jinja2 extension |
37//! | 3 | `.j2` | Short Jinja2 extension |
38//! | 4 (lowest) | `.txt` | Plain text templates |
39//!
40//! If multiple files exist with the same base name but different extensions
41//! (e.g., `config.jinja` and `config.j2`), the higher-priority extension wins.
42//!
43//! # Collision Handling
44//!
45//! The registry enforces strict collision rules:
46//!
47//! - **Same-directory, different extensions**: Higher priority extension wins (no error)
48//! - **Cross-directory collisions**: Panic with detailed message listing conflicting files
49//!
50//! This strict behavior catches configuration mistakes early rather than silently
51//! using an arbitrary winner.
52//!
53//! # Example
54//!
55//! ```rust,ignore
56//! use standout::render::TemplateRegistry;
57//!
58//! let mut registry = TemplateRegistry::new();
59//! registry.add_template_dir("./templates")?;
60//! registry.add_inline("override", "Custom content");
61//!
62//! // Resolve templates
63//! let content = registry.get_content("config")?;
64//! ```
65
66use std::collections::HashMap;
67use std::path::{Path, PathBuf};
68
69use crate::file_loader::{
70 self, build_embedded_registry, FileRegistry, FileRegistryConfig, LoadError, LoadedEntry,
71 LoadedFile,
72};
73
74/// Recognized template file extensions in priority order.
75///
76/// When multiple files exist with the same base name but different extensions,
77/// the extension appearing earlier in this list takes precedence.
78///
79/// # Priority Order
80///
81/// 1. `.jinja` - Standard Jinja extension
82/// 2. `.jinja2` - Full Jinja2 extension
83/// 3. `.j2` - Short Jinja2 extension
84/// 4. `.txt` - Plain text templates
85pub const TEMPLATE_EXTENSIONS: &[&str] = &[".jinja", ".jinja2", ".j2", ".txt"];
86
87/// A template file discovered during directory walking.
88///
89/// This struct captures the essential information about a template file
90/// without reading its content, enabling lazy loading and hot reloading.
91///
92/// # Fields
93///
94/// - `name`: The resolution name without extension (e.g., `"todos/list"`)
95/// - `name_with_ext`: The resolution name with extension (e.g., `"todos/list.jinja"`)
96/// - `absolute_path`: Full filesystem path for reading content
97/// - `source_dir`: The template directory this file came from (for collision reporting)
98///
99/// # Example
100///
101/// For a file at `/app/templates/todos/list.jinja` with root `/app/templates`:
102///
103/// ```rust,ignore
104/// TemplateFile {
105/// name: "todos/list".to_string(),
106/// name_with_ext: "todos/list.jinja".to_string(),
107/// absolute_path: PathBuf::from("/app/templates/todos/list.jinja"),
108/// source_dir: PathBuf::from("/app/templates"),
109/// }
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct TemplateFile {
113 /// Resolution name without extension (e.g., "config" or "todos/list")
114 pub name: String,
115 /// Resolution name with extension (e.g., "config.jinja" or "todos/list.jinja")
116 pub name_with_ext: String,
117 /// Absolute path to the template file
118 pub absolute_path: PathBuf,
119 /// The template directory root this file belongs to
120 pub source_dir: PathBuf,
121}
122
123impl TemplateFile {
124 /// Creates a new template file descriptor.
125 pub fn new(
126 name: impl Into<String>,
127 name_with_ext: impl Into<String>,
128 absolute_path: impl Into<PathBuf>,
129 source_dir: impl Into<PathBuf>,
130 ) -> Self {
131 Self {
132 name: name.into(),
133 name_with_ext: name_with_ext.into(),
134 absolute_path: absolute_path.into(),
135 source_dir: source_dir.into(),
136 }
137 }
138
139 /// Returns the extension priority (lower is higher priority).
140 ///
141 /// Returns `usize::MAX` if the extension is not recognized.
142 pub fn extension_priority(&self) -> usize {
143 for (i, ext) in TEMPLATE_EXTENSIONS.iter().enumerate() {
144 if self.name_with_ext.ends_with(ext) {
145 return i;
146 }
147 }
148 usize::MAX
149 }
150}
151
152impl From<LoadedFile> for TemplateFile {
153 fn from(file: LoadedFile) -> Self {
154 Self {
155 name: file.name,
156 name_with_ext: file.name_with_ext,
157 absolute_path: file.path,
158 source_dir: file.source_dir,
159 }
160 }
161}
162
163impl From<TemplateFile> for LoadedFile {
164 fn from(file: TemplateFile) -> Self {
165 Self {
166 name: file.name,
167 name_with_ext: file.name_with_ext,
168 path: file.absolute_path,
169 source_dir: file.source_dir,
170 }
171 }
172}
173
174/// How a template's content is stored or accessed.
175///
176/// This enum enables different storage strategies:
177/// - `Inline`: Content is stored directly (for inline templates or embedded builds)
178/// - `File`: Content is read from disk on demand (for hot reloading in development)
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum ResolvedTemplate {
181 /// Template content stored directly in memory.
182 ///
183 /// Used for:
184 /// - Inline templates added via `add_inline()`
185 /// - Embedded templates in release builds
186 Inline(String),
187
188 /// Template loaded from filesystem on demand.
189 ///
190 /// The path is read on each render in development mode,
191 /// enabling hot reloading without recompilation.
192 File(PathBuf),
193}
194
195impl From<&LoadedEntry<String>> for ResolvedTemplate {
196 fn from(entry: &LoadedEntry<String>) -> Self {
197 match entry {
198 LoadedEntry::Embedded(content) => ResolvedTemplate::Inline(content.clone()),
199 LoadedEntry::File(path) => ResolvedTemplate::File(path.clone()),
200 }
201 }
202}
203
204/// Error type for template registry operations.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum RegistryError {
207 /// Two template directories contain files that resolve to the same name.
208 ///
209 /// This is an unrecoverable configuration error that must be fixed
210 /// by the application developer.
211 Collision {
212 /// The template name that has conflicting sources
213 name: String,
214 /// Path to the existing template
215 existing_path: PathBuf,
216 /// Directory containing the existing template
217 existing_dir: PathBuf,
218 /// Path to the conflicting template
219 conflicting_path: PathBuf,
220 /// Directory containing the conflicting template
221 conflicting_dir: PathBuf,
222 },
223
224 /// Template not found in registry.
225 NotFound {
226 /// The name that was requested
227 name: String,
228 },
229
230 /// Failed to read template file from disk.
231 ReadError {
232 /// Path that failed to read
233 path: PathBuf,
234 /// Error message
235 message: String,
236 },
237}
238
239impl std::fmt::Display for RegistryError {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 match self {
242 RegistryError::Collision {
243 name,
244 existing_path,
245 existing_dir,
246 conflicting_path,
247 conflicting_dir,
248 } => {
249 write!(
250 f,
251 "Template collision detected for \"{}\":\n \
252 - {} (from {})\n \
253 - {} (from {})",
254 name,
255 existing_path.display(),
256 existing_dir.display(),
257 conflicting_path.display(),
258 conflicting_dir.display()
259 )
260 }
261 RegistryError::NotFound { name } => {
262 write!(f, "Template not found: \"{}\"", name)
263 }
264 RegistryError::ReadError { path, message } => {
265 write!(
266 f,
267 "Failed to read template \"{}\": {}",
268 path.display(),
269 message
270 )
271 }
272 }
273 }
274}
275
276impl std::error::Error for RegistryError {}
277
278impl From<LoadError> for RegistryError {
279 fn from(err: LoadError) -> Self {
280 match err {
281 LoadError::NotFound { name } => RegistryError::NotFound { name },
282 LoadError::Io { path, message } => RegistryError::ReadError { path, message },
283 LoadError::Collision {
284 name,
285 existing_path,
286 existing_dir,
287 conflicting_path,
288 conflicting_dir,
289 } => RegistryError::Collision {
290 name,
291 existing_path,
292 existing_dir,
293 conflicting_path,
294 conflicting_dir,
295 },
296 LoadError::DirectoryNotFound { path } => RegistryError::ReadError {
297 path: path.clone(),
298 message: format!("Directory not found: {}", path.display()),
299 },
300 LoadError::Transform { name, message } => RegistryError::ReadError {
301 path: PathBuf::from(&name),
302 message,
303 },
304 }
305 }
306}
307
308/// Creates the file registry configuration for templates.
309fn template_config() -> FileRegistryConfig<String> {
310 FileRegistryConfig {
311 extensions: TEMPLATE_EXTENSIONS,
312 transform: |content| Ok(content.to_string()),
313 }
314}
315
316/// Registry for template resolution from multiple sources.
317///
318/// The registry maintains a unified view of templates from:
319/// - Inline strings (highest priority)
320/// - Multiple filesystem directories
321/// - Embedded content (for release builds)
322///
323/// # Resolution Order
324///
325/// When looking up a template name:
326///
327/// 1. Check inline templates first
328/// 2. Check file-based templates in registration order
329/// 3. Return error if not found
330///
331/// # Thread Safety
332///
333/// The registry is not thread-safe. For concurrent access, wrap in appropriate
334/// synchronization primitives.
335///
336/// # Example
337///
338/// ```rust,ignore
339/// let mut registry = TemplateRegistry::new();
340///
341/// // Add inline template (highest priority)
342/// registry.add_inline("header", "{{ title }}");
343///
344/// // Add from directory
345/// registry.add_template_dir("./templates")?;
346///
347/// // Resolve and get content
348/// let content = registry.get_content("header")?;
349/// ```
350pub struct TemplateRegistry {
351 /// The underlying file registry for directory-based file loading.
352 inner: FileRegistry<String>,
353
354 /// Inline templates (stored separately for highest priority).
355 inline: HashMap<String, String>,
356
357 /// File-based templates from add_from_files (maps name → path).
358 /// These are separate from directory-based loading.
359 files: HashMap<String, PathBuf>,
360
361 /// Tracks source info for collision detection: name → (path, source_dir).
362 sources: HashMap<String, (PathBuf, PathBuf)>,
363}
364
365impl Default for TemplateRegistry {
366 fn default() -> Self {
367 Self::new()
368 }
369}
370
371impl TemplateRegistry {
372 /// Creates an empty template registry.
373 pub fn new() -> Self {
374 Self {
375 inner: FileRegistry::new(template_config()),
376 inline: HashMap::new(),
377 files: HashMap::new(),
378 sources: HashMap::new(),
379 }
380 }
381
382 /// Adds an inline template with the given name.
383 ///
384 /// Inline templates have the highest priority and will shadow any
385 /// file-based templates with the same name.
386 ///
387 /// # Arguments
388 ///
389 /// * `name` - The template name for resolution
390 /// * `content` - The template content
391 ///
392 /// # Example
393 ///
394 /// ```rust,ignore
395 /// registry.add_inline("header", "{{ title | style(\"title\") }}");
396 /// ```
397 pub fn add_inline(&mut self, name: impl Into<String>, content: impl Into<String>) {
398 self.inline.insert(name.into(), content.into());
399 }
400
401 /// Adds a template directory to search for files.
402 ///
403 /// Templates in the directory are resolved by their relative path without
404 /// extension. For example, with directory `./templates`:
405 ///
406 /// - `"config"` → `./templates/config.jinja`
407 /// - `"todos/list"` → `./templates/todos/list.jinja`
408 ///
409 /// # Errors
410 ///
411 /// Returns an error if the directory doesn't exist.
412 pub fn add_template_dir<P: AsRef<Path>>(&mut self, path: P) -> Result<(), RegistryError> {
413 self.inner.add_dir(path).map_err(RegistryError::from)
414 }
415
416 /// Adds templates discovered from a directory scan.
417 ///
418 /// This method processes a list of [`TemplateFile`] entries, typically
419 /// produced by [`walk_template_dir`], and registers them for resolution.
420 ///
421 /// # Resolution Names
422 ///
423 /// Each file is registered under two names:
424 /// - Without extension: `"config"` for `config.jinja`
425 /// - With extension: `"config.jinja"` for `config.jinja`
426 ///
427 /// # Extension Priority
428 ///
429 /// If multiple files share the same base name with different extensions
430 /// (e.g., `config.jinja` and `config.j2`), the higher-priority extension wins
431 /// for the extensionless name. Both can still be accessed by full name.
432 ///
433 /// # Collision Detection
434 ///
435 /// If a template name conflicts with one from a different source directory,
436 /// an error is returned with details about both files.
437 ///
438 /// # Arguments
439 ///
440 /// * `files` - Template files discovered during directory walking
441 ///
442 /// # Errors
443 ///
444 /// Returns [`RegistryError::Collision`] if templates from different
445 /// directories resolve to the same name.
446 pub fn add_from_files(&mut self, files: Vec<TemplateFile>) -> Result<(), RegistryError> {
447 // Sort by extension priority so higher-priority extensions are processed first
448 let mut sorted_files = files;
449 sorted_files.sort_by_key(|f| f.extension_priority());
450
451 for file in sorted_files {
452 // Check for cross-directory collision on the base name
453 if let Some((existing_path, existing_dir)) = self.sources.get(&file.name) {
454 // Only error if from different source directories
455 if existing_dir != &file.source_dir {
456 return Err(RegistryError::Collision {
457 name: file.name.clone(),
458 existing_path: existing_path.clone(),
459 existing_dir: existing_dir.clone(),
460 conflicting_path: file.absolute_path.clone(),
461 conflicting_dir: file.source_dir.clone(),
462 });
463 }
464 // Same directory, different extension - skip (higher priority already registered)
465 continue;
466 }
467
468 // Track source for collision detection
469 self.sources.insert(
470 file.name.clone(),
471 (file.absolute_path.clone(), file.source_dir.clone()),
472 );
473
474 // Register the template under extensionless name
475 self.files
476 .insert(file.name.clone(), file.absolute_path.clone());
477
478 // Register under name with extension (allows explicit access)
479 self.files
480 .insert(file.name_with_ext.clone(), file.absolute_path);
481 }
482
483 Ok(())
484 }
485
486 /// Adds pre-embedded templates (for release builds).
487 ///
488 /// Embedded templates are treated as inline templates, stored directly
489 /// in memory without filesystem access.
490 ///
491 /// # Arguments
492 ///
493 /// * `templates` - Map of template name to content
494 pub fn add_embedded(&mut self, templates: HashMap<String, String>) {
495 for (name, content) in templates {
496 self.inline.insert(name, content);
497 }
498 }
499
500 /// Creates a registry from embedded template entries.
501 ///
502 /// This is the primary entry point for compile-time embedded templates,
503 /// typically called by the `embed_templates!` macro.
504 ///
505 /// # Arguments
506 ///
507 /// * `entries` - Slice of `(name_with_ext, content)` pairs where `name_with_ext`
508 /// is the relative path including extension (e.g., `"report/summary.jinja"`)
509 ///
510 /// # Processing
511 ///
512 /// This method applies the same logic as runtime file loading:
513 ///
514 /// 1. **Extension stripping**: `"report/summary.jinja"` → `"report/summary"`
515 /// 2. **Extension priority**: When multiple files share a base name, the
516 /// higher-priority extension wins (see [`TEMPLATE_EXTENSIONS`])
517 /// 3. **Dual registration**: Each template is accessible by both its base
518 /// name and its full name with extension
519 ///
520 /// # Example
521 ///
522 /// ```rust
523 /// use standout::TemplateRegistry;
524 ///
525 /// // Typically generated by embed_templates! macro
526 /// let entries: &[(&str, &str)] = &[
527 /// ("list.jinja", "Hello {{ name }}"),
528 /// ("report/summary.jinja", "Report: {{ title }}"),
529 /// ];
530 ///
531 /// let registry = TemplateRegistry::from_embedded_entries(entries);
532 ///
533 /// // Access by base name or full name
534 /// assert!(registry.get("list").is_ok());
535 /// assert!(registry.get("list.jinja").is_ok());
536 /// assert!(registry.get("report/summary").is_ok());
537 /// ```
538 pub fn from_embedded_entries(entries: &[(&str, &str)]) -> Self {
539 let mut registry = Self::new();
540
541 // Use shared helper - infallible transform for templates
542 let inline: HashMap<String, String> =
543 build_embedded_registry(entries, TEMPLATE_EXTENSIONS, |content| {
544 Ok::<_, std::convert::Infallible>(content.to_string())
545 })
546 .unwrap(); // Safe: Infallible error type
547
548 registry.inline = inline;
549 registry
550 }
551
552 /// Looks up a template by name.
553 ///
554 /// Names can be specified with or without extension:
555 /// - `"config"` resolves to `config.jinja` (or highest-priority extension)
556 /// - `"config.jinja"` resolves to exactly that file
557 ///
558 /// # Errors
559 ///
560 /// Returns [`RegistryError::NotFound`] if the template doesn't exist.
561 pub fn get(&self, name: &str) -> Result<ResolvedTemplate, RegistryError> {
562 // Check inline first (highest priority)
563 if let Some(content) = self.inline.get(name) {
564 return Ok(ResolvedTemplate::Inline(content.clone()));
565 }
566
567 // Check file-based templates from add_from_files
568 if let Some(path) = self.files.get(name) {
569 return Ok(ResolvedTemplate::File(path.clone()));
570 }
571
572 // Check directory-based file registry
573 if let Some(entry) = self.inner.get_entry(name) {
574 return Ok(ResolvedTemplate::from(entry));
575 }
576
577 Err(RegistryError::NotFound {
578 name: name.to_string(),
579 })
580 }
581
582 /// Gets the content of a template, reading from disk if necessary.
583 ///
584 /// For inline templates, returns the stored content directly.
585 /// For file templates, reads the file from disk (enabling hot reload).
586 ///
587 /// # Errors
588 ///
589 /// Returns an error if the template is not found or cannot be read from disk.
590 pub fn get_content(&self, name: &str) -> Result<String, RegistryError> {
591 let resolved = self.get(name)?;
592 match resolved {
593 ResolvedTemplate::Inline(content) => Ok(content),
594 ResolvedTemplate::File(path) => {
595 std::fs::read_to_string(&path).map_err(|e| RegistryError::ReadError {
596 path,
597 message: e.to_string(),
598 })
599 }
600 }
601 }
602
603 /// Refreshes the registry from registered directories.
604 ///
605 /// This re-walks all registered template directories and rebuilds the
606 /// resolution map. Call this if:
607 ///
608 /// - You've added template directories after the first render
609 /// - Template files have been added/removed from disk
610 ///
611 /// # Panics
612 ///
613 /// Panics if a collision is detected (same name from different directories).
614 pub fn refresh(&mut self) -> Result<(), RegistryError> {
615 self.inner.refresh().map_err(RegistryError::from)
616 }
617
618 /// Returns the number of registered templates.
619 ///
620 /// Note: This counts both extensionless and with-extension entries,
621 /// so it may be higher than the number of unique template files.
622 pub fn len(&self) -> usize {
623 self.inline.len() + self.files.len() + self.inner.len()
624 }
625
626 /// Returns true if no templates are registered.
627 pub fn is_empty(&self) -> bool {
628 self.inline.is_empty() && self.files.is_empty() && self.inner.is_empty()
629 }
630
631 /// Returns an iterator over all registered template names.
632 pub fn names(&self) -> impl Iterator<Item = &str> {
633 self.inline
634 .keys()
635 .map(|s| s.as_str())
636 .chain(self.files.keys().map(|s| s.as_str()))
637 .chain(self.inner.names())
638 }
639
640 /// Clears all templates from the registry.
641 pub fn clear(&mut self) {
642 self.inline.clear();
643 self.files.clear();
644 self.sources.clear();
645 self.inner.clear();
646 }
647}
648
649/// Walks a template directory and collects template files.
650///
651/// This function traverses the directory recursively, finding all files
652/// with recognized template extensions ([`TEMPLATE_EXTENSIONS`]).
653///
654/// # Arguments
655///
656/// * `root` - The template directory root to walk
657///
658/// # Returns
659///
660/// A vector of [`TemplateFile`] entries, one for each discovered template.
661/// The vector is not sorted; use [`TemplateFile::extension_priority`] for ordering.
662///
663/// # Errors
664///
665/// Returns an error if the directory cannot be read or traversed.
666///
667/// # Example
668///
669/// ```rust,ignore
670/// let files = walk_template_dir("./templates")?;
671/// for file in &files {
672/// println!("{} -> {}", file.name, file.absolute_path.display());
673/// }
674/// ```
675pub fn walk_template_dir(root: impl AsRef<Path>) -> Result<Vec<TemplateFile>, std::io::Error> {
676 let files = file_loader::walk_dir(root.as_ref(), TEMPLATE_EXTENSIONS)
677 .map_err(|e| std::io::Error::other(e.to_string()))?;
678
679 Ok(files.into_iter().map(TemplateFile::from).collect())
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 // =========================================================================
687 // TemplateFile tests
688 // =========================================================================
689
690 #[test]
691 fn test_template_file_extension_priority() {
692 let jinja = TemplateFile::new("config", "config.jinja", "/a/config.jinja", "/a");
693 let jinja2 = TemplateFile::new("config", "config.jinja2", "/a/config.jinja2", "/a");
694 let j2 = TemplateFile::new("config", "config.j2", "/a/config.j2", "/a");
695 let txt = TemplateFile::new("config", "config.txt", "/a/config.txt", "/a");
696 let unknown = TemplateFile::new("config", "config.xyz", "/a/config.xyz", "/a");
697
698 assert_eq!(jinja.extension_priority(), 0);
699 assert_eq!(jinja2.extension_priority(), 1);
700 assert_eq!(j2.extension_priority(), 2);
701 assert_eq!(txt.extension_priority(), 3);
702 assert_eq!(unknown.extension_priority(), usize::MAX);
703 }
704
705 // =========================================================================
706 // TemplateRegistry inline tests
707 // =========================================================================
708
709 #[test]
710 fn test_registry_add_inline() {
711 let mut registry = TemplateRegistry::new();
712 registry.add_inline("header", "{{ title }}");
713
714 assert_eq!(registry.len(), 1);
715 assert!(!registry.is_empty());
716
717 let content = registry.get_content("header").unwrap();
718 assert_eq!(content, "{{ title }}");
719 }
720
721 #[test]
722 fn test_registry_inline_overwrites() {
723 let mut registry = TemplateRegistry::new();
724 registry.add_inline("header", "first");
725 registry.add_inline("header", "second");
726
727 let content = registry.get_content("header").unwrap();
728 assert_eq!(content, "second");
729 }
730
731 #[test]
732 fn test_registry_not_found() {
733 let registry = TemplateRegistry::new();
734 let result = registry.get("nonexistent");
735
736 assert!(matches!(result, Err(RegistryError::NotFound { .. })));
737 }
738
739 // =========================================================================
740 // File-based template tests (using synthetic data)
741 // =========================================================================
742
743 #[test]
744 fn test_registry_add_from_files() {
745 let mut registry = TemplateRegistry::new();
746
747 let files = vec![
748 TemplateFile::new(
749 "config",
750 "config.jinja",
751 "/templates/config.jinja",
752 "/templates",
753 ),
754 TemplateFile::new(
755 "todos/list",
756 "todos/list.jinja",
757 "/templates/todos/list.jinja",
758 "/templates",
759 ),
760 ];
761
762 registry.add_from_files(files).unwrap();
763
764 // Should have 4 entries: 2 names + 2 names with extension
765 assert_eq!(registry.len(), 4);
766
767 // Can access by name without extension
768 assert!(registry.get("config").is_ok());
769 assert!(registry.get("todos/list").is_ok());
770
771 // Can access by name with extension
772 assert!(registry.get("config.jinja").is_ok());
773 assert!(registry.get("todos/list.jinja").is_ok());
774 }
775
776 #[test]
777 fn test_registry_extension_priority() {
778 let mut registry = TemplateRegistry::new();
779
780 // Add files with different extensions for same base name
781 // (j2 should be ignored because jinja has higher priority)
782 let files = vec![
783 TemplateFile::new("config", "config.j2", "/templates/config.j2", "/templates"),
784 TemplateFile::new(
785 "config",
786 "config.jinja",
787 "/templates/config.jinja",
788 "/templates",
789 ),
790 ];
791
792 registry.add_from_files(files).unwrap();
793
794 // Extensionless name should resolve to .jinja
795 let resolved = registry.get("config").unwrap();
796 match resolved {
797 ResolvedTemplate::File(path) => {
798 assert!(path.to_string_lossy().ends_with("config.jinja"));
799 }
800 _ => panic!("Expected file template"),
801 }
802 }
803
804 #[test]
805 fn test_registry_collision_different_dirs() {
806 let mut registry = TemplateRegistry::new();
807
808 let files = vec![
809 TemplateFile::new(
810 "config",
811 "config.jinja",
812 "/app/templates/config.jinja",
813 "/app/templates",
814 ),
815 TemplateFile::new(
816 "config",
817 "config.jinja",
818 "/plugins/templates/config.jinja",
819 "/plugins/templates",
820 ),
821 ];
822
823 let result = registry.add_from_files(files);
824
825 assert!(matches!(result, Err(RegistryError::Collision { .. })));
826
827 if let Err(RegistryError::Collision { name, .. }) = result {
828 assert_eq!(name, "config");
829 }
830 }
831
832 #[test]
833 fn test_registry_inline_shadows_file() {
834 let mut registry = TemplateRegistry::new();
835
836 // Add file-based template first
837 let files = vec![TemplateFile::new(
838 "config",
839 "config.jinja",
840 "/templates/config.jinja",
841 "/templates",
842 )];
843 registry.add_from_files(files).unwrap();
844
845 // Add inline with same name (should shadow)
846 registry.add_inline("config", "inline content");
847
848 let content = registry.get_content("config").unwrap();
849 assert_eq!(content, "inline content");
850 }
851
852 #[test]
853 fn test_registry_names_iterator() {
854 let mut registry = TemplateRegistry::new();
855 registry.add_inline("a", "content a");
856 registry.add_inline("b", "content b");
857
858 let names: Vec<&str> = registry.names().collect();
859 assert!(names.contains(&"a"));
860 assert!(names.contains(&"b"));
861 }
862
863 #[test]
864 fn test_registry_clear() {
865 let mut registry = TemplateRegistry::new();
866 registry.add_inline("a", "content");
867
868 assert!(!registry.is_empty());
869 registry.clear();
870 assert!(registry.is_empty());
871 }
872
873 // =========================================================================
874 // Error display tests
875 // =========================================================================
876
877 #[test]
878 fn test_error_display_collision() {
879 let err = RegistryError::Collision {
880 name: "config".to_string(),
881 existing_path: PathBuf::from("/a/config.jinja"),
882 existing_dir: PathBuf::from("/a"),
883 conflicting_path: PathBuf::from("/b/config.jinja"),
884 conflicting_dir: PathBuf::from("/b"),
885 };
886
887 let display = err.to_string();
888 assert!(display.contains("config"));
889 assert!(display.contains("/a/config.jinja"));
890 assert!(display.contains("/b/config.jinja"));
891 }
892
893 #[test]
894 fn test_error_display_not_found() {
895 let err = RegistryError::NotFound {
896 name: "missing".to_string(),
897 };
898
899 let display = err.to_string();
900 assert!(display.contains("missing"));
901 }
902
903 // =========================================================================
904 // from_embedded_entries tests
905 // =========================================================================
906
907 #[test]
908 fn test_from_embedded_entries_single() {
909 let entries: &[(&str, &str)] = &[("hello.jinja", "Hello {{ name }}")];
910 let registry = TemplateRegistry::from_embedded_entries(entries);
911
912 // Should be accessible by both names
913 assert!(registry.get("hello").is_ok());
914 assert!(registry.get("hello.jinja").is_ok());
915
916 let content = registry.get_content("hello").unwrap();
917 assert_eq!(content, "Hello {{ name }}");
918 }
919
920 #[test]
921 fn test_from_embedded_entries_multiple() {
922 let entries: &[(&str, &str)] = &[
923 ("header.jinja", "{{ title }}"),
924 ("footer.jinja", "Copyright {{ year }}"),
925 ];
926 let registry = TemplateRegistry::from_embedded_entries(entries);
927
928 assert_eq!(registry.len(), 4); // 2 base + 2 with ext
929 assert!(registry.get("header").is_ok());
930 assert!(registry.get("footer").is_ok());
931 }
932
933 #[test]
934 fn test_from_embedded_entries_nested_paths() {
935 let entries: &[(&str, &str)] = &[
936 ("report/summary.jinja", "Summary: {{ text }}"),
937 ("report/details.jinja", "Details: {{ info }}"),
938 ];
939 let registry = TemplateRegistry::from_embedded_entries(entries);
940
941 assert!(registry.get("report/summary").is_ok());
942 assert!(registry.get("report/summary.jinja").is_ok());
943 assert!(registry.get("report/details").is_ok());
944 }
945
946 #[test]
947 fn test_from_embedded_entries_extension_priority() {
948 // .jinja has higher priority than .txt (index 0 vs index 3)
949 let entries: &[(&str, &str)] = &[
950 ("config.txt", "txt content"),
951 ("config.jinja", "jinja content"),
952 ];
953 let registry = TemplateRegistry::from_embedded_entries(entries);
954
955 // Base name should resolve to higher priority (.jinja)
956 let content = registry.get_content("config").unwrap();
957 assert_eq!(content, "jinja content");
958
959 // Both can still be accessed by full name
960 assert_eq!(registry.get_content("config.txt").unwrap(), "txt content");
961 assert_eq!(
962 registry.get_content("config.jinja").unwrap(),
963 "jinja content"
964 );
965 }
966
967 #[test]
968 fn test_from_embedded_entries_extension_priority_reverse_order() {
969 // Same test but with entries in reverse order to ensure sorting works
970 let entries: &[(&str, &str)] = &[
971 ("config.jinja", "jinja content"),
972 ("config.txt", "txt content"),
973 ];
974 let registry = TemplateRegistry::from_embedded_entries(entries);
975
976 // Base name should still resolve to higher priority (.jinja)
977 let content = registry.get_content("config").unwrap();
978 assert_eq!(content, "jinja content");
979 }
980
981 #[test]
982 fn test_from_embedded_entries_names_iterator() {
983 let entries: &[(&str, &str)] = &[("a.jinja", "content a"), ("nested/b.jinja", "content b")];
984 let registry = TemplateRegistry::from_embedded_entries(entries);
985
986 let names: Vec<&str> = registry.names().collect();
987 assert!(names.contains(&"a"));
988 assert!(names.contains(&"a.jinja"));
989 assert!(names.contains(&"nested/b"));
990 assert!(names.contains(&"nested/b.jinja"));
991 }
992
993 #[test]
994 fn test_from_embedded_entries_empty() {
995 let entries: &[(&str, &str)] = &[];
996 let registry = TemplateRegistry::from_embedded_entries(entries);
997
998 assert!(registry.is_empty());
999 assert_eq!(registry.len(), 0);
1000 }
1001
1002 #[test]
1003 fn test_extensionless_includes_work() {
1004 // Simulates the user's report: {% include "_partial" %} should work
1005 // when the file is actually "_partial.jinja"
1006 let entries: &[(&str, &str)] = &[
1007 ("main.jinja", "Start {% include '_partial' %} End"),
1008 ("_partial.jinja", "PARTIAL_CONTENT"),
1009 ];
1010 let registry = TemplateRegistry::from_embedded_entries(entries);
1011
1012 // Build MiniJinja environment the same way App.render() does
1013 let mut env = minijinja::Environment::new();
1014 for name in registry.names() {
1015 if let Ok(content) = registry.get_content(name) {
1016 env.add_template_owned(name.to_string(), content).unwrap();
1017 }
1018 }
1019
1020 // Verify extensionless include works
1021 let tmpl = env.get_template("main").unwrap();
1022 let output = tmpl.render(()).unwrap();
1023 assert_eq!(output, "Start PARTIAL_CONTENT End");
1024 }
1025
1026 #[test]
1027 fn test_extensionless_includes_with_extension_syntax() {
1028 // Also verify that {% include "_partial.jinja" %} works
1029 let entries: &[(&str, &str)] = &[
1030 ("main.jinja", "Start {% include '_partial.jinja' %} End"),
1031 ("_partial.jinja", "PARTIAL_CONTENT"),
1032 ];
1033 let registry = TemplateRegistry::from_embedded_entries(entries);
1034
1035 let mut env = minijinja::Environment::new();
1036 for name in registry.names() {
1037 if let Ok(content) = registry.get_content(name) {
1038 env.add_template_owned(name.to_string(), content).unwrap();
1039 }
1040 }
1041
1042 let tmpl = env.get_template("main").unwrap();
1043 let output = tmpl.render(()).unwrap();
1044 assert_eq!(output, "Start PARTIAL_CONTENT End");
1045 }
1046}