standout_render/file_loader.rs
1//! File-based resource loading for templates and stylesheets.
2//!
3//! Standout supports file-based configuration for templates and stylesheets,
4//! enabling a web-app-like development workflow for CLI applications.
5//!
6//! # Problem
7//!
8//! CLI applications need to manage templates and stylesheets. Developers want:
9//!
10//! - Separation of concerns - Keep templates and styles in files, not Rust code
11//! - Accessible to non-developers - Designers can edit YAML/Jinja without Rust
12//! - Rapid iteration - Changes visible immediately without recompilation
13//! - Single-binary distribution - Released apps should be self-contained
14//!
15//! These requirements create tension: development wants external files for flexibility,
16//! while release wants everything embedded for distribution.
17//!
18//! # Solution
19//!
20//! The file loader provides a unified system that:
21//!
22//! - Development mode: Reads files from disk with hot reload on each access
23//! - Release mode: Embeds all files into the binary at compile time via proc macros
24//!
25//! ## Directory Structure
26//!
27//! Organize resources in dedicated directories:
28//!
29//! ```text
30//! my-app/
31//! ├── templates/
32//! │ ├── list.jinja
33//! │ └── report/
34//! │ └── summary.jinja
35//! └── styles/
36//! ├── default.yaml
37//! └── themes/
38//! └── dark.yaml
39//! ```
40//!
41//! ## Name Resolution
42//!
43//! Files are referenced by their relative path from the root, without extension:
44//!
45//! | File Path | Resolution Name |
46//! |-----------|-----------------|
47//! | `templates/list.jinja` | `"list"` |
48//! | `templates/report/summary.jinja` | `"report/summary"` |
49//! | `styles/themes/dark.yaml` | `"themes/dark"` |
50//!
51//! ## Development Usage
52//!
53//! Register directories and access resources by name:
54//!
55//! ```rust,ignore
56//! use standout_render::file_loader::{FileRegistry, FileRegistryConfig};
57//!
58//! let config = FileRegistryConfig {
59//! extensions: &[".yaml", ".yml"],
60//! transform: |content| Ok(content.to_string()),
61//! };
62//!
63//! let mut registry = FileRegistry::new(config);
64//! registry.add_dir("./styles")?;
65//!
66//! // Re-reads from disk each call - edits are immediately visible
67//! let content = registry.get("themes/dark")?;
68//! ```
69//!
70//! ## Release Embedding
71//!
72//! For release builds, use the embedding macros to bake files into the binary:
73//!
74//! ```rust,ignore
75//! // At compile time, walks directory and embeds all files
76//! let styles = standout_render::embed_styles!("./styles");
77//!
78//! // Same API - resources accessed by name
79//! let theme = styles.get("themes/dark")?;
80//! ```
81//!
82//! The macros walk the directory at compile time, read each file, and generate
83//! code that registers all resources with their derived names.
84//!
85//! See the `standout_macros` crate for detailed documentation on
86//! `embed_templates!` and `embed_styles!`.
87//!
88//! # Extension Priority
89//!
90//! Extensions are specified in priority order. When multiple files share the same
91//! base name, the extension appearing earlier wins for extensionless lookups:
92//!
93//! ```rust,ignore
94//! // With extensions: [".yaml", ".yml"]
95//! // If both default.yaml and default.yml exist:
96//! registry.get("default") // → default.yaml (higher priority)
97//! registry.get("default.yml") // → default.yml (explicit)
98//! ```
99//!
100//! # Extension-Agnostic Resolution
101//!
102//! Lookups are extension-agnostic: if a name with a recognized extension isn't
103//! found, the extension is stripped and the base name is tried. This allows
104//! callers to use any known extension regardless of the actual file extension:
105//!
106//! ```rust,ignore
107//! // File on disk: config.yaml
108//! // Registry has: "config" and "config.yaml"
109//! registry.get("config") // → found (extensionless key)
110//! registry.get("config.yaml") // → found (exact match)
111//! registry.get("config.yml") // → found (strips .yml, falls back to "config")
112//! ```
113//!
114//! # Collision Detection
115//!
116//! Cross-directory collisions (same name from different directories) cause a panic
117//! with detailed diagnostics. This catches configuration mistakes early.
118//!
119//! Same-directory, different-extension scenarios are resolved by priority (not errors).
120//!
121//! # Supported Resource Types
122//!
123//! | Resource | Extensions | Transform |
124//! |----------|------------|-----------|
125//! | Templates | `.jinja`, `.jinja2`, `.j2`, `.txt` | Identity |
126//! | Stylesheets | `.yaml`, `.yml` | YAML parsing |
127//! | Custom | User-defined | User-defined |
128//!
129//! The registry is generic over content type `T`, enabling consistent behavior
130//! across all resource types with type-specific parsing via the transform function.
131
132use std::collections::HashMap;
133use std::path::{Path, PathBuf};
134
135/// A file discovered during directory walking.
136///
137/// This struct captures essential metadata about a file without reading its content,
138/// enabling lazy loading and hot reloading.
139///
140/// # Fields
141///
142/// - `name`: The resolution name without extension (e.g., `"todos/list"`)
143/// - `name_with_ext`: The resolution name with extension (e.g., `"todos/list.tmpl"`)
144/// - `path`: Absolute filesystem path for reading content
145/// - `source_dir`: The root directory this file came from (for collision reporting)
146///
147/// # Example
148///
149/// For a file at `/app/templates/todos/list.tmpl` with root `/app/templates`:
150///
151/// ```rust,ignore
152/// LoadedFile {
153/// name: "todos/list".to_string(),
154/// name_with_ext: "todos/list.tmpl".to_string(),
155/// path: PathBuf::from("/app/templates/todos/list.tmpl"),
156/// source_dir: PathBuf::from("/app/templates"),
157/// }
158/// ```
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct LoadedFile {
161 /// Resolution name without extension (e.g., "config" or "todos/list").
162 pub name: String,
163 /// Resolution name with extension (e.g., "config.tmpl" or "todos/list.tmpl").
164 pub name_with_ext: String,
165 /// Absolute path to the file.
166 pub path: PathBuf,
167 /// The source directory this file belongs to.
168 pub source_dir: PathBuf,
169}
170
171impl LoadedFile {
172 /// Creates a new loaded file descriptor.
173 pub fn new(
174 name: impl Into<String>,
175 name_with_ext: impl Into<String>,
176 path: impl Into<PathBuf>,
177 source_dir: impl Into<PathBuf>,
178 ) -> Self {
179 Self {
180 name: name.into(),
181 name_with_ext: name_with_ext.into(),
182 path: path.into(),
183 source_dir: source_dir.into(),
184 }
185 }
186
187 /// Returns the extension priority for this file given a list of extensions.
188 ///
189 /// Lower values indicate higher priority. Returns `usize::MAX` if the file's
190 /// extension is not in the list.
191 pub fn extension_priority(&self, extensions: &[&str]) -> usize {
192 extension_priority(&self.name_with_ext, extensions)
193 }
194}
195
196// =============================================================================
197// Shared helper functions for extension handling
198// =============================================================================
199
200/// Returns the extension priority for a filename (lower = higher priority).
201///
202/// Extensions are matched in order against the provided list. The index of the
203/// first matching extension is returned. If no extension matches, returns `usize::MAX`.
204///
205/// # Example
206///
207/// ```rust
208/// use standout_render::file_loader::extension_priority;
209///
210/// let extensions = &[".yaml", ".yml"];
211/// assert_eq!(extension_priority("config.yaml", extensions), 0);
212/// assert_eq!(extension_priority("config.yml", extensions), 1);
213/// assert_eq!(extension_priority("config.txt", extensions), usize::MAX);
214/// ```
215pub fn extension_priority(name: &str, extensions: &[&str]) -> usize {
216 for (i, ext) in extensions.iter().enumerate() {
217 if name.ends_with(ext) {
218 return i;
219 }
220 }
221 usize::MAX
222}
223
224/// Strips a recognized extension from a filename.
225///
226/// Returns the base name without extension if a recognized extension is found,
227/// otherwise returns the original name.
228///
229/// # Example
230///
231/// ```rust
232/// use standout_render::file_loader::strip_extension;
233///
234/// let extensions = &[".yaml", ".yml"];
235/// assert_eq!(strip_extension("config.yaml", extensions), "config");
236/// assert_eq!(strip_extension("themes/dark.yml", extensions), "themes/dark");
237/// assert_eq!(strip_extension("readme.txt", extensions), "readme.txt");
238/// ```
239pub fn strip_extension(name: &str, extensions: &[&str]) -> String {
240 for ext in extensions {
241 if let Some(base) = name.strip_suffix(ext) {
242 return base.to_string();
243 }
244 }
245 name.to_string()
246}
247
248/// Looks up a key in a HashMap with extension-agnostic fallback.
249///
250/// Tries the exact name first. If not found and the name has a recognized
251/// extension, strips the extension and tries the base name. This enables
252/// lookups like `"config.j2"` to find entries registered as `"config"`.
253///
254/// # Example
255///
256/// ```rust
257/// use std::collections::HashMap;
258/// use standout_render::file_loader::resolve_in_map;
259///
260/// let mut map = HashMap::new();
261/// map.insert("config".to_string(), "content");
262/// map.insert("config.yaml".to_string(), "yaml content");
263///
264/// let extensions = &[".yaml", ".yml", ".j2"];
265///
266/// // Exact match
267/// assert_eq!(resolve_in_map(&map, "config", extensions), Some(&"content"));
268/// assert_eq!(resolve_in_map(&map, "config.yaml", extensions), Some(&"yaml content"));
269///
270/// // Extension-agnostic fallback: .j2 is stripped, finds "config"
271/// assert_eq!(resolve_in_map(&map, "config.j2", extensions), Some(&"content"));
272///
273/// // No match
274/// assert_eq!(resolve_in_map(&map, "missing", extensions), None::<&&str>);
275/// ```
276pub fn resolve_in_map<'a, V>(
277 map: &'a HashMap<String, V>,
278 name: &str,
279 extensions: &[&str],
280) -> Option<&'a V> {
281 if let Some(value) = map.get(name) {
282 return Some(value);
283 }
284 let base_name = strip_extension(name, extensions);
285 if base_name != name {
286 map.get(base_name.as_str())
287 } else {
288 None
289 }
290}
291
292/// Builds a registry map from embedded entries with extension priority handling.
293///
294/// This is the core logic for creating registries from compile-time embedded resources.
295/// It handles:
296///
297/// 1. Extension priority: Entries are sorted so higher-priority extensions are processed first
298/// 2. Dual registration: Each entry is accessible by both base name and full name with extension
299/// 3. Transform: Each entry's content is transformed via the provided function
300///
301/// # Arguments
302///
303/// * `entries` - Slice of `(name_with_ext, content)` pairs
304/// * `extensions` - Extension list in priority order (first = highest)
305/// * `transform` - Function to transform content into target type
306///
307/// # Returns
308///
309/// A `HashMap<String, T>` where each entry is accessible by both its base name
310/// (without extension) and its full name (with extension).
311///
312/// # Example
313///
314/// ```rust,ignore
315/// use standout_render::file_loader::build_embedded_registry;
316///
317/// let entries = &[
318/// ("config.yaml", "key: value"),
319/// ("config.yml", "other: data"),
320/// ("themes/dark.yaml", "bg: black"),
321/// ];
322///
323/// let registry = build_embedded_registry(
324/// entries,
325/// &[".yaml", ".yml"],
326/// |content| Ok(content.to_string()),
327/// )?;
328///
329/// // "config" resolves to config.yaml (higher priority)
330/// // Both "config.yaml" and "config.yml" are accessible explicitly
331/// ```
332pub fn build_embedded_registry<T, E, F>(
333 entries: &[(&str, &str)],
334 extensions: &[&str],
335 transform: F,
336) -> Result<HashMap<String, T>, E>
337where
338 T: Clone,
339 F: Fn(&str) -> Result<T, E>,
340{
341 let mut registry = HashMap::new();
342
343 // Sort by extension priority so higher-priority extensions are processed first
344 let mut sorted: Vec<_> = entries.iter().collect();
345 sorted.sort_by_key(|(name, _)| extension_priority(name, extensions));
346
347 let mut seen_base_names = std::collections::HashSet::new();
348
349 for (name_with_ext, content) in sorted {
350 let value = transform(content)?;
351 let base_name = strip_extension(name_with_ext, extensions);
352
353 // Register under full name with extension
354 registry.insert((*name_with_ext).to_string(), value.clone());
355
356 // Register under base name only if not already registered
357 // (higher priority extension was already processed)
358 if seen_base_names.insert(base_name.clone()) {
359 registry.insert(base_name, value);
360 }
361 }
362
363 Ok(registry)
364}
365
366/// How a resource is stored—file path (dev) or content (release).
367///
368/// This enum enables different storage strategies:
369///
370/// - [`File`](LoadedEntry::File): Store the path, read on demand (hot reload in dev)
371/// - [`Embedded`](LoadedEntry::Embedded): Store content directly (no filesystem access)
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum LoadedEntry<T> {
374 /// Path to read from disk (dev mode, enables hot reload).
375 ///
376 /// On each access, the file is re-read and transformed, picking up any changes.
377 File(PathBuf),
378
379 /// Pre-loaded/embedded content (release mode).
380 ///
381 /// Content is stored directly, avoiding filesystem access at runtime.
382 Embedded(T),
383}
384
385/// Configuration for a file registry.
386///
387/// Specifies which file extensions to recognize and how to transform file content
388/// into the target type.
389///
390/// # Example
391///
392/// ```rust,ignore
393/// // For template files (identity transform)
394/// FileRegistryConfig {
395/// extensions: &[".tmpl", ".jinja2", ".j2"],
396/// transform: |content| Ok(content.to_string()),
397/// }
398///
399/// // For stylesheet files (YAML parsing)
400/// FileRegistryConfig {
401/// extensions: &[".yaml", ".yml"],
402/// transform: |content| parse_style_definitions(content),
403/// }
404/// ```
405pub struct FileRegistryConfig<T> {
406 /// Valid file extensions in priority order (first = highest priority).
407 ///
408 /// When multiple files exist with the same base name but different extensions,
409 /// the extension appearing earlier in this list wins for extensionless lookups.
410 pub extensions: &'static [&'static str],
411
412 /// Transform function: file content → typed value.
413 ///
414 /// Called when reading a file to convert raw string content into the target type.
415 /// Return `Err(LoadError::Transform { .. })` for parse failures.
416 pub transform: fn(&str) -> Result<T, LoadError>,
417}
418
419/// Error type for file loading operations.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub enum LoadError {
422 /// Directory does not exist or is not accessible.
423 DirectoryNotFound {
424 /// Path to the directory that was not found.
425 path: PathBuf,
426 },
427
428 /// IO error reading a file.
429 Io {
430 /// Path that failed to read.
431 path: PathBuf,
432 /// Error message.
433 message: String,
434 },
435
436 /// Resource not found in registry.
437 NotFound {
438 /// The name that was requested.
439 name: String,
440 },
441
442 /// Cross-directory collision detected.
443 ///
444 /// Two directories contain files that resolve to the same name.
445 /// This is a configuration error that must be fixed.
446 Collision {
447 /// The resource name that has conflicting sources.
448 name: String,
449 /// Path to the existing resource.
450 existing_path: PathBuf,
451 /// Directory containing the existing resource.
452 existing_dir: PathBuf,
453 /// Path to the conflicting resource.
454 conflicting_path: PathBuf,
455 /// Directory containing the conflicting resource.
456 conflicting_dir: PathBuf,
457 },
458
459 /// Transform function failed.
460 Transform {
461 /// The resource name.
462 name: String,
463 /// Error message from the transform.
464 message: String,
465 },
466}
467
468impl std::fmt::Display for LoadError {
469 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470 match self {
471 LoadError::DirectoryNotFound { path } => {
472 write!(f, "Directory not found: {}", path.display())
473 }
474 LoadError::Io { path, message } => {
475 write!(f, "Failed to read \"{}\": {}", path.display(), message)
476 }
477 LoadError::NotFound { name } => {
478 write!(f, "Resource not found: \"{}\"", name)
479 }
480 LoadError::Collision {
481 name,
482 existing_path,
483 existing_dir,
484 conflicting_path,
485 conflicting_dir,
486 } => {
487 write!(
488 f,
489 "Collision detected for \"{}\":\n \
490 - {} (from {})\n \
491 - {} (from {})",
492 name,
493 existing_path.display(),
494 existing_dir.display(),
495 conflicting_path.display(),
496 conflicting_dir.display()
497 )
498 }
499 LoadError::Transform { name, message } => {
500 write!(f, "Failed to transform \"{}\": {}", name, message)
501 }
502 }
503 }
504}
505
506impl std::error::Error for LoadError {}
507
508/// Generic registry for file-based resources.
509///
510/// Manages loading and accessing resources from multiple directories with consistent
511/// behavior for extension priority, collision detection, and dev/release modes.
512///
513/// # Type Parameter
514///
515/// - `T`: The content type. Must implement `Clone` for `get()` to return owned values.
516///
517/// # Example
518///
519/// ```rust,ignore
520/// let config = FileRegistryConfig {
521/// extensions: &[".yaml", ".yml"],
522/// transform: |content| serde_yaml::from_str(content).map_err(|e| LoadError::Transform {
523/// name: String::new(),
524/// message: e.to_string(),
525/// }),
526/// };
527///
528/// let mut registry = FileRegistry::new(config);
529/// registry.add_dir("./styles")?;
530///
531/// let definitions = registry.get("darcula")?;
532/// ```
533pub struct FileRegistry<T> {
534 /// Configuration for this registry.
535 config: FileRegistryConfig<T>,
536 /// Registered source directories.
537 dirs: Vec<PathBuf>,
538 /// Map from name to loaded entry.
539 entries: HashMap<String, LoadedEntry<T>>,
540 /// Tracks source info for collision detection: name → (path, source_dir).
541 sources: HashMap<String, (PathBuf, PathBuf)>,
542 /// Whether the registry has been initialized from directories.
543 initialized: bool,
544}
545
546impl<T: Clone> FileRegistry<T> {
547 /// Creates a new registry with the given configuration.
548 ///
549 /// The registry starts empty. Call [`add_dir`](Self::add_dir) to register
550 /// directories, then [`refresh`](Self::refresh) or access resources to
551 /// trigger initialization.
552 pub fn new(config: FileRegistryConfig<T>) -> Self {
553 Self {
554 config,
555 dirs: Vec::new(),
556 entries: HashMap::new(),
557 sources: HashMap::new(),
558 initialized: false,
559 }
560 }
561
562 /// Adds a directory to search for files.
563 ///
564 /// Directories are searched in registration order. If files with the same name
565 /// exist in multiple directories, a collision error is raised.
566 ///
567 /// # Lazy Initialization
568 ///
569 /// The directory is validated but not walked immediately. Walking happens on
570 /// first access or explicit [`refresh`](Self::refresh) call.
571 ///
572 /// # Errors
573 ///
574 /// Returns [`LoadError::DirectoryNotFound`] if the path doesn't exist or
575 /// isn't a directory.
576 pub fn add_dir<P: AsRef<Path>>(&mut self, path: P) -> Result<(), LoadError> {
577 let path = path.as_ref();
578
579 if !path.exists() {
580 return Err(LoadError::DirectoryNotFound {
581 path: path.to_path_buf(),
582 });
583 }
584 if !path.is_dir() {
585 return Err(LoadError::DirectoryNotFound {
586 path: path.to_path_buf(),
587 });
588 }
589
590 self.dirs.push(path.to_path_buf());
591 self.initialized = false;
592 Ok(())
593 }
594
595 /// Adds pre-embedded content (for release builds).
596 ///
597 /// Embedded resources are stored directly in memory, avoiding filesystem
598 /// access at runtime. Useful for deployment scenarios.
599 ///
600 /// # Note
601 ///
602 /// Embedded resources shadow file-based resources with the same name.
603 pub fn add_embedded(&mut self, name: &str, content: T) {
604 self.entries
605 .insert(name.to_string(), LoadedEntry::Embedded(content));
606 }
607
608 /// Initializes/refreshes the registry from registered directories.
609 ///
610 /// This walks all registered directories, discovers files, and builds the
611 /// resolution map. Call this to:
612 ///
613 /// - Pick up newly added files (in dev mode)
614 /// - Force re-initialization after adding directories
615 ///
616 /// # Panics
617 ///
618 /// Panics if a collision is detected (same name from different directories).
619 /// This is intentional—collisions are configuration errors that must be fixed.
620 ///
621 /// # Errors
622 ///
623 /// Returns an error if directory walking fails.
624 pub fn refresh(&mut self) -> Result<(), LoadError> {
625 // Collect all files from all directories
626 let mut all_files = Vec::new();
627 for dir in &self.dirs {
628 let files = walk_dir(dir, self.config.extensions)?;
629 all_files.extend(files);
630 }
631
632 // Clear existing file-based entries (keep embedded)
633 self.entries
634 .retain(|_, v| matches!(v, LoadedEntry::Embedded(_)));
635 self.sources.clear();
636
637 // Sort by extension priority so higher-priority extensions are processed first
638 all_files.sort_by_key(|f| f.extension_priority(self.config.extensions));
639
640 // Process files
641 for file in all_files {
642 let entry = LoadedEntry::File(file.path.clone());
643
644 // Check for cross-directory collision on the base name
645 if let Some((existing_path, existing_dir)) = self.sources.get(&file.name) {
646 if existing_dir != &file.source_dir {
647 panic!(
648 "{}",
649 LoadError::Collision {
650 name: file.name.clone(),
651 existing_path: existing_path.clone(),
652 existing_dir: existing_dir.clone(),
653 conflicting_path: file.path.clone(),
654 conflicting_dir: file.source_dir.clone(),
655 }
656 );
657 }
658 // Same directory, different extension—only register the explicit name with extension
659 // (the extensionless name already points to higher-priority extension)
660 // But only if there's no embedded entry for this explicit name
661 if !self.entries.contains_key(&file.name_with_ext) {
662 self.entries.insert(file.name_with_ext.clone(), entry);
663 }
664 continue;
665 }
666
667 // Track source for collision detection
668 self.sources.insert(
669 file.name.clone(),
670 (file.path.clone(), file.source_dir.clone()),
671 );
672
673 // Add under extensionless name (only if no embedded entry exists)
674 if !self.entries.contains_key(&file.name) {
675 self.entries.insert(file.name.clone(), entry.clone());
676 }
677
678 // Add under name with extension (only if no embedded entry exists)
679 if !self.entries.contains_key(&file.name_with_ext) {
680 self.entries.insert(file.name_with_ext.clone(), entry);
681 }
682 }
683
684 self.initialized = true;
685 Ok(())
686 }
687
688 /// Ensures the registry is initialized, doing so lazily if needed.
689 fn ensure_initialized(&mut self) -> Result<(), LoadError> {
690 if !self.initialized && !self.dirs.is_empty() {
691 self.refresh()?;
692 }
693 Ok(())
694 }
695
696 /// Gets a resource by name, applying the transform if reading from disk.
697 ///
698 /// Names are resolved with extension-agnostic fallback: if the exact name
699 /// isn't found and it has a recognized extension, the extension is stripped
700 /// and the base name is tried. This allows lookups like `"config.j2"` to
701 /// find a file registered as `"config"` (from `config.jinja`).
702 ///
703 /// In dev mode (when using [`LoadedEntry::File`]): re-reads file and transforms
704 /// on each call, enabling hot reload.
705 ///
706 /// In release mode (when using [`LoadedEntry::Embedded`]): returns embedded
707 /// content directly.
708 ///
709 /// # Errors
710 ///
711 /// - [`LoadError::NotFound`] if the name doesn't exist
712 /// - [`LoadError::Io`] if the file can't be read
713 /// - [`LoadError::Transform`] if the transform function fails
714 pub fn get(&mut self, name: &str) -> Result<T, LoadError> {
715 self.ensure_initialized()?;
716
717 // Try exact name first, fall back to base name (extension stripped)
718 if self.entries.contains_key(name) {
719 return self.get_by_key(name);
720 }
721
722 let base_name = strip_extension(name, self.config.extensions);
723 if base_name != name && self.entries.contains_key(base_name.as_str()) {
724 return self.get_by_key(&base_name);
725 }
726
727 Err(LoadError::NotFound {
728 name: name.to_string(),
729 })
730 }
731
732 /// Resolves an entry by key, reading from disk if necessary.
733 fn get_by_key(&self, key: &str) -> Result<T, LoadError> {
734 match self.entries.get(key) {
735 Some(LoadedEntry::Embedded(content)) => Ok(content.clone()),
736 Some(LoadedEntry::File(path)) => {
737 let content = std::fs::read_to_string(path).map_err(|e| LoadError::Io {
738 path: path.clone(),
739 message: e.to_string(),
740 })?;
741 (self.config.transform)(&content).map_err(|e| {
742 if let LoadError::Transform { message, .. } = e {
743 LoadError::Transform {
744 name: key.to_string(),
745 message,
746 }
747 } else {
748 e
749 }
750 })
751 }
752 None => Err(LoadError::NotFound {
753 name: key.to_string(),
754 }),
755 }
756 }
757
758 /// Returns a reference to the entry if it exists.
759 ///
760 /// Names are resolved with extension-agnostic fallback: if the exact name
761 /// isn't found and it has a recognized extension, the extension is stripped
762 /// and the base name is tried.
763 ///
764 /// Unlike [`get`](Self::get), this doesn't trigger initialization or file reading.
765 /// Useful for checking if a name exists without side effects.
766 pub fn get_entry(&self, name: &str) -> Option<&LoadedEntry<T>> {
767 if let Some(entry) = self.entries.get(name) {
768 return Some(entry);
769 }
770 let base_name = strip_extension(name, self.config.extensions);
771 if base_name != name {
772 self.entries.get(base_name.as_str())
773 } else {
774 None
775 }
776 }
777
778 /// Returns an iterator over all registered names.
779 pub fn names(&self) -> impl Iterator<Item = &str> {
780 self.entries.keys().map(|s| s.as_str())
781 }
782
783 /// Returns the number of registered resources.
784 ///
785 /// Note: This counts both extensionless and with-extension entries,
786 /// so it may be higher than the number of unique files.
787 pub fn len(&self) -> usize {
788 self.entries.len()
789 }
790
791 /// Returns true if no resources are registered.
792 pub fn is_empty(&self) -> bool {
793 self.entries.is_empty()
794 }
795
796 /// Clears all entries from the registry.
797 pub fn clear(&mut self) {
798 self.entries.clear();
799 self.sources.clear();
800 self.initialized = false;
801 }
802
803 /// Returns the registered directories.
804 pub fn dirs(&self) -> &[PathBuf] {
805 &self.dirs
806 }
807}
808
809/// Walks a directory recursively and collects files with recognized extensions.
810///
811/// # Arguments
812///
813/// - `root`: The directory to walk
814/// - `extensions`: Recognized file extensions
815///
816/// # Returns
817///
818/// A vector of [`LoadedFile`] entries, one for each discovered file.
819pub fn walk_dir(root: &Path, extensions: &[&str]) -> Result<Vec<LoadedFile>, LoadError> {
820 let root_canonical = root.canonicalize().map_err(|e| LoadError::Io {
821 path: root.to_path_buf(),
822 message: e.to_string(),
823 })?;
824
825 let mut files = Vec::new();
826 walk_dir_recursive(&root_canonical, &root_canonical, extensions, &mut files)?;
827 Ok(files)
828}
829
830/// Recursive helper for directory walking.
831fn walk_dir_recursive(
832 current: &Path,
833 root: &Path,
834 extensions: &[&str],
835 files: &mut Vec<LoadedFile>,
836) -> Result<(), LoadError> {
837 let entries = std::fs::read_dir(current).map_err(|e| LoadError::Io {
838 path: current.to_path_buf(),
839 message: e.to_string(),
840 })?;
841
842 for entry in entries {
843 let entry = entry.map_err(|e| LoadError::Io {
844 path: current.to_path_buf(),
845 message: e.to_string(),
846 })?;
847 let path = entry.path();
848
849 if path.is_dir() {
850 walk_dir_recursive(&path, root, extensions, files)?;
851 } else if path.is_file() {
852 if let Some(loaded_file) = try_parse_file(&path, root, extensions) {
853 files.push(loaded_file);
854 }
855 }
856 }
857
858 Ok(())
859}
860
861/// Attempts to parse a file path as a loadable file.
862///
863/// Returns `None` if the file doesn't have a recognized extension.
864fn try_parse_file(path: &Path, root: &Path, extensions: &[&str]) -> Option<LoadedFile> {
865 let path_str = path.to_string_lossy();
866
867 // Find which extension this file has
868 let extension = extensions.iter().find(|ext| path_str.ends_with(*ext))?;
869
870 // Compute relative path from root
871 let relative = path.strip_prefix(root).ok()?;
872 let relative_str = relative.to_string_lossy();
873
874 // Name with extension (using forward slashes for consistency)
875 let name_with_ext = relative_str.replace(std::path::MAIN_SEPARATOR, "/");
876
877 // Name without extension
878 let name = name_with_ext.strip_suffix(extension)?.to_string();
879
880 Some(LoadedFile::new(name, name_with_ext, path, root))
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886 use std::io::Write;
887 use tempfile::TempDir;
888
889 fn create_file(dir: &Path, relative_path: &str, content: &str) {
890 let full_path = dir.join(relative_path);
891 if let Some(parent) = full_path.parent() {
892 std::fs::create_dir_all(parent).unwrap();
893 }
894 let mut file = std::fs::File::create(&full_path).unwrap();
895 file.write_all(content.as_bytes()).unwrap();
896 }
897
898 fn string_config() -> FileRegistryConfig<String> {
899 FileRegistryConfig {
900 extensions: &[".tmpl", ".jinja2", ".j2"],
901 transform: |content| Ok(content.to_string()),
902 }
903 }
904
905 // =========================================================================
906 // LoadedFile tests
907 // =========================================================================
908
909 #[test]
910 fn test_loaded_file_extension_priority() {
911 let extensions = &[".tmpl", ".jinja2", ".j2"];
912
913 let tmpl = LoadedFile::new("config", "config.tmpl", "/a/config.tmpl", "/a");
914 let jinja2 = LoadedFile::new("config", "config.jinja2", "/a/config.jinja2", "/a");
915 let j2 = LoadedFile::new("config", "config.j2", "/a/config.j2", "/a");
916 let unknown = LoadedFile::new("config", "config.txt", "/a/config.txt", "/a");
917
918 assert_eq!(tmpl.extension_priority(extensions), 0);
919 assert_eq!(jinja2.extension_priority(extensions), 1);
920 assert_eq!(j2.extension_priority(extensions), 2);
921 assert_eq!(unknown.extension_priority(extensions), usize::MAX);
922 }
923
924 // =========================================================================
925 // FileRegistry basic tests
926 // =========================================================================
927
928 #[test]
929 fn test_registry_new_is_empty() {
930 let registry = FileRegistry::new(string_config());
931 assert!(registry.is_empty());
932 assert_eq!(registry.len(), 0);
933 }
934
935 #[test]
936 fn test_registry_add_embedded() {
937 let mut registry = FileRegistry::new(string_config());
938 registry.add_embedded("test", "content".to_string());
939
940 assert_eq!(registry.len(), 1);
941 assert!(!registry.is_empty());
942
943 let content = registry.get("test").unwrap();
944 assert_eq!(content, "content");
945 }
946
947 #[test]
948 fn test_registry_embedded_overwrites() {
949 let mut registry = FileRegistry::new(string_config());
950 registry.add_embedded("test", "first".to_string());
951 registry.add_embedded("test", "second".to_string());
952
953 let content = registry.get("test").unwrap();
954 assert_eq!(content, "second");
955 }
956
957 #[test]
958 fn test_registry_not_found() {
959 let mut registry = FileRegistry::new(string_config());
960 let result = registry.get("nonexistent");
961 assert!(matches!(result, Err(LoadError::NotFound { .. })));
962 }
963
964 // =========================================================================
965 // Directory-based tests
966 // =========================================================================
967
968 #[test]
969 fn test_registry_add_dir_nonexistent() {
970 let mut registry = FileRegistry::new(string_config());
971 let result = registry.add_dir("/nonexistent/path");
972 assert!(matches!(result, Err(LoadError::DirectoryNotFound { .. })));
973 }
974
975 #[test]
976 fn test_registry_add_dir_and_get() {
977 let temp_dir = TempDir::new().unwrap();
978 create_file(temp_dir.path(), "config.tmpl", "Config content");
979
980 let mut registry = FileRegistry::new(string_config());
981 registry.add_dir(temp_dir.path()).unwrap();
982
983 let content = registry.get("config").unwrap();
984 assert_eq!(content, "Config content");
985 }
986
987 #[test]
988 fn test_registry_nested_directories() {
989 let temp_dir = TempDir::new().unwrap();
990 create_file(temp_dir.path(), "todos/list.tmpl", "List content");
991 create_file(temp_dir.path(), "todos/detail.tmpl", "Detail content");
992
993 let mut registry = FileRegistry::new(string_config());
994 registry.add_dir(temp_dir.path()).unwrap();
995
996 assert_eq!(registry.get("todos/list").unwrap(), "List content");
997 assert_eq!(registry.get("todos/detail").unwrap(), "Detail content");
998 }
999
1000 #[test]
1001 fn test_registry_access_with_extension() {
1002 let temp_dir = TempDir::new().unwrap();
1003 create_file(temp_dir.path(), "config.tmpl", "Content");
1004
1005 let mut registry = FileRegistry::new(string_config());
1006 registry.add_dir(temp_dir.path()).unwrap();
1007
1008 // Both with and without extension should work
1009 assert!(registry.get("config").is_ok());
1010 assert!(registry.get("config.tmpl").is_ok());
1011 }
1012
1013 #[test]
1014 fn test_registry_extension_priority() {
1015 let temp_dir = TempDir::new().unwrap();
1016 create_file(temp_dir.path(), "config.j2", "From j2");
1017 create_file(temp_dir.path(), "config.tmpl", "From tmpl");
1018
1019 let mut registry = FileRegistry::new(string_config());
1020 registry.add_dir(temp_dir.path()).unwrap();
1021
1022 // Extensionless should resolve to .tmpl (higher priority)
1023 let content = registry.get("config").unwrap();
1024 assert_eq!(content, "From tmpl");
1025
1026 // Explicit extension access still works
1027 assert_eq!(registry.get("config.j2").unwrap(), "From j2");
1028 assert_eq!(registry.get("config.tmpl").unwrap(), "From tmpl");
1029 }
1030
1031 #[test]
1032 #[should_panic(expected = "Collision")]
1033 fn test_registry_collision_panics() {
1034 let temp_dir1 = TempDir::new().unwrap();
1035 let temp_dir2 = TempDir::new().unwrap();
1036
1037 create_file(temp_dir1.path(), "config.tmpl", "From dir1");
1038 create_file(temp_dir2.path(), "config.tmpl", "From dir2");
1039
1040 let mut registry = FileRegistry::new(string_config());
1041 registry.add_dir(temp_dir1.path()).unwrap();
1042 registry.add_dir(temp_dir2.path()).unwrap();
1043
1044 // This should panic due to collision
1045 registry.refresh().unwrap();
1046 }
1047
1048 #[test]
1049 fn test_registry_embedded_shadows_file() {
1050 let temp_dir = TempDir::new().unwrap();
1051 create_file(temp_dir.path(), "config.tmpl", "From file");
1052
1053 let mut registry = FileRegistry::new(string_config());
1054 registry.add_dir(temp_dir.path()).unwrap();
1055 registry.add_embedded("config", "From embedded".to_string());
1056
1057 // Embedded should shadow file
1058 let content = registry.get("config").unwrap();
1059 assert_eq!(content, "From embedded");
1060 }
1061
1062 #[test]
1063 fn test_registry_hot_reload() {
1064 let temp_dir = TempDir::new().unwrap();
1065 create_file(temp_dir.path(), "hot.tmpl", "Version 1");
1066
1067 let mut registry = FileRegistry::new(string_config());
1068 registry.add_dir(temp_dir.path()).unwrap();
1069
1070 // First read
1071 assert_eq!(registry.get("hot").unwrap(), "Version 1");
1072
1073 // Modify file
1074 create_file(temp_dir.path(), "hot.tmpl", "Version 2");
1075
1076 // Second read should see change (hot reload)
1077 assert_eq!(registry.get("hot").unwrap(), "Version 2");
1078 }
1079
1080 #[test]
1081 fn test_registry_names_iterator() {
1082 let mut registry = FileRegistry::new(string_config());
1083 registry.add_embedded("a", "content a".to_string());
1084 registry.add_embedded("b", "content b".to_string());
1085
1086 let names: Vec<&str> = registry.names().collect();
1087 assert!(names.contains(&"a"));
1088 assert!(names.contains(&"b"));
1089 }
1090
1091 #[test]
1092 fn test_registry_clear() {
1093 let mut registry = FileRegistry::new(string_config());
1094 registry.add_embedded("a", "content".to_string());
1095
1096 assert!(!registry.is_empty());
1097 registry.clear();
1098 assert!(registry.is_empty());
1099 }
1100
1101 #[test]
1102 fn test_registry_refresh_picks_up_new_files() {
1103 let temp_dir = TempDir::new().unwrap();
1104 create_file(temp_dir.path(), "first.tmpl", "First content");
1105
1106 let mut registry = FileRegistry::new(string_config());
1107 registry.add_dir(temp_dir.path()).unwrap();
1108 registry.refresh().unwrap();
1109
1110 assert!(registry.get("first").is_ok());
1111 assert!(registry.get("second").is_err());
1112
1113 // Add new file
1114 create_file(temp_dir.path(), "second.tmpl", "Second content");
1115
1116 // Refresh to pick up new file
1117 registry.refresh().unwrap();
1118
1119 assert!(registry.get("second").is_ok());
1120 assert_eq!(registry.get("second").unwrap(), "Second content");
1121 }
1122
1123 // =========================================================================
1124 // Transform tests
1125 // =========================================================================
1126
1127 #[test]
1128 fn test_registry_transform_success() {
1129 let config = FileRegistryConfig {
1130 extensions: &[".num"],
1131 transform: |content| {
1132 content
1133 .trim()
1134 .parse::<i32>()
1135 .map_err(|e| LoadError::Transform {
1136 name: String::new(),
1137 message: e.to_string(),
1138 })
1139 },
1140 };
1141
1142 let temp_dir = TempDir::new().unwrap();
1143 create_file(temp_dir.path(), "value.num", "42");
1144
1145 let mut registry = FileRegistry::new(config);
1146 registry.add_dir(temp_dir.path()).unwrap();
1147
1148 let value = registry.get("value").unwrap();
1149 assert_eq!(value, 42);
1150 }
1151
1152 #[test]
1153 fn test_registry_transform_failure() {
1154 let config = FileRegistryConfig {
1155 extensions: &[".num"],
1156 transform: |content| {
1157 content
1158 .trim()
1159 .parse::<i32>()
1160 .map_err(|e| LoadError::Transform {
1161 name: String::new(),
1162 message: e.to_string(),
1163 })
1164 },
1165 };
1166
1167 let temp_dir = TempDir::new().unwrap();
1168 create_file(temp_dir.path(), "bad.num", "not a number");
1169
1170 let mut registry = FileRegistry::new(config);
1171 registry.add_dir(temp_dir.path()).unwrap();
1172
1173 let result = registry.get("bad");
1174 assert!(matches!(result, Err(LoadError::Transform { name, .. }) if name == "bad"));
1175 }
1176
1177 // =========================================================================
1178 // walk_dir tests
1179 // =========================================================================
1180
1181 #[test]
1182 fn test_walk_dir_empty() {
1183 let temp_dir = TempDir::new().unwrap();
1184 let files = walk_dir(temp_dir.path(), &[".tmpl"]).unwrap();
1185 assert!(files.is_empty());
1186 }
1187
1188 #[test]
1189 fn test_walk_dir_filters_extensions() {
1190 let temp_dir = TempDir::new().unwrap();
1191 create_file(temp_dir.path(), "good.tmpl", "content");
1192 create_file(temp_dir.path(), "bad.txt", "content");
1193 create_file(temp_dir.path(), "also_good.j2", "content");
1194
1195 let files = walk_dir(temp_dir.path(), &[".tmpl", ".j2"]).unwrap();
1196
1197 assert_eq!(files.len(), 2);
1198 let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
1199 assert!(names.contains(&"good"));
1200 assert!(names.contains(&"also_good"));
1201 }
1202
1203 #[test]
1204 fn test_walk_dir_nested() {
1205 let temp_dir = TempDir::new().unwrap();
1206 create_file(temp_dir.path(), "root.tmpl", "content");
1207 create_file(temp_dir.path(), "sub/nested.tmpl", "content");
1208 create_file(temp_dir.path(), "sub/deep/very.tmpl", "content");
1209
1210 let files = walk_dir(temp_dir.path(), &[".tmpl"]).unwrap();
1211
1212 assert_eq!(files.len(), 3);
1213 let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
1214 assert!(names.contains(&"root"));
1215 assert!(names.contains(&"sub/nested"));
1216 assert!(names.contains(&"sub/deep/very"));
1217 }
1218
1219 // =========================================================================
1220 // Error display tests
1221 // =========================================================================
1222
1223 #[test]
1224 fn test_error_display_directory_not_found() {
1225 let err = LoadError::DirectoryNotFound {
1226 path: PathBuf::from("/missing"),
1227 };
1228 assert!(err.to_string().contains("/missing"));
1229 }
1230
1231 #[test]
1232 fn test_error_display_not_found() {
1233 let err = LoadError::NotFound {
1234 name: "missing".to_string(),
1235 };
1236 assert!(err.to_string().contains("missing"));
1237 }
1238
1239 #[test]
1240 fn test_error_display_collision() {
1241 let err = LoadError::Collision {
1242 name: "config".to_string(),
1243 existing_path: PathBuf::from("/a/config.tmpl"),
1244 existing_dir: PathBuf::from("/a"),
1245 conflicting_path: PathBuf::from("/b/config.tmpl"),
1246 conflicting_dir: PathBuf::from("/b"),
1247 };
1248
1249 let display = err.to_string();
1250 assert!(display.contains("config"));
1251 assert!(display.contains("/a/config.tmpl"));
1252 assert!(display.contains("/b/config.tmpl"));
1253 }
1254
1255 #[test]
1256 fn test_error_display_transform() {
1257 let err = LoadError::Transform {
1258 name: "bad".to_string(),
1259 message: "parse error".to_string(),
1260 };
1261 let display = err.to_string();
1262 assert!(display.contains("bad"));
1263 assert!(display.contains("parse error"));
1264 }
1265
1266 // =========================================================================
1267 // Extension-agnostic resolution tests
1268 // =========================================================================
1269
1270 #[test]
1271 fn test_resolve_in_map_exact_match() {
1272 let mut map = HashMap::new();
1273 map.insert("config".to_string(), "base");
1274 map.insert("config.tmpl".to_string(), "with ext");
1275
1276 let extensions = &[".tmpl", ".j2"];
1277
1278 assert_eq!(resolve_in_map(&map, "config", extensions), Some(&"base"));
1279 assert_eq!(
1280 resolve_in_map(&map, "config.tmpl", extensions),
1281 Some(&"with ext")
1282 );
1283 }
1284
1285 #[test]
1286 fn test_resolve_in_map_fallback_to_base_name() {
1287 let mut map = HashMap::new();
1288 map.insert("config".to_string(), "content");
1289
1290 let extensions = &[".tmpl", ".j2"];
1291
1292 // "config.j2" not in map, but .j2 is known → strips to "config" → found
1293 assert_eq!(
1294 resolve_in_map(&map, "config.j2", extensions),
1295 Some(&"content")
1296 );
1297 // "config.tmpl" not in map either → strips to "config" → found
1298 assert_eq!(
1299 resolve_in_map(&map, "config.tmpl", extensions),
1300 Some(&"content")
1301 );
1302 }
1303
1304 #[test]
1305 fn test_resolve_in_map_unknown_extension_no_fallback() {
1306 let mut map = HashMap::new();
1307 map.insert("config".to_string(), "content");
1308
1309 let extensions = &[".tmpl", ".j2"];
1310
1311 // ".txt" is not a known extension → no stripping → not found
1312 assert_eq!(resolve_in_map(&map, "config.txt", extensions), None);
1313 }
1314
1315 #[test]
1316 fn test_resolve_in_map_no_match() {
1317 let map: HashMap<String, &str> = HashMap::new();
1318 let extensions = &[".tmpl"];
1319
1320 assert_eq!(resolve_in_map(&map, "missing", extensions), None);
1321 assert_eq!(resolve_in_map(&map, "missing.tmpl", extensions), None);
1322 }
1323
1324 #[test]
1325 fn test_registry_get_cross_extension_fallback() {
1326 let temp_dir = TempDir::new().unwrap();
1327 // File has .tmpl extension
1328 create_file(temp_dir.path(), "config.tmpl", "Template content");
1329
1330 let mut registry = FileRegistry::new(string_config());
1331 registry.add_dir(temp_dir.path()).unwrap();
1332
1333 // Lookup with different known extension should fall back to base name
1334 assert_eq!(registry.get("config.j2").unwrap(), "Template content");
1335 assert_eq!(registry.get("config.jinja2").unwrap(), "Template content");
1336 // Direct access still works
1337 assert_eq!(registry.get("config").unwrap(), "Template content");
1338 assert_eq!(registry.get("config.tmpl").unwrap(), "Template content");
1339 }
1340
1341 #[test]
1342 fn test_registry_get_entry_cross_extension_fallback() {
1343 let temp_dir = TempDir::new().unwrap();
1344 create_file(temp_dir.path(), "list.tmpl", "List content");
1345
1346 let mut registry = FileRegistry::new(string_config());
1347 registry.add_dir(temp_dir.path()).unwrap();
1348 registry.refresh().unwrap();
1349
1350 // get_entry with different extension should fall back to base name
1351 assert!(registry.get_entry("list.j2").is_some());
1352 assert!(registry.get_entry("list.jinja2").is_some());
1353 assert!(registry.get_entry("list").is_some());
1354 assert!(registry.get_entry("list.tmpl").is_some());
1355 }
1356
1357 #[test]
1358 fn test_registry_get_cross_extension_nested_path() {
1359 let temp_dir = TempDir::new().unwrap();
1360 create_file(temp_dir.path(), "todos/list.tmpl", "Todos list");
1361
1362 let mut registry = FileRegistry::new(string_config());
1363 registry.add_dir(temp_dir.path()).unwrap();
1364
1365 // Nested path with different extension
1366 assert_eq!(registry.get("todos/list.j2").unwrap(), "Todos list");
1367 assert_eq!(registry.get("todos/list").unwrap(), "Todos list");
1368 }
1369}