Skip to main content

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}