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}