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, resolve_in_map, FileRegistry, FileRegistryConfig, LoadError,
72    LoadedEntry, 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 are resolved with extension-agnostic fallback: if the exact name
614    /// isn't found and it has a recognized extension, the extension is stripped
615    /// and the base name is tried. This allows lookups like `"list.j2"` to
616    /// find a template registered as `"list"` (from `list.jinja`).
617    ///
618    /// # Resolution Priority
619    ///
620    /// Templates are resolved in this order:
621    /// 1. Inline templates (highest priority)
622    /// 2. File-based templates from `add_from_files`
623    /// 3. Directory-based templates from `add_template_dir`
624    /// 4. Framework templates (lowest priority)
625    ///
626    /// This allows user templates to override framework defaults.
627    ///
628    /// # Errors
629    ///
630    /// Returns [`RegistryError::NotFound`] if the template doesn't exist.
631    pub fn get(&self, name: &str) -> Result<ResolvedTemplate, RegistryError> {
632        // Check inline first (highest priority)
633        if let Some(content) = resolve_in_map(&self.inline, name, TEMPLATE_EXTENSIONS) {
634            return Ok(ResolvedTemplate::Inline(content.clone()));
635        }
636
637        // Check file-based templates from add_from_files
638        if let Some(path) = resolve_in_map(&self.files, name, TEMPLATE_EXTENSIONS) {
639            return Ok(ResolvedTemplate::File(path.clone()));
640        }
641
642        // Check directory-based file registry (has its own extension fallback)
643        if let Some(entry) = self.inner.get_entry(name) {
644            return Ok(ResolvedTemplate::from(entry));
645        }
646
647        // Check framework templates (lowest priority)
648        if let Some(content) = resolve_in_map(&self.framework, name, TEMPLATE_EXTENSIONS) {
649            return Ok(ResolvedTemplate::Inline(content.clone()));
650        }
651
652        Err(RegistryError::NotFound {
653            name: name.to_string(),
654        })
655    }
656
657    /// Gets the content of a template, reading from disk if necessary.
658    ///
659    /// For inline templates, returns the stored content directly.
660    /// For file templates, reads the file from disk (enabling hot reload).
661    ///
662    /// # Errors
663    ///
664    /// Returns an error if the template is not found or cannot be read from disk.
665    pub fn get_content(&self, name: &str) -> Result<String, RegistryError> {
666        let resolved = self.get(name)?;
667        match resolved {
668            ResolvedTemplate::Inline(content) => Ok(content),
669            ResolvedTemplate::File(path) => {
670                std::fs::read_to_string(&path).map_err(|e| RegistryError::ReadError {
671                    path,
672                    message: e.to_string(),
673                })
674            }
675        }
676    }
677
678    /// Refreshes the registry from registered directories.
679    ///
680    /// This re-walks all registered template directories and rebuilds the
681    /// resolution map. Call this if:
682    ///
683    /// - You've added template directories after the first render
684    /// - Template files have been added/removed from disk
685    ///
686    /// # Panics
687    ///
688    /// Panics if a collision is detected (same name from different directories).
689    pub fn refresh(&mut self) -> Result<(), RegistryError> {
690        self.inner.refresh().map_err(RegistryError::from)
691    }
692
693    /// Returns the number of registered templates.
694    ///
695    /// Note: This counts both extensionless and with-extension entries,
696    /// so it may be higher than the number of unique template files.
697    pub fn len(&self) -> usize {
698        self.inline.len() + self.files.len() + self.inner.len() + self.framework.len()
699    }
700
701    /// Returns true if no templates are registered.
702    pub fn is_empty(&self) -> bool {
703        self.inline.is_empty()
704            && self.files.is_empty()
705            && self.inner.is_empty()
706            && self.framework.is_empty()
707    }
708
709    /// Returns an iterator over all registered template names.
710    pub fn names(&self) -> impl Iterator<Item = &str> {
711        self.inline
712            .keys()
713            .map(|s| s.as_str())
714            .chain(self.files.keys().map(|s| s.as_str()))
715            .chain(self.inner.names())
716            .chain(self.framework.keys().map(|s| s.as_str()))
717    }
718
719    /// Clears all templates from the registry.
720    pub fn clear(&mut self) {
721        self.inline.clear();
722        self.files.clear();
723        self.sources.clear();
724        self.inner.clear();
725        self.framework.clear();
726    }
727
728    /// Returns true if the registry has framework templates.
729    pub fn has_framework_templates(&self) -> bool {
730        !self.framework.is_empty()
731    }
732
733    /// Returns an iterator over framework template names.
734    pub fn framework_names(&self) -> impl Iterator<Item = &str> {
735        self.framework.keys().map(|s| s.as_str())
736    }
737}
738
739/// Walks a template directory and collects template files.
740///
741/// This function traverses the directory recursively, finding all files
742/// with recognized template extensions ([`TEMPLATE_EXTENSIONS`]).
743///
744/// # Arguments
745///
746/// * `root` - The template directory root to walk
747///
748/// # Returns
749///
750/// A vector of [`TemplateFile`] entries, one for each discovered template.
751/// The vector is not sorted; use [`TemplateFile::extension_priority`] for ordering.
752///
753/// # Errors
754///
755/// Returns an error if the directory cannot be read or traversed.
756///
757/// # Example
758///
759/// ```rust,ignore
760/// let files = walk_template_dir("./templates")?;
761/// for file in &files {
762///     println!("{} -> {}", file.name, file.absolute_path.display());
763/// }
764/// ```
765pub fn walk_template_dir(root: impl AsRef<Path>) -> Result<Vec<TemplateFile>, std::io::Error> {
766    let files = file_loader::walk_dir(root.as_ref(), TEMPLATE_EXTENSIONS)
767        .map_err(|e| std::io::Error::other(e.to_string()))?;
768
769    Ok(files.into_iter().map(TemplateFile::from).collect())
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    // =========================================================================
777    // TemplateFile tests
778    // =========================================================================
779
780    #[test]
781    fn test_template_file_extension_priority() {
782        let jinja = TemplateFile::new("config", "config.jinja", "/a/config.jinja", "/a");
783        let jinja2 = TemplateFile::new("config", "config.jinja2", "/a/config.jinja2", "/a");
784        let j2 = TemplateFile::new("config", "config.j2", "/a/config.j2", "/a");
785        let stpl = TemplateFile::new("config", "config.stpl", "/a/config.stpl", "/a");
786        let txt = TemplateFile::new("config", "config.txt", "/a/config.txt", "/a");
787        let unknown = TemplateFile::new("config", "config.xyz", "/a/config.xyz", "/a");
788
789        assert_eq!(jinja.extension_priority(), 0);
790        assert_eq!(jinja2.extension_priority(), 1);
791        assert_eq!(j2.extension_priority(), 2);
792        assert_eq!(stpl.extension_priority(), 3);
793        assert_eq!(txt.extension_priority(), 4);
794        assert_eq!(unknown.extension_priority(), usize::MAX);
795    }
796
797    // =========================================================================
798    // TemplateRegistry inline tests
799    // =========================================================================
800
801    #[test]
802    fn test_registry_add_inline() {
803        let mut registry = TemplateRegistry::new();
804        registry.add_inline("header", "{{ title }}");
805
806        assert_eq!(registry.len(), 1);
807        assert!(!registry.is_empty());
808
809        let content = registry.get_content("header").unwrap();
810        assert_eq!(content, "{{ title }}");
811    }
812
813    #[test]
814    fn test_registry_inline_overwrites() {
815        let mut registry = TemplateRegistry::new();
816        registry.add_inline("header", "first");
817        registry.add_inline("header", "second");
818
819        let content = registry.get_content("header").unwrap();
820        assert_eq!(content, "second");
821    }
822
823    #[test]
824    fn test_registry_not_found() {
825        let registry = TemplateRegistry::new();
826        let result = registry.get("nonexistent");
827
828        assert!(matches!(result, Err(RegistryError::NotFound { .. })));
829    }
830
831    // =========================================================================
832    // File-based template tests (using synthetic data)
833    // =========================================================================
834
835    #[test]
836    fn test_registry_add_from_files() {
837        let mut registry = TemplateRegistry::new();
838
839        let files = vec![
840            TemplateFile::new(
841                "config",
842                "config.jinja",
843                "/templates/config.jinja",
844                "/templates",
845            ),
846            TemplateFile::new(
847                "todos/list",
848                "todos/list.jinja",
849                "/templates/todos/list.jinja",
850                "/templates",
851            ),
852        ];
853
854        registry.add_from_files(files).unwrap();
855
856        // Should have 4 entries: 2 names + 2 names with extension
857        assert_eq!(registry.len(), 4);
858
859        // Can access by name without extension
860        assert!(registry.get("config").is_ok());
861        assert!(registry.get("todos/list").is_ok());
862
863        // Can access by name with extension
864        assert!(registry.get("config.jinja").is_ok());
865        assert!(registry.get("todos/list.jinja").is_ok());
866    }
867
868    #[test]
869    fn test_registry_extension_priority() {
870        let mut registry = TemplateRegistry::new();
871
872        // Add files with different extensions for same base name
873        // (j2 should be ignored because jinja has higher priority)
874        let files = vec![
875            TemplateFile::new("config", "config.j2", "/templates/config.j2", "/templates"),
876            TemplateFile::new(
877                "config",
878                "config.jinja",
879                "/templates/config.jinja",
880                "/templates",
881            ),
882        ];
883
884        registry.add_from_files(files).unwrap();
885
886        // Extensionless name should resolve to .jinja
887        let resolved = registry.get("config").unwrap();
888        match resolved {
889            ResolvedTemplate::File(path) => {
890                assert!(path.to_string_lossy().ends_with("config.jinja"));
891            }
892            _ => panic!("Expected file template"),
893        }
894    }
895
896    #[test]
897    fn test_registry_collision_different_dirs() {
898        let mut registry = TemplateRegistry::new();
899
900        let files = vec![
901            TemplateFile::new(
902                "config",
903                "config.jinja",
904                "/app/templates/config.jinja",
905                "/app/templates",
906            ),
907            TemplateFile::new(
908                "config",
909                "config.jinja",
910                "/plugins/templates/config.jinja",
911                "/plugins/templates",
912            ),
913        ];
914
915        let result = registry.add_from_files(files);
916
917        assert!(matches!(result, Err(RegistryError::Collision { .. })));
918
919        if let Err(RegistryError::Collision { name, .. }) = result {
920            assert_eq!(name, "config");
921        }
922    }
923
924    #[test]
925    fn test_registry_inline_shadows_file() {
926        let mut registry = TemplateRegistry::new();
927
928        // Add file-based template first
929        let files = vec![TemplateFile::new(
930            "config",
931            "config.jinja",
932            "/templates/config.jinja",
933            "/templates",
934        )];
935        registry.add_from_files(files).unwrap();
936
937        // Add inline with same name (should shadow)
938        registry.add_inline("config", "inline content");
939
940        let content = registry.get_content("config").unwrap();
941        assert_eq!(content, "inline content");
942    }
943
944    #[test]
945    fn test_registry_names_iterator() {
946        let mut registry = TemplateRegistry::new();
947        registry.add_inline("a", "content a");
948        registry.add_inline("b", "content b");
949
950        let names: Vec<&str> = registry.names().collect();
951        assert!(names.contains(&"a"));
952        assert!(names.contains(&"b"));
953    }
954
955    #[test]
956    fn test_registry_clear() {
957        let mut registry = TemplateRegistry::new();
958        registry.add_inline("a", "content");
959
960        assert!(!registry.is_empty());
961        registry.clear();
962        assert!(registry.is_empty());
963    }
964
965    // =========================================================================
966    // Error display tests
967    // =========================================================================
968
969    #[test]
970    fn test_error_display_collision() {
971        let err = RegistryError::Collision {
972            name: "config".to_string(),
973            existing_path: PathBuf::from("/a/config.jinja"),
974            existing_dir: PathBuf::from("/a"),
975            conflicting_path: PathBuf::from("/b/config.jinja"),
976            conflicting_dir: PathBuf::from("/b"),
977        };
978
979        let display = err.to_string();
980        assert!(display.contains("config"));
981        assert!(display.contains("/a/config.jinja"));
982        assert!(display.contains("/b/config.jinja"));
983    }
984
985    #[test]
986    fn test_error_display_not_found() {
987        let err = RegistryError::NotFound {
988            name: "missing".to_string(),
989        };
990
991        let display = err.to_string();
992        assert!(display.contains("missing"));
993    }
994
995    // =========================================================================
996    // from_embedded_entries tests
997    // =========================================================================
998
999    #[test]
1000    fn test_from_embedded_entries_single() {
1001        let entries: &[(&str, &str)] = &[("hello.jinja", "Hello {{ name }}")];
1002        let registry = TemplateRegistry::from_embedded_entries(entries);
1003
1004        // Should be accessible by both names
1005        assert!(registry.get("hello").is_ok());
1006        assert!(registry.get("hello.jinja").is_ok());
1007
1008        let content = registry.get_content("hello").unwrap();
1009        assert_eq!(content, "Hello {{ name }}");
1010    }
1011
1012    #[test]
1013    fn test_from_embedded_entries_multiple() {
1014        let entries: &[(&str, &str)] = &[
1015            ("header.jinja", "{{ title }}"),
1016            ("footer.jinja", "Copyright {{ year }}"),
1017        ];
1018        let registry = TemplateRegistry::from_embedded_entries(entries);
1019
1020        assert_eq!(registry.len(), 4); // 2 base + 2 with ext
1021        assert!(registry.get("header").is_ok());
1022        assert!(registry.get("footer").is_ok());
1023    }
1024
1025    #[test]
1026    fn test_from_embedded_entries_nested_paths() {
1027        let entries: &[(&str, &str)] = &[
1028            ("report/summary.jinja", "Summary: {{ text }}"),
1029            ("report/details.jinja", "Details: {{ info }}"),
1030        ];
1031        let registry = TemplateRegistry::from_embedded_entries(entries);
1032
1033        assert!(registry.get("report/summary").is_ok());
1034        assert!(registry.get("report/summary.jinja").is_ok());
1035        assert!(registry.get("report/details").is_ok());
1036    }
1037
1038    #[test]
1039    fn test_from_embedded_entries_extension_priority() {
1040        // .jinja has higher priority than .txt (index 0 vs index 3)
1041        let entries: &[(&str, &str)] = &[
1042            ("config.txt", "txt content"),
1043            ("config.jinja", "jinja content"),
1044        ];
1045        let registry = TemplateRegistry::from_embedded_entries(entries);
1046
1047        // Base name should resolve to higher priority (.jinja)
1048        let content = registry.get_content("config").unwrap();
1049        assert_eq!(content, "jinja content");
1050
1051        // Both can still be accessed by full name
1052        assert_eq!(registry.get_content("config.txt").unwrap(), "txt content");
1053        assert_eq!(
1054            registry.get_content("config.jinja").unwrap(),
1055            "jinja content"
1056        );
1057    }
1058
1059    #[test]
1060    fn test_from_embedded_entries_extension_priority_reverse_order() {
1061        // Same test but with entries in reverse order to ensure sorting works
1062        let entries: &[(&str, &str)] = &[
1063            ("config.jinja", "jinja content"),
1064            ("config.txt", "txt content"),
1065        ];
1066        let registry = TemplateRegistry::from_embedded_entries(entries);
1067
1068        // Base name should still resolve to higher priority (.jinja)
1069        let content = registry.get_content("config").unwrap();
1070        assert_eq!(content, "jinja content");
1071    }
1072
1073    #[test]
1074    fn test_from_embedded_entries_names_iterator() {
1075        let entries: &[(&str, &str)] = &[("a.jinja", "content a"), ("nested/b.jinja", "content b")];
1076        let registry = TemplateRegistry::from_embedded_entries(entries);
1077
1078        let names: Vec<&str> = registry.names().collect();
1079        assert!(names.contains(&"a"));
1080        assert!(names.contains(&"a.jinja"));
1081        assert!(names.contains(&"nested/b"));
1082        assert!(names.contains(&"nested/b.jinja"));
1083    }
1084
1085    #[test]
1086    fn test_from_embedded_entries_empty() {
1087        let entries: &[(&str, &str)] = &[];
1088        let registry = TemplateRegistry::from_embedded_entries(entries);
1089
1090        assert!(registry.is_empty());
1091        assert_eq!(registry.len(), 0);
1092    }
1093
1094    #[test]
1095    fn test_extensionless_includes_work() {
1096        // Simulates the user's report: {% include "_partial" %} should work
1097        // when the file is actually "_partial.jinja"
1098        let entries: &[(&str, &str)] = &[
1099            ("main.jinja", "Start {% include '_partial' %} End"),
1100            ("_partial.jinja", "PARTIAL_CONTENT"),
1101        ];
1102        let registry = TemplateRegistry::from_embedded_entries(entries);
1103
1104        // Build MiniJinja environment the same way App.render() does
1105        let mut env = minijinja::Environment::new();
1106        for name in registry.names() {
1107            if let Ok(content) = registry.get_content(name) {
1108                env.add_template_owned(name.to_string(), content).unwrap();
1109            }
1110        }
1111
1112        // Verify extensionless include works
1113        let tmpl = env.get_template("main").unwrap();
1114        let output = tmpl.render(()).unwrap();
1115        assert_eq!(output, "Start PARTIAL_CONTENT End");
1116    }
1117
1118    #[test]
1119    fn test_extensionless_includes_with_extension_syntax() {
1120        // Also verify that {% include "_partial.jinja" %} works
1121        let entries: &[(&str, &str)] = &[
1122            ("main.jinja", "Start {% include '_partial.jinja' %} End"),
1123            ("_partial.jinja", "PARTIAL_CONTENT"),
1124        ];
1125        let registry = TemplateRegistry::from_embedded_entries(entries);
1126
1127        let mut env = minijinja::Environment::new();
1128        for name in registry.names() {
1129            if let Ok(content) = registry.get_content(name) {
1130                env.add_template_owned(name.to_string(), content).unwrap();
1131            }
1132        }
1133
1134        let tmpl = env.get_template("main").unwrap();
1135        let output = tmpl.render(()).unwrap();
1136        assert_eq!(output, "Start PARTIAL_CONTENT End");
1137    }
1138
1139    // =========================================================================
1140    // Framework templates tests
1141    // =========================================================================
1142
1143    #[test]
1144    fn test_framework_add_and_get() {
1145        let mut registry = TemplateRegistry::new();
1146        registry.add_framework("standout/list-view", "Framework list view");
1147
1148        assert!(registry.has_framework_templates());
1149        let content = registry.get_content("standout/list-view").unwrap();
1150        assert_eq!(content, "Framework list view");
1151    }
1152
1153    #[test]
1154    fn test_framework_lowest_priority() {
1155        let mut registry = TemplateRegistry::new();
1156
1157        // Add framework template
1158        registry.add_framework("config", "framework content");
1159
1160        // Add inline template with same name (should shadow)
1161        registry.add_inline("config", "inline content");
1162
1163        // Inline should win
1164        let content = registry.get_content("config").unwrap();
1165        assert_eq!(content, "inline content");
1166    }
1167
1168    #[test]
1169    fn test_framework_user_can_override() {
1170        let mut registry = TemplateRegistry::new();
1171
1172        // Add framework template in standout/ namespace
1173        registry.add_framework("standout/list-view", "framework default");
1174
1175        // User creates their own version
1176        registry.add_inline("standout/list-view", "user override");
1177
1178        // User version should win
1179        let content = registry.get_content("standout/list-view").unwrap();
1180        assert_eq!(content, "user override");
1181    }
1182
1183    #[test]
1184    fn test_framework_entries() {
1185        let mut registry = TemplateRegistry::new();
1186
1187        let entries: &[(&str, &str)] = &[
1188            ("standout/list-view.jinja", "List view content"),
1189            ("standout/detail-view.jinja", "Detail view content"),
1190        ];
1191
1192        registry.add_framework_entries(entries);
1193
1194        // Should be accessible by both names
1195        assert!(registry.get("standout/list-view").is_ok());
1196        assert!(registry.get("standout/list-view.jinja").is_ok());
1197        assert!(registry.get("standout/detail-view").is_ok());
1198    }
1199
1200    #[test]
1201    fn test_framework_names_iterator() {
1202        let mut registry = TemplateRegistry::new();
1203        registry.add_framework("standout/a", "content a");
1204        registry.add_framework("standout/b", "content b");
1205
1206        let names: Vec<&str> = registry.framework_names().collect();
1207        assert_eq!(names.len(), 2);
1208        assert!(names.contains(&"standout/a"));
1209        assert!(names.contains(&"standout/b"));
1210    }
1211
1212    #[test]
1213    fn test_framework_clear() {
1214        let mut registry = TemplateRegistry::new();
1215        registry.add_framework("standout/list-view", "content");
1216
1217        assert!(registry.has_framework_templates());
1218
1219        registry.clear_framework();
1220
1221        assert!(!registry.has_framework_templates());
1222        assert!(registry.get("standout/list-view").is_err());
1223    }
1224
1225    #[test]
1226    fn test_framework_included_in_len_and_names() {
1227        let mut registry = TemplateRegistry::new();
1228        registry.add_inline("user-template", "user content");
1229        registry.add_framework("standout/framework", "framework content");
1230
1231        // Both should be counted
1232        assert_eq!(registry.len(), 2);
1233
1234        let names: Vec<&str> = registry.names().collect();
1235        assert!(names.contains(&"user-template"));
1236        assert!(names.contains(&"standout/framework"));
1237    }
1238
1239    #[test]
1240    fn test_framework_clear_all_clears_framework() {
1241        let mut registry = TemplateRegistry::new();
1242        registry.add_framework("standout/test", "content");
1243
1244        registry.clear();
1245
1246        assert!(registry.is_empty());
1247        assert!(!registry.has_framework_templates());
1248    }
1249
1250    // =========================================================================
1251    // Extension-agnostic resolution tests
1252    // =========================================================================
1253
1254    #[test]
1255    fn test_inline_cross_extension_lookup() {
1256        // Inline registered as "list.jinja", looked up as "list.j2"
1257        let entries: &[(&str, &str)] = &[("list.jinja", "List content")];
1258        let registry = TemplateRegistry::from_embedded_entries(entries);
1259
1260        // "list.j2" should fall back to "list" (base name)
1261        let content = registry.get_content("list.j2").unwrap();
1262        assert_eq!(content, "List content");
1263    }
1264
1265    #[test]
1266    fn test_inline_cross_extension_nested_path() {
1267        let entries: &[(&str, &str)] = &[("todos/list.jinja", "Todos")];
1268        let registry = TemplateRegistry::from_embedded_entries(entries);
1269
1270        assert_eq!(registry.get_content("todos/list.j2").unwrap(), "Todos");
1271        assert_eq!(registry.get_content("todos/list.stpl").unwrap(), "Todos");
1272        assert_eq!(registry.get_content("todos/list").unwrap(), "Todos");
1273    }
1274
1275    #[test]
1276    fn test_framework_cross_extension_lookup() {
1277        let mut registry = TemplateRegistry::new();
1278        let entries: &[(&str, &str)] = &[("standout/list-view.jinja", "Framework view")];
1279        registry.add_framework_entries(entries);
1280
1281        // Look up with different extension
1282        let content = registry.get_content("standout/list-view.j2").unwrap();
1283        assert_eq!(content, "Framework view");
1284    }
1285
1286    #[test]
1287    fn test_files_cross_extension_lookup() {
1288        let mut registry = TemplateRegistry::new();
1289        let files = vec![TemplateFile::new(
1290            "config",
1291            "config.jinja",
1292            "/templates/config.jinja",
1293            "/templates",
1294        )];
1295        registry.add_from_files(files).unwrap();
1296
1297        // Look up with different extension should find via base name
1298        assert!(registry.get("config.j2").is_ok());
1299        assert!(registry.get("config.stpl").is_ok());
1300        assert!(registry.get("config.txt").is_ok());
1301    }
1302}