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