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