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}