Skip to main content

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    /// Framework templates (lowest priority fallback).
365    /// These are provided by the standout framework and can be overridden
366    /// by user templates with the same name.
367    framework: HashMap<String, String>,
368}
369
370impl Default for TemplateRegistry {
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376impl TemplateRegistry {
377    /// Creates an empty template registry.
378    pub fn new() -> Self {
379        Self {
380            inner: FileRegistry::new(template_config()),
381            inline: HashMap::new(),
382            files: HashMap::new(),
383            sources: HashMap::new(),
384            framework: HashMap::new(),
385        }
386    }
387
388    /// Adds an inline template with the given name.
389    ///
390    /// Inline templates have the highest priority and will shadow any
391    /// file-based templates with the same name.
392    ///
393    /// # Arguments
394    ///
395    /// * `name` - The template name for resolution
396    /// * `content` - The template content
397    ///
398    /// # Example
399    ///
400    /// ```rust,ignore
401    /// registry.add_inline("header", "{{ title | style(\"title\") }}");
402    /// ```
403    pub fn add_inline(&mut self, name: impl Into<String>, content: impl Into<String>) {
404        self.inline.insert(name.into(), content.into());
405    }
406
407    /// Adds a template directory to search for files.
408    ///
409    /// Templates in the directory are resolved by their relative path without
410    /// extension. For example, with directory `./templates`:
411    ///
412    /// - `"config"` → `./templates/config.jinja`
413    /// - `"todos/list"` → `./templates/todos/list.jinja`
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if the directory doesn't exist.
418    pub fn add_template_dir<P: AsRef<Path>>(&mut self, path: P) -> Result<(), RegistryError> {
419        self.inner.add_dir(path).map_err(RegistryError::from)
420    }
421
422    /// Adds templates discovered from a directory scan.
423    ///
424    /// This method processes a list of [`TemplateFile`] entries, typically
425    /// produced by [`walk_template_dir`], and registers them for resolution.
426    ///
427    /// # Resolution Names
428    ///
429    /// Each file is registered under two names:
430    /// - Without extension: `"config"` for `config.jinja`
431    /// - With extension: `"config.jinja"` for `config.jinja`
432    ///
433    /// # Extension Priority
434    ///
435    /// If multiple files share the same base name with different extensions
436    /// (e.g., `config.jinja` and `config.j2`), the higher-priority extension wins
437    /// for the extensionless name. Both can still be accessed by full name.
438    ///
439    /// # Collision Detection
440    ///
441    /// If a template name conflicts with one from a different source directory,
442    /// an error is returned with details about both files.
443    ///
444    /// # Arguments
445    ///
446    /// * `files` - Template files discovered during directory walking
447    ///
448    /// # Errors
449    ///
450    /// Returns [`RegistryError::Collision`] if templates from different
451    /// directories resolve to the same name.
452    pub fn add_from_files(&mut self, files: Vec<TemplateFile>) -> Result<(), RegistryError> {
453        // Sort by extension priority so higher-priority extensions are processed first
454        let mut sorted_files = files;
455        sorted_files.sort_by_key(|f| f.extension_priority());
456
457        for file in sorted_files {
458            // Check for cross-directory collision on the base name
459            if let Some((existing_path, existing_dir)) = self.sources.get(&file.name) {
460                // Only error if from different source directories
461                if existing_dir != &file.source_dir {
462                    return Err(RegistryError::Collision {
463                        name: file.name.clone(),
464                        existing_path: existing_path.clone(),
465                        existing_dir: existing_dir.clone(),
466                        conflicting_path: file.absolute_path.clone(),
467                        conflicting_dir: file.source_dir.clone(),
468                    });
469                }
470                // Same directory, different extension - skip (higher priority already registered)
471                continue;
472            }
473
474            // Track source for collision detection
475            self.sources.insert(
476                file.name.clone(),
477                (file.absolute_path.clone(), file.source_dir.clone()),
478            );
479
480            // Register the template under extensionless name
481            self.files
482                .insert(file.name.clone(), file.absolute_path.clone());
483
484            // Register under name with extension (allows explicit access)
485            self.files
486                .insert(file.name_with_ext.clone(), file.absolute_path);
487        }
488
489        Ok(())
490    }
491
492    /// Adds pre-embedded templates (for release builds).
493    ///
494    /// Embedded templates are treated as inline templates, stored directly
495    /// in memory without filesystem access.
496    ///
497    /// # Arguments
498    ///
499    /// * `templates` - Map of template name to content
500    pub fn add_embedded(&mut self, templates: HashMap<String, String>) {
501        for (name, content) in templates {
502            self.inline.insert(name, content);
503        }
504    }
505
506    /// Adds framework templates (lowest priority fallback).
507    ///
508    /// Framework templates are provided by the standout framework and serve as
509    /// defaults that can be overridden by user templates with the same name.
510    /// They are checked last during resolution.
511    ///
512    /// Framework templates typically use the `standout/` namespace to avoid
513    /// accidental collision with user templates (e.g., `standout/list-view`).
514    ///
515    /// # Arguments
516    ///
517    /// * `name` - The template name (e.g., `"standout/list-view"`)
518    /// * `content` - The template content
519    ///
520    /// # Example
521    ///
522    /// ```rust,ignore
523    /// registry.add_framework("standout/list-view", include_str!("templates/list-view.jinja"));
524    /// ```
525    pub fn add_framework(&mut self, name: impl Into<String>, content: impl Into<String>) {
526        self.framework.insert(name.into(), content.into());
527    }
528
529    /// Adds multiple framework templates from embedded entries.
530    ///
531    /// This is similar to [`from_embedded_entries`] but adds templates to the
532    /// framework (lowest priority) tier instead of inline (highest priority).
533    ///
534    /// # Arguments
535    ///
536    /// * `entries` - Slice of `(name_with_ext, content)` pairs
537    pub fn add_framework_entries(&mut self, entries: &[(&str, &str)]) {
538        let framework: HashMap<String, String> =
539            build_embedded_registry(entries, TEMPLATE_EXTENSIONS, |content| {
540                Ok::<_, std::convert::Infallible>(content.to_string())
541            })
542            .unwrap(); // Safe: Infallible error type
543
544        for (name, content) in framework {
545            self.framework.insert(name, content);
546        }
547    }
548
549    /// Clears all framework templates.
550    ///
551    /// This is useful when you want to disable all framework-provided defaults
552    /// and require explicit template configuration.
553    pub fn clear_framework(&mut self) {
554        self.framework.clear();
555    }
556
557    /// Creates a registry from embedded template entries.
558    ///
559    /// This is the primary entry point for compile-time embedded templates,
560    /// typically called by the `embed_templates!` macro.
561    ///
562    /// # Arguments
563    ///
564    /// * `entries` - Slice of `(name_with_ext, content)` pairs where `name_with_ext`
565    ///   is the relative path including extension (e.g., `"report/summary.jinja"`)
566    ///
567    /// # Processing
568    ///
569    /// This method applies the same logic as runtime file loading:
570    ///
571    /// 1. Extension stripping: `"report/summary.jinja"` → `"report/summary"`
572    /// 2. Extension priority: When multiple files share a base name, the
573    ///    higher-priority extension wins (see [`TEMPLATE_EXTENSIONS`])
574    /// 3. Dual registration: Each template is accessible by both its base
575    ///    name and its full name with extension
576    ///
577    /// # Example
578    ///
579    /// ```rust
580    /// use standout::TemplateRegistry;
581    ///
582    /// // Typically generated by embed_templates! macro
583    /// let entries: &[(&str, &str)] = &[
584    ///     ("list.jinja", "Hello {{ name }}"),
585    ///     ("report/summary.jinja", "Report: {{ title }}"),
586    /// ];
587    ///
588    /// let registry = TemplateRegistry::from_embedded_entries(entries);
589    ///
590    /// // Access by base name or full name
591    /// assert!(registry.get("list").is_ok());
592    /// assert!(registry.get("list.jinja").is_ok());
593    /// assert!(registry.get("report/summary").is_ok());
594    /// ```
595    pub fn from_embedded_entries(entries: &[(&str, &str)]) -> Self {
596        let mut registry = Self::new();
597
598        // Use shared helper - infallible transform for templates
599        let inline: HashMap<String, String> =
600            build_embedded_registry(entries, TEMPLATE_EXTENSIONS, |content| {
601                Ok::<_, std::convert::Infallible>(content.to_string())
602            })
603            .unwrap(); // Safe: Infallible error type
604
605        registry.inline = inline;
606        registry
607    }
608
609    /// Looks up a template by name.
610    ///
611    /// Names can be specified with or without extension:
612    /// - `"config"` resolves to `config.jinja` (or highest-priority extension)
613    /// - `"config.jinja"` resolves to exactly that file
614    ///
615    /// # Resolution Priority
616    ///
617    /// Templates are resolved in this order:
618    /// 1. Inline templates (highest priority)
619    /// 2. File-based templates from `add_from_files`
620    /// 3. Directory-based templates from `add_template_dir`
621    /// 4. Framework templates (lowest priority)
622    ///
623    /// This allows user templates to override framework defaults.
624    ///
625    /// # Errors
626    ///
627    /// Returns [`RegistryError::NotFound`] if the template doesn't exist.
628    pub fn get(&self, name: &str) -> Result<ResolvedTemplate, RegistryError> {
629        // Check inline first (highest priority)
630        if let Some(content) = self.inline.get(name) {
631            return Ok(ResolvedTemplate::Inline(content.clone()));
632        }
633
634        // Check file-based templates from add_from_files
635        if let Some(path) = self.files.get(name) {
636            return Ok(ResolvedTemplate::File(path.clone()));
637        }
638
639        // Check directory-based file registry
640        if let Some(entry) = self.inner.get_entry(name) {
641            return Ok(ResolvedTemplate::from(entry));
642        }
643
644        // Check framework templates (lowest priority)
645        if let Some(content) = self.framework.get(name) {
646            return Ok(ResolvedTemplate::Inline(content.clone()));
647        }
648
649        Err(RegistryError::NotFound {
650            name: name.to_string(),
651        })
652    }
653
654    /// Gets the content of a template, reading from disk if necessary.
655    ///
656    /// For inline templates, returns the stored content directly.
657    /// For file templates, reads the file from disk (enabling hot reload).
658    ///
659    /// # Errors
660    ///
661    /// Returns an error if the template is not found or cannot be read from disk.
662    pub fn get_content(&self, name: &str) -> Result<String, RegistryError> {
663        let resolved = self.get(name)?;
664        match resolved {
665            ResolvedTemplate::Inline(content) => Ok(content),
666            ResolvedTemplate::File(path) => {
667                std::fs::read_to_string(&path).map_err(|e| RegistryError::ReadError {
668                    path,
669                    message: e.to_string(),
670                })
671            }
672        }
673    }
674
675    /// Refreshes the registry from registered directories.
676    ///
677    /// This re-walks all registered template directories and rebuilds the
678    /// resolution map. Call this if:
679    ///
680    /// - You've added template directories after the first render
681    /// - Template files have been added/removed from disk
682    ///
683    /// # Panics
684    ///
685    /// Panics if a collision is detected (same name from different directories).
686    pub fn refresh(&mut self) -> Result<(), RegistryError> {
687        self.inner.refresh().map_err(RegistryError::from)
688    }
689
690    /// Returns the number of registered templates.
691    ///
692    /// Note: This counts both extensionless and with-extension entries,
693    /// so it may be higher than the number of unique template files.
694    pub fn len(&self) -> usize {
695        self.inline.len() + self.files.len() + self.inner.len() + self.framework.len()
696    }
697
698    /// Returns true if no templates are registered.
699    pub fn is_empty(&self) -> bool {
700        self.inline.is_empty()
701            && self.files.is_empty()
702            && self.inner.is_empty()
703            && self.framework.is_empty()
704    }
705
706    /// Returns an iterator over all registered template names.
707    pub fn names(&self) -> impl Iterator<Item = &str> {
708        self.inline
709            .keys()
710            .map(|s| s.as_str())
711            .chain(self.files.keys().map(|s| s.as_str()))
712            .chain(self.inner.names())
713            .chain(self.framework.keys().map(|s| s.as_str()))
714    }
715
716    /// Clears all templates from the registry.
717    pub fn clear(&mut self) {
718        self.inline.clear();
719        self.files.clear();
720        self.sources.clear();
721        self.inner.clear();
722        self.framework.clear();
723    }
724
725    /// Returns true if the registry has framework templates.
726    pub fn has_framework_templates(&self) -> bool {
727        !self.framework.is_empty()
728    }
729
730    /// Returns an iterator over framework template names.
731    pub fn framework_names(&self) -> impl Iterator<Item = &str> {
732        self.framework.keys().map(|s| s.as_str())
733    }
734}
735
736/// Walks a template directory and collects template files.
737///
738/// This function traverses the directory recursively, finding all files
739/// with recognized template extensions ([`TEMPLATE_EXTENSIONS`]).
740///
741/// # Arguments
742///
743/// * `root` - The template directory root to walk
744///
745/// # Returns
746///
747/// A vector of [`TemplateFile`] entries, one for each discovered template.
748/// The vector is not sorted; use [`TemplateFile::extension_priority`] for ordering.
749///
750/// # Errors
751///
752/// Returns an error if the directory cannot be read or traversed.
753///
754/// # Example
755///
756/// ```rust,ignore
757/// let files = walk_template_dir("./templates")?;
758/// for file in &files {
759///     println!("{} -> {}", file.name, file.absolute_path.display());
760/// }
761/// ```
762pub fn walk_template_dir(root: impl AsRef<Path>) -> Result<Vec<TemplateFile>, std::io::Error> {
763    let files = file_loader::walk_dir(root.as_ref(), TEMPLATE_EXTENSIONS)
764        .map_err(|e| std::io::Error::other(e.to_string()))?;
765
766    Ok(files.into_iter().map(TemplateFile::from).collect())
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    // =========================================================================
774    // TemplateFile tests
775    // =========================================================================
776
777    #[test]
778    fn test_template_file_extension_priority() {
779        let jinja = TemplateFile::new("config", "config.jinja", "/a/config.jinja", "/a");
780        let jinja2 = TemplateFile::new("config", "config.jinja2", "/a/config.jinja2", "/a");
781        let j2 = TemplateFile::new("config", "config.j2", "/a/config.j2", "/a");
782        let txt = TemplateFile::new("config", "config.txt", "/a/config.txt", "/a");
783        let unknown = TemplateFile::new("config", "config.xyz", "/a/config.xyz", "/a");
784
785        assert_eq!(jinja.extension_priority(), 0);
786        assert_eq!(jinja2.extension_priority(), 1);
787        assert_eq!(j2.extension_priority(), 2);
788        assert_eq!(txt.extension_priority(), 3);
789        assert_eq!(unknown.extension_priority(), usize::MAX);
790    }
791
792    // =========================================================================
793    // TemplateRegistry inline tests
794    // =========================================================================
795
796    #[test]
797    fn test_registry_add_inline() {
798        let mut registry = TemplateRegistry::new();
799        registry.add_inline("header", "{{ title }}");
800
801        assert_eq!(registry.len(), 1);
802        assert!(!registry.is_empty());
803
804        let content = registry.get_content("header").unwrap();
805        assert_eq!(content, "{{ title }}");
806    }
807
808    #[test]
809    fn test_registry_inline_overwrites() {
810        let mut registry = TemplateRegistry::new();
811        registry.add_inline("header", "first");
812        registry.add_inline("header", "second");
813
814        let content = registry.get_content("header").unwrap();
815        assert_eq!(content, "second");
816    }
817
818    #[test]
819    fn test_registry_not_found() {
820        let registry = TemplateRegistry::new();
821        let result = registry.get("nonexistent");
822
823        assert!(matches!(result, Err(RegistryError::NotFound { .. })));
824    }
825
826    // =========================================================================
827    // File-based template tests (using synthetic data)
828    // =========================================================================
829
830    #[test]
831    fn test_registry_add_from_files() {
832        let mut registry = TemplateRegistry::new();
833
834        let files = vec![
835            TemplateFile::new(
836                "config",
837                "config.jinja",
838                "/templates/config.jinja",
839                "/templates",
840            ),
841            TemplateFile::new(
842                "todos/list",
843                "todos/list.jinja",
844                "/templates/todos/list.jinja",
845                "/templates",
846            ),
847        ];
848
849        registry.add_from_files(files).unwrap();
850
851        // Should have 4 entries: 2 names + 2 names with extension
852        assert_eq!(registry.len(), 4);
853
854        // Can access by name without extension
855        assert!(registry.get("config").is_ok());
856        assert!(registry.get("todos/list").is_ok());
857
858        // Can access by name with extension
859        assert!(registry.get("config.jinja").is_ok());
860        assert!(registry.get("todos/list.jinja").is_ok());
861    }
862
863    #[test]
864    fn test_registry_extension_priority() {
865        let mut registry = TemplateRegistry::new();
866
867        // Add files with different extensions for same base name
868        // (j2 should be ignored because jinja has higher priority)
869        let files = vec![
870            TemplateFile::new("config", "config.j2", "/templates/config.j2", "/templates"),
871            TemplateFile::new(
872                "config",
873                "config.jinja",
874                "/templates/config.jinja",
875                "/templates",
876            ),
877        ];
878
879        registry.add_from_files(files).unwrap();
880
881        // Extensionless name should resolve to .jinja
882        let resolved = registry.get("config").unwrap();
883        match resolved {
884            ResolvedTemplate::File(path) => {
885                assert!(path.to_string_lossy().ends_with("config.jinja"));
886            }
887            _ => panic!("Expected file template"),
888        }
889    }
890
891    #[test]
892    fn test_registry_collision_different_dirs() {
893        let mut registry = TemplateRegistry::new();
894
895        let files = vec![
896            TemplateFile::new(
897                "config",
898                "config.jinja",
899                "/app/templates/config.jinja",
900                "/app/templates",
901            ),
902            TemplateFile::new(
903                "config",
904                "config.jinja",
905                "/plugins/templates/config.jinja",
906                "/plugins/templates",
907            ),
908        ];
909
910        let result = registry.add_from_files(files);
911
912        assert!(matches!(result, Err(RegistryError::Collision { .. })));
913
914        if let Err(RegistryError::Collision { name, .. }) = result {
915            assert_eq!(name, "config");
916        }
917    }
918
919    #[test]
920    fn test_registry_inline_shadows_file() {
921        let mut registry = TemplateRegistry::new();
922
923        // Add file-based template first
924        let files = vec![TemplateFile::new(
925            "config",
926            "config.jinja",
927            "/templates/config.jinja",
928            "/templates",
929        )];
930        registry.add_from_files(files).unwrap();
931
932        // Add inline with same name (should shadow)
933        registry.add_inline("config", "inline content");
934
935        let content = registry.get_content("config").unwrap();
936        assert_eq!(content, "inline content");
937    }
938
939    #[test]
940    fn test_registry_names_iterator() {
941        let mut registry = TemplateRegistry::new();
942        registry.add_inline("a", "content a");
943        registry.add_inline("b", "content b");
944
945        let names: Vec<&str> = registry.names().collect();
946        assert!(names.contains(&"a"));
947        assert!(names.contains(&"b"));
948    }
949
950    #[test]
951    fn test_registry_clear() {
952        let mut registry = TemplateRegistry::new();
953        registry.add_inline("a", "content");
954
955        assert!(!registry.is_empty());
956        registry.clear();
957        assert!(registry.is_empty());
958    }
959
960    // =========================================================================
961    // Error display tests
962    // =========================================================================
963
964    #[test]
965    fn test_error_display_collision() {
966        let err = RegistryError::Collision {
967            name: "config".to_string(),
968            existing_path: PathBuf::from("/a/config.jinja"),
969            existing_dir: PathBuf::from("/a"),
970            conflicting_path: PathBuf::from("/b/config.jinja"),
971            conflicting_dir: PathBuf::from("/b"),
972        };
973
974        let display = err.to_string();
975        assert!(display.contains("config"));
976        assert!(display.contains("/a/config.jinja"));
977        assert!(display.contains("/b/config.jinja"));
978    }
979
980    #[test]
981    fn test_error_display_not_found() {
982        let err = RegistryError::NotFound {
983            name: "missing".to_string(),
984        };
985
986        let display = err.to_string();
987        assert!(display.contains("missing"));
988    }
989
990    // =========================================================================
991    // from_embedded_entries tests
992    // =========================================================================
993
994    #[test]
995    fn test_from_embedded_entries_single() {
996        let entries: &[(&str, &str)] = &[("hello.jinja", "Hello {{ name }}")];
997        let registry = TemplateRegistry::from_embedded_entries(entries);
998
999        // Should be accessible by both names
1000        assert!(registry.get("hello").is_ok());
1001        assert!(registry.get("hello.jinja").is_ok());
1002
1003        let content = registry.get_content("hello").unwrap();
1004        assert_eq!(content, "Hello {{ name }}");
1005    }
1006
1007    #[test]
1008    fn test_from_embedded_entries_multiple() {
1009        let entries: &[(&str, &str)] = &[
1010            ("header.jinja", "{{ title }}"),
1011            ("footer.jinja", "Copyright {{ year }}"),
1012        ];
1013        let registry = TemplateRegistry::from_embedded_entries(entries);
1014
1015        assert_eq!(registry.len(), 4); // 2 base + 2 with ext
1016        assert!(registry.get("header").is_ok());
1017        assert!(registry.get("footer").is_ok());
1018    }
1019
1020    #[test]
1021    fn test_from_embedded_entries_nested_paths() {
1022        let entries: &[(&str, &str)] = &[
1023            ("report/summary.jinja", "Summary: {{ text }}"),
1024            ("report/details.jinja", "Details: {{ info }}"),
1025        ];
1026        let registry = TemplateRegistry::from_embedded_entries(entries);
1027
1028        assert!(registry.get("report/summary").is_ok());
1029        assert!(registry.get("report/summary.jinja").is_ok());
1030        assert!(registry.get("report/details").is_ok());
1031    }
1032
1033    #[test]
1034    fn test_from_embedded_entries_extension_priority() {
1035        // .jinja has higher priority than .txt (index 0 vs index 3)
1036        let entries: &[(&str, &str)] = &[
1037            ("config.txt", "txt content"),
1038            ("config.jinja", "jinja content"),
1039        ];
1040        let registry = TemplateRegistry::from_embedded_entries(entries);
1041
1042        // Base name should resolve to higher priority (.jinja)
1043        let content = registry.get_content("config").unwrap();
1044        assert_eq!(content, "jinja content");
1045
1046        // Both can still be accessed by full name
1047        assert_eq!(registry.get_content("config.txt").unwrap(), "txt content");
1048        assert_eq!(
1049            registry.get_content("config.jinja").unwrap(),
1050            "jinja content"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_from_embedded_entries_extension_priority_reverse_order() {
1056        // Same test but with entries in reverse order to ensure sorting works
1057        let entries: &[(&str, &str)] = &[
1058            ("config.jinja", "jinja content"),
1059            ("config.txt", "txt content"),
1060        ];
1061        let registry = TemplateRegistry::from_embedded_entries(entries);
1062
1063        // Base name should still resolve to higher priority (.jinja)
1064        let content = registry.get_content("config").unwrap();
1065        assert_eq!(content, "jinja content");
1066    }
1067
1068    #[test]
1069    fn test_from_embedded_entries_names_iterator() {
1070        let entries: &[(&str, &str)] = &[("a.jinja", "content a"), ("nested/b.jinja", "content b")];
1071        let registry = TemplateRegistry::from_embedded_entries(entries);
1072
1073        let names: Vec<&str> = registry.names().collect();
1074        assert!(names.contains(&"a"));
1075        assert!(names.contains(&"a.jinja"));
1076        assert!(names.contains(&"nested/b"));
1077        assert!(names.contains(&"nested/b.jinja"));
1078    }
1079
1080    #[test]
1081    fn test_from_embedded_entries_empty() {
1082        let entries: &[(&str, &str)] = &[];
1083        let registry = TemplateRegistry::from_embedded_entries(entries);
1084
1085        assert!(registry.is_empty());
1086        assert_eq!(registry.len(), 0);
1087    }
1088
1089    #[test]
1090    fn test_extensionless_includes_work() {
1091        // Simulates the user's report: {% include "_partial" %} should work
1092        // when the file is actually "_partial.jinja"
1093        let entries: &[(&str, &str)] = &[
1094            ("main.jinja", "Start {% include '_partial' %} End"),
1095            ("_partial.jinja", "PARTIAL_CONTENT"),
1096        ];
1097        let registry = TemplateRegistry::from_embedded_entries(entries);
1098
1099        // Build MiniJinja environment the same way App.render() does
1100        let mut env = minijinja::Environment::new();
1101        for name in registry.names() {
1102            if let Ok(content) = registry.get_content(name) {
1103                env.add_template_owned(name.to_string(), content).unwrap();
1104            }
1105        }
1106
1107        // Verify extensionless include works
1108        let tmpl = env.get_template("main").unwrap();
1109        let output = tmpl.render(()).unwrap();
1110        assert_eq!(output, "Start PARTIAL_CONTENT End");
1111    }
1112
1113    #[test]
1114    fn test_extensionless_includes_with_extension_syntax() {
1115        // Also verify that {% include "_partial.jinja" %} works
1116        let entries: &[(&str, &str)] = &[
1117            ("main.jinja", "Start {% include '_partial.jinja' %} End"),
1118            ("_partial.jinja", "PARTIAL_CONTENT"),
1119        ];
1120        let registry = TemplateRegistry::from_embedded_entries(entries);
1121
1122        let mut env = minijinja::Environment::new();
1123        for name in registry.names() {
1124            if let Ok(content) = registry.get_content(name) {
1125                env.add_template_owned(name.to_string(), content).unwrap();
1126            }
1127        }
1128
1129        let tmpl = env.get_template("main").unwrap();
1130        let output = tmpl.render(()).unwrap();
1131        assert_eq!(output, "Start PARTIAL_CONTENT End");
1132    }
1133
1134    // =========================================================================
1135    // Framework templates tests
1136    // =========================================================================
1137
1138    #[test]
1139    fn test_framework_add_and_get() {
1140        let mut registry = TemplateRegistry::new();
1141        registry.add_framework("standout/list-view", "Framework list view");
1142
1143        assert!(registry.has_framework_templates());
1144        let content = registry.get_content("standout/list-view").unwrap();
1145        assert_eq!(content, "Framework list view");
1146    }
1147
1148    #[test]
1149    fn test_framework_lowest_priority() {
1150        let mut registry = TemplateRegistry::new();
1151
1152        // Add framework template
1153        registry.add_framework("config", "framework content");
1154
1155        // Add inline template with same name (should shadow)
1156        registry.add_inline("config", "inline content");
1157
1158        // Inline should win
1159        let content = registry.get_content("config").unwrap();
1160        assert_eq!(content, "inline content");
1161    }
1162
1163    #[test]
1164    fn test_framework_user_can_override() {
1165        let mut registry = TemplateRegistry::new();
1166
1167        // Add framework template in standout/ namespace
1168        registry.add_framework("standout/list-view", "framework default");
1169
1170        // User creates their own version
1171        registry.add_inline("standout/list-view", "user override");
1172
1173        // User version should win
1174        let content = registry.get_content("standout/list-view").unwrap();
1175        assert_eq!(content, "user override");
1176    }
1177
1178    #[test]
1179    fn test_framework_entries() {
1180        let mut registry = TemplateRegistry::new();
1181
1182        let entries: &[(&str, &str)] = &[
1183            ("standout/list-view.jinja", "List view content"),
1184            ("standout/detail-view.jinja", "Detail view content"),
1185        ];
1186
1187        registry.add_framework_entries(entries);
1188
1189        // Should be accessible by both names
1190        assert!(registry.get("standout/list-view").is_ok());
1191        assert!(registry.get("standout/list-view.jinja").is_ok());
1192        assert!(registry.get("standout/detail-view").is_ok());
1193    }
1194
1195    #[test]
1196    fn test_framework_names_iterator() {
1197        let mut registry = TemplateRegistry::new();
1198        registry.add_framework("standout/a", "content a");
1199        registry.add_framework("standout/b", "content b");
1200
1201        let names: Vec<&str> = registry.framework_names().collect();
1202        assert_eq!(names.len(), 2);
1203        assert!(names.contains(&"standout/a"));
1204        assert!(names.contains(&"standout/b"));
1205    }
1206
1207    #[test]
1208    fn test_framework_clear() {
1209        let mut registry = TemplateRegistry::new();
1210        registry.add_framework("standout/list-view", "content");
1211
1212        assert!(registry.has_framework_templates());
1213
1214        registry.clear_framework();
1215
1216        assert!(!registry.has_framework_templates());
1217        assert!(registry.get("standout/list-view").is_err());
1218    }
1219
1220    #[test]
1221    fn test_framework_included_in_len_and_names() {
1222        let mut registry = TemplateRegistry::new();
1223        registry.add_inline("user-template", "user content");
1224        registry.add_framework("standout/framework", "framework content");
1225
1226        // Both should be counted
1227        assert_eq!(registry.len(), 2);
1228
1229        let names: Vec<&str> = registry.names().collect();
1230        assert!(names.contains(&"user-template"));
1231        assert!(names.contains(&"standout/framework"));
1232    }
1233
1234    #[test]
1235    fn test_framework_clear_all_clears_framework() {
1236        let mut registry = TemplateRegistry::new();
1237        registry.add_framework("standout/test", "content");
1238
1239        registry.clear();
1240
1241        assert!(registry.is_empty());
1242        assert!(!registry.has_framework_templates());
1243    }
1244}