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}