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::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::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//! # Collision Detection
101//!
102//! Cross-directory collisions (same name from different directories) cause a panic
103//! with detailed diagnostics. This catches configuration mistakes early.
104//!
105//! Same-directory, different-extension scenarios are resolved by priority (not errors).
106//!
107//! # Supported Resource Types
108//!
109//! | Resource | Extensions | Transform |
110//! |----------|------------|-----------|
111//! | Templates | `.jinja`, `.jinja2`, `.j2`, `.txt` | Identity |
112//! | Stylesheets | `.yaml`, `.yml` | YAML parsing |
113//! | Custom | User-defined | User-defined |
114//!
115//! The registry is generic over content type `T`, enabling consistent behavior
116//! across all resource types with type-specific parsing via the transform function.
117
118use std::collections::HashMap;
119use std::path::{Path, PathBuf};
120
121/// A file discovered during directory walking.
122///
123/// This struct captures essential metadata about a file without reading its content,
124/// enabling lazy loading and hot reloading.
125///
126/// # Fields
127///
128/// - `name`: The resolution name without extension (e.g., `"todos/list"`)
129/// - `name_with_ext`: The resolution name with extension (e.g., `"todos/list.tmpl"`)
130/// - `path`: Absolute filesystem path for reading content
131/// - `source_dir`: The root directory this file came from (for collision reporting)
132///
133/// # Example
134///
135/// For a file at `/app/templates/todos/list.tmpl` with root `/app/templates`:
136///
137/// ```rust,ignore
138/// LoadedFile {
139///     name: "todos/list".to_string(),
140///     name_with_ext: "todos/list.tmpl".to_string(),
141///     path: PathBuf::from("/app/templates/todos/list.tmpl"),
142///     source_dir: PathBuf::from("/app/templates"),
143/// }
144/// ```
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct LoadedFile {
147    /// Resolution name without extension (e.g., "config" or "todos/list").
148    pub name: String,
149    /// Resolution name with extension (e.g., "config.tmpl" or "todos/list.tmpl").
150    pub name_with_ext: String,
151    /// Absolute path to the file.
152    pub path: PathBuf,
153    /// The source directory this file belongs to.
154    pub source_dir: PathBuf,
155}
156
157impl LoadedFile {
158    /// Creates a new loaded file descriptor.
159    pub fn new(
160        name: impl Into<String>,
161        name_with_ext: impl Into<String>,
162        path: impl Into<PathBuf>,
163        source_dir: impl Into<PathBuf>,
164    ) -> Self {
165        Self {
166            name: name.into(),
167            name_with_ext: name_with_ext.into(),
168            path: path.into(),
169            source_dir: source_dir.into(),
170        }
171    }
172
173    /// Returns the extension priority for this file given a list of extensions.
174    ///
175    /// Lower values indicate higher priority. Returns `usize::MAX` if the file's
176    /// extension is not in the list.
177    pub fn extension_priority(&self, extensions: &[&str]) -> usize {
178        extension_priority(&self.name_with_ext, extensions)
179    }
180}
181
182// =============================================================================
183// Shared helper functions for extension handling
184// =============================================================================
185
186/// Returns the extension priority for a filename (lower = higher priority).
187///
188/// Extensions are matched in order against the provided list. The index of the
189/// first matching extension is returned. If no extension matches, returns `usize::MAX`.
190///
191/// # Example
192///
193/// ```rust
194/// use standout::file_loader::extension_priority;
195///
196/// let extensions = &[".yaml", ".yml"];
197/// assert_eq!(extension_priority("config.yaml", extensions), 0);
198/// assert_eq!(extension_priority("config.yml", extensions), 1);
199/// assert_eq!(extension_priority("config.txt", extensions), usize::MAX);
200/// ```
201pub fn extension_priority(name: &str, extensions: &[&str]) -> usize {
202    for (i, ext) in extensions.iter().enumerate() {
203        if name.ends_with(ext) {
204            return i;
205        }
206    }
207    usize::MAX
208}
209
210/// Strips a recognized extension from a filename.
211///
212/// Returns the base name without extension if a recognized extension is found,
213/// otherwise returns the original name.
214///
215/// # Example
216///
217/// ```rust
218/// use standout::file_loader::strip_extension;
219///
220/// let extensions = &[".yaml", ".yml"];
221/// assert_eq!(strip_extension("config.yaml", extensions), "config");
222/// assert_eq!(strip_extension("themes/dark.yml", extensions), "themes/dark");
223/// assert_eq!(strip_extension("readme.txt", extensions), "readme.txt");
224/// ```
225pub fn strip_extension(name: &str, extensions: &[&str]) -> String {
226    for ext in extensions {
227        if let Some(base) = name.strip_suffix(ext) {
228            return base.to_string();
229        }
230    }
231    name.to_string()
232}
233
234/// Builds a registry map from embedded entries with extension priority handling.
235///
236/// This is the core logic for creating registries from compile-time embedded resources.
237/// It handles:
238///
239/// 1. **Extension priority**: Entries are sorted so higher-priority extensions are processed first
240/// 2. **Dual registration**: Each entry is accessible by both base name and full name with extension
241/// 3. **Transform**: Each entry's content is transformed via the provided function
242///
243/// # Arguments
244///
245/// * `entries` - Slice of `(name_with_ext, content)` pairs
246/// * `extensions` - Extension list in priority order (first = highest)
247/// * `transform` - Function to transform content into target type
248///
249/// # Returns
250///
251/// A `HashMap<String, T>` where each entry is accessible by both its base name
252/// (without extension) and its full name (with extension).
253///
254/// # Example
255///
256/// ```rust,ignore
257/// use standout::file_loader::build_embedded_registry;
258///
259/// let entries = &[
260///     ("config.yaml", "key: value"),
261///     ("config.yml", "other: data"),
262///     ("themes/dark.yaml", "bg: black"),
263/// ];
264///
265/// let registry = build_embedded_registry(
266///     entries,
267///     &[".yaml", ".yml"],
268///     |content| Ok(content.to_string()),
269/// )?;
270///
271/// // "config" resolves to config.yaml (higher priority)
272/// // Both "config.yaml" and "config.yml" are accessible explicitly
273/// ```
274pub fn build_embedded_registry<T, E, F>(
275    entries: &[(&str, &str)],
276    extensions: &[&str],
277    transform: F,
278) -> Result<HashMap<String, T>, E>
279where
280    T: Clone,
281    F: Fn(&str) -> Result<T, E>,
282{
283    let mut registry = HashMap::new();
284
285    // Sort by extension priority so higher-priority extensions are processed first
286    let mut sorted: Vec<_> = entries.iter().collect();
287    sorted.sort_by_key(|(name, _)| extension_priority(name, extensions));
288
289    let mut seen_base_names = std::collections::HashSet::new();
290
291    for (name_with_ext, content) in sorted {
292        let value = transform(content)?;
293        let base_name = strip_extension(name_with_ext, extensions);
294
295        // Register under full name with extension
296        registry.insert((*name_with_ext).to_string(), value.clone());
297
298        // Register under base name only if not already registered
299        // (higher priority extension was already processed)
300        if seen_base_names.insert(base_name.clone()) {
301            registry.insert(base_name, value);
302        }
303    }
304
305    Ok(registry)
306}
307
308/// How a resource is stored—file path (dev) or content (release).
309///
310/// This enum enables different storage strategies:
311///
312/// - [`File`](LoadedEntry::File): Store the path, read on demand (hot reload in dev)
313/// - [`Embedded`](LoadedEntry::Embedded): Store content directly (no filesystem access)
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum LoadedEntry<T> {
316    /// Path to read from disk (dev mode, enables hot reload).
317    ///
318    /// On each access, the file is re-read and transformed, picking up any changes.
319    File(PathBuf),
320
321    /// Pre-loaded/embedded content (release mode).
322    ///
323    /// Content is stored directly, avoiding filesystem access at runtime.
324    Embedded(T),
325}
326
327/// Configuration for a file registry.
328///
329/// Specifies which file extensions to recognize and how to transform file content
330/// into the target type.
331///
332/// # Example
333///
334/// ```rust,ignore
335/// // For template files (identity transform)
336/// FileRegistryConfig {
337///     extensions: &[".tmpl", ".jinja2", ".j2"],
338///     transform: |content| Ok(content.to_string()),
339/// }
340///
341/// // For stylesheet files (YAML parsing)
342/// FileRegistryConfig {
343///     extensions: &[".yaml", ".yml"],
344///     transform: |content| parse_style_definitions(content),
345/// }
346/// ```
347pub struct FileRegistryConfig<T> {
348    /// Valid file extensions in priority order (first = highest priority).
349    ///
350    /// When multiple files exist with the same base name but different extensions,
351    /// the extension appearing earlier in this list wins for extensionless lookups.
352    pub extensions: &'static [&'static str],
353
354    /// Transform function: file content → typed value.
355    ///
356    /// Called when reading a file to convert raw string content into the target type.
357    /// Return `Err(LoadError::Transform { .. })` for parse failures.
358    pub transform: fn(&str) -> Result<T, LoadError>,
359}
360
361/// Error type for file loading operations.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub enum LoadError {
364    /// Directory does not exist or is not accessible.
365    DirectoryNotFound {
366        /// Path to the directory that was not found.
367        path: PathBuf,
368    },
369
370    /// IO error reading a file.
371    Io {
372        /// Path that failed to read.
373        path: PathBuf,
374        /// Error message.
375        message: String,
376    },
377
378    /// Resource not found in registry.
379    NotFound {
380        /// The name that was requested.
381        name: String,
382    },
383
384    /// Cross-directory collision detected.
385    ///
386    /// Two directories contain files that resolve to the same name.
387    /// This is a configuration error that must be fixed.
388    Collision {
389        /// The resource name that has conflicting sources.
390        name: String,
391        /// Path to the existing resource.
392        existing_path: PathBuf,
393        /// Directory containing the existing resource.
394        existing_dir: PathBuf,
395        /// Path to the conflicting resource.
396        conflicting_path: PathBuf,
397        /// Directory containing the conflicting resource.
398        conflicting_dir: PathBuf,
399    },
400
401    /// Transform function failed.
402    Transform {
403        /// The resource name.
404        name: String,
405        /// Error message from the transform.
406        message: String,
407    },
408}
409
410impl std::fmt::Display for LoadError {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        match self {
413            LoadError::DirectoryNotFound { path } => {
414                write!(f, "Directory not found: {}", path.display())
415            }
416            LoadError::Io { path, message } => {
417                write!(f, "Failed to read \"{}\": {}", path.display(), message)
418            }
419            LoadError::NotFound { name } => {
420                write!(f, "Resource not found: \"{}\"", name)
421            }
422            LoadError::Collision {
423                name,
424                existing_path,
425                existing_dir,
426                conflicting_path,
427                conflicting_dir,
428            } => {
429                write!(
430                    f,
431                    "Collision detected for \"{}\":\n  \
432                     - {} (from {})\n  \
433                     - {} (from {})",
434                    name,
435                    existing_path.display(),
436                    existing_dir.display(),
437                    conflicting_path.display(),
438                    conflicting_dir.display()
439                )
440            }
441            LoadError::Transform { name, message } => {
442                write!(f, "Failed to transform \"{}\": {}", name, message)
443            }
444        }
445    }
446}
447
448impl std::error::Error for LoadError {}
449
450/// Generic registry for file-based resources.
451///
452/// Manages loading and accessing resources from multiple directories with consistent
453/// behavior for extension priority, collision detection, and dev/release modes.
454///
455/// # Type Parameter
456///
457/// - `T`: The content type. Must implement `Clone` for `get()` to return owned values.
458///
459/// # Example
460///
461/// ```rust,ignore
462/// let config = FileRegistryConfig {
463///     extensions: &[".yaml", ".yml"],
464///     transform: |content| serde_yaml::from_str(content).map_err(|e| LoadError::Transform {
465///         name: String::new(),
466///         message: e.to_string(),
467///     }),
468/// };
469///
470/// let mut registry = FileRegistry::new(config);
471/// registry.add_dir("./styles")?;
472///
473/// let definitions = registry.get("darcula")?;
474/// ```
475pub struct FileRegistry<T> {
476    /// Configuration for this registry.
477    config: FileRegistryConfig<T>,
478    /// Registered source directories.
479    dirs: Vec<PathBuf>,
480    /// Map from name to loaded entry.
481    entries: HashMap<String, LoadedEntry<T>>,
482    /// Tracks source info for collision detection: name → (path, source_dir).
483    sources: HashMap<String, (PathBuf, PathBuf)>,
484    /// Whether the registry has been initialized from directories.
485    initialized: bool,
486}
487
488impl<T: Clone> FileRegistry<T> {
489    /// Creates a new registry with the given configuration.
490    ///
491    /// The registry starts empty. Call [`add_dir`](Self::add_dir) to register
492    /// directories, then [`refresh`](Self::refresh) or access resources to
493    /// trigger initialization.
494    pub fn new(config: FileRegistryConfig<T>) -> Self {
495        Self {
496            config,
497            dirs: Vec::new(),
498            entries: HashMap::new(),
499            sources: HashMap::new(),
500            initialized: false,
501        }
502    }
503
504    /// Adds a directory to search for files.
505    ///
506    /// Directories are searched in registration order. If files with the same name
507    /// exist in multiple directories, a collision error is raised.
508    ///
509    /// # Lazy Initialization
510    ///
511    /// The directory is validated but not walked immediately. Walking happens on
512    /// first access or explicit [`refresh`](Self::refresh) call.
513    ///
514    /// # Errors
515    ///
516    /// Returns [`LoadError::DirectoryNotFound`] if the path doesn't exist or
517    /// isn't a directory.
518    pub fn add_dir<P: AsRef<Path>>(&mut self, path: P) -> Result<(), LoadError> {
519        let path = path.as_ref();
520
521        if !path.exists() {
522            return Err(LoadError::DirectoryNotFound {
523                path: path.to_path_buf(),
524            });
525        }
526        if !path.is_dir() {
527            return Err(LoadError::DirectoryNotFound {
528                path: path.to_path_buf(),
529            });
530        }
531
532        self.dirs.push(path.to_path_buf());
533        self.initialized = false;
534        Ok(())
535    }
536
537    /// Adds pre-embedded content (for release builds).
538    ///
539    /// Embedded resources are stored directly in memory, avoiding filesystem
540    /// access at runtime. Useful for deployment scenarios.
541    ///
542    /// # Note
543    ///
544    /// Embedded resources shadow file-based resources with the same name.
545    pub fn add_embedded(&mut self, name: &str, content: T) {
546        self.entries
547            .insert(name.to_string(), LoadedEntry::Embedded(content));
548    }
549
550    /// Initializes/refreshes the registry from registered directories.
551    ///
552    /// This walks all registered directories, discovers files, and builds the
553    /// resolution map. Call this to:
554    ///
555    /// - Pick up newly added files (in dev mode)
556    /// - Force re-initialization after adding directories
557    ///
558    /// # Panics
559    ///
560    /// Panics if a collision is detected (same name from different directories).
561    /// This is intentional—collisions are configuration errors that must be fixed.
562    ///
563    /// # Errors
564    ///
565    /// Returns an error if directory walking fails.
566    pub fn refresh(&mut self) -> Result<(), LoadError> {
567        // Collect all files from all directories
568        let mut all_files = Vec::new();
569        for dir in &self.dirs {
570            let files = walk_dir(dir, self.config.extensions)?;
571            all_files.extend(files);
572        }
573
574        // Clear existing file-based entries (keep embedded)
575        self.entries
576            .retain(|_, v| matches!(v, LoadedEntry::Embedded(_)));
577        self.sources.clear();
578
579        // Sort by extension priority so higher-priority extensions are processed first
580        all_files.sort_by_key(|f| f.extension_priority(self.config.extensions));
581
582        // Process files
583        for file in all_files {
584            let entry = LoadedEntry::File(file.path.clone());
585
586            // Check for cross-directory collision on the base name
587            if let Some((existing_path, existing_dir)) = self.sources.get(&file.name) {
588                if existing_dir != &file.source_dir {
589                    panic!(
590                        "{}",
591                        LoadError::Collision {
592                            name: file.name.clone(),
593                            existing_path: existing_path.clone(),
594                            existing_dir: existing_dir.clone(),
595                            conflicting_path: file.path.clone(),
596                            conflicting_dir: file.source_dir.clone(),
597                        }
598                    );
599                }
600                // Same directory, different extension—only register the explicit name with extension
601                // (the extensionless name already points to higher-priority extension)
602                // But only if there's no embedded entry for this explicit name
603                if !self.entries.contains_key(&file.name_with_ext) {
604                    self.entries.insert(file.name_with_ext.clone(), entry);
605                }
606                continue;
607            }
608
609            // Track source for collision detection
610            self.sources.insert(
611                file.name.clone(),
612                (file.path.clone(), file.source_dir.clone()),
613            );
614
615            // Add under extensionless name (only if no embedded entry exists)
616            if !self.entries.contains_key(&file.name) {
617                self.entries.insert(file.name.clone(), entry.clone());
618            }
619
620            // Add under name with extension (only if no embedded entry exists)
621            if !self.entries.contains_key(&file.name_with_ext) {
622                self.entries.insert(file.name_with_ext.clone(), entry);
623            }
624        }
625
626        self.initialized = true;
627        Ok(())
628    }
629
630    /// Ensures the registry is initialized, doing so lazily if needed.
631    fn ensure_initialized(&mut self) -> Result<(), LoadError> {
632        if !self.initialized && !self.dirs.is_empty() {
633            self.refresh()?;
634        }
635        Ok(())
636    }
637
638    /// Gets a resource by name, applying the transform if reading from disk.
639    ///
640    /// In dev mode (when using [`LoadedEntry::File`]): re-reads file and transforms
641    /// on each call, enabling hot reload.
642    ///
643    /// In release mode (when using [`LoadedEntry::Embedded`]): returns embedded
644    /// content directly.
645    ///
646    /// # Errors
647    ///
648    /// - [`LoadError::NotFound`] if the name doesn't exist
649    /// - [`LoadError::Io`] if the file can't be read
650    /// - [`LoadError::Transform`] if the transform function fails
651    pub fn get(&mut self, name: &str) -> Result<T, LoadError> {
652        self.ensure_initialized()?;
653
654        match self.entries.get(name) {
655            Some(LoadedEntry::Embedded(content)) => Ok(content.clone()),
656            Some(LoadedEntry::File(path)) => {
657                let content = std::fs::read_to_string(path).map_err(|e| LoadError::Io {
658                    path: path.clone(),
659                    message: e.to_string(),
660                })?;
661                (self.config.transform)(&content).map_err(|e| {
662                    if let LoadError::Transform { message, .. } = e {
663                        LoadError::Transform {
664                            name: name.to_string(),
665                            message,
666                        }
667                    } else {
668                        e
669                    }
670                })
671            }
672            None => Err(LoadError::NotFound {
673                name: name.to_string(),
674            }),
675        }
676    }
677
678    /// Returns a reference to the entry if it exists.
679    ///
680    /// Unlike [`get`](Self::get), this doesn't trigger initialization or file reading.
681    /// Useful for checking if a name exists without side effects.
682    pub fn get_entry(&self, name: &str) -> Option<&LoadedEntry<T>> {
683        self.entries.get(name)
684    }
685
686    /// Returns an iterator over all registered names.
687    pub fn names(&self) -> impl Iterator<Item = &str> {
688        self.entries.keys().map(|s| s.as_str())
689    }
690
691    /// Returns the number of registered resources.
692    ///
693    /// Note: This counts both extensionless and with-extension entries,
694    /// so it may be higher than the number of unique files.
695    pub fn len(&self) -> usize {
696        self.entries.len()
697    }
698
699    /// Returns true if no resources are registered.
700    pub fn is_empty(&self) -> bool {
701        self.entries.is_empty()
702    }
703
704    /// Clears all entries from the registry.
705    pub fn clear(&mut self) {
706        self.entries.clear();
707        self.sources.clear();
708        self.initialized = false;
709    }
710
711    /// Returns the registered directories.
712    pub fn dirs(&self) -> &[PathBuf] {
713        &self.dirs
714    }
715}
716
717/// Walks a directory recursively and collects files with recognized extensions.
718///
719/// # Arguments
720///
721/// - `root`: The directory to walk
722/// - `extensions`: Recognized file extensions
723///
724/// # Returns
725///
726/// A vector of [`LoadedFile`] entries, one for each discovered file.
727pub fn walk_dir(root: &Path, extensions: &[&str]) -> Result<Vec<LoadedFile>, LoadError> {
728    let root_canonical = root.canonicalize().map_err(|e| LoadError::Io {
729        path: root.to_path_buf(),
730        message: e.to_string(),
731    })?;
732
733    let mut files = Vec::new();
734    walk_dir_recursive(&root_canonical, &root_canonical, extensions, &mut files)?;
735    Ok(files)
736}
737
738/// Recursive helper for directory walking.
739fn walk_dir_recursive(
740    current: &Path,
741    root: &Path,
742    extensions: &[&str],
743    files: &mut Vec<LoadedFile>,
744) -> Result<(), LoadError> {
745    let entries = std::fs::read_dir(current).map_err(|e| LoadError::Io {
746        path: current.to_path_buf(),
747        message: e.to_string(),
748    })?;
749
750    for entry in entries {
751        let entry = entry.map_err(|e| LoadError::Io {
752            path: current.to_path_buf(),
753            message: e.to_string(),
754        })?;
755        let path = entry.path();
756
757        if path.is_dir() {
758            walk_dir_recursive(&path, root, extensions, files)?;
759        } else if path.is_file() {
760            if let Some(loaded_file) = try_parse_file(&path, root, extensions) {
761                files.push(loaded_file);
762            }
763        }
764    }
765
766    Ok(())
767}
768
769/// Attempts to parse a file path as a loadable file.
770///
771/// Returns `None` if the file doesn't have a recognized extension.
772fn try_parse_file(path: &Path, root: &Path, extensions: &[&str]) -> Option<LoadedFile> {
773    let path_str = path.to_string_lossy();
774
775    // Find which extension this file has
776    let extension = extensions.iter().find(|ext| path_str.ends_with(*ext))?;
777
778    // Compute relative path from root
779    let relative = path.strip_prefix(root).ok()?;
780    let relative_str = relative.to_string_lossy();
781
782    // Name with extension (using forward slashes for consistency)
783    let name_with_ext = relative_str.replace(std::path::MAIN_SEPARATOR, "/");
784
785    // Name without extension
786    let name = name_with_ext.strip_suffix(extension)?.to_string();
787
788    Some(LoadedFile::new(name, name_with_ext, path, root))
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use std::io::Write;
795    use tempfile::TempDir;
796
797    fn create_file(dir: &Path, relative_path: &str, content: &str) {
798        let full_path = dir.join(relative_path);
799        if let Some(parent) = full_path.parent() {
800            std::fs::create_dir_all(parent).unwrap();
801        }
802        let mut file = std::fs::File::create(&full_path).unwrap();
803        file.write_all(content.as_bytes()).unwrap();
804    }
805
806    fn string_config() -> FileRegistryConfig<String> {
807        FileRegistryConfig {
808            extensions: &[".tmpl", ".jinja2", ".j2"],
809            transform: |content| Ok(content.to_string()),
810        }
811    }
812
813    // =========================================================================
814    // LoadedFile tests
815    // =========================================================================
816
817    #[test]
818    fn test_loaded_file_extension_priority() {
819        let extensions = &[".tmpl", ".jinja2", ".j2"];
820
821        let tmpl = LoadedFile::new("config", "config.tmpl", "/a/config.tmpl", "/a");
822        let jinja2 = LoadedFile::new("config", "config.jinja2", "/a/config.jinja2", "/a");
823        let j2 = LoadedFile::new("config", "config.j2", "/a/config.j2", "/a");
824        let unknown = LoadedFile::new("config", "config.txt", "/a/config.txt", "/a");
825
826        assert_eq!(tmpl.extension_priority(extensions), 0);
827        assert_eq!(jinja2.extension_priority(extensions), 1);
828        assert_eq!(j2.extension_priority(extensions), 2);
829        assert_eq!(unknown.extension_priority(extensions), usize::MAX);
830    }
831
832    // =========================================================================
833    // FileRegistry basic tests
834    // =========================================================================
835
836    #[test]
837    fn test_registry_new_is_empty() {
838        let registry = FileRegistry::new(string_config());
839        assert!(registry.is_empty());
840        assert_eq!(registry.len(), 0);
841    }
842
843    #[test]
844    fn test_registry_add_embedded() {
845        let mut registry = FileRegistry::new(string_config());
846        registry.add_embedded("test", "content".to_string());
847
848        assert_eq!(registry.len(), 1);
849        assert!(!registry.is_empty());
850
851        let content = registry.get("test").unwrap();
852        assert_eq!(content, "content");
853    }
854
855    #[test]
856    fn test_registry_embedded_overwrites() {
857        let mut registry = FileRegistry::new(string_config());
858        registry.add_embedded("test", "first".to_string());
859        registry.add_embedded("test", "second".to_string());
860
861        let content = registry.get("test").unwrap();
862        assert_eq!(content, "second");
863    }
864
865    #[test]
866    fn test_registry_not_found() {
867        let mut registry = FileRegistry::new(string_config());
868        let result = registry.get("nonexistent");
869        assert!(matches!(result, Err(LoadError::NotFound { .. })));
870    }
871
872    // =========================================================================
873    // Directory-based tests
874    // =========================================================================
875
876    #[test]
877    fn test_registry_add_dir_nonexistent() {
878        let mut registry = FileRegistry::new(string_config());
879        let result = registry.add_dir("/nonexistent/path");
880        assert!(matches!(result, Err(LoadError::DirectoryNotFound { .. })));
881    }
882
883    #[test]
884    fn test_registry_add_dir_and_get() {
885        let temp_dir = TempDir::new().unwrap();
886        create_file(temp_dir.path(), "config.tmpl", "Config content");
887
888        let mut registry = FileRegistry::new(string_config());
889        registry.add_dir(temp_dir.path()).unwrap();
890
891        let content = registry.get("config").unwrap();
892        assert_eq!(content, "Config content");
893    }
894
895    #[test]
896    fn test_registry_nested_directories() {
897        let temp_dir = TempDir::new().unwrap();
898        create_file(temp_dir.path(), "todos/list.tmpl", "List content");
899        create_file(temp_dir.path(), "todos/detail.tmpl", "Detail content");
900
901        let mut registry = FileRegistry::new(string_config());
902        registry.add_dir(temp_dir.path()).unwrap();
903
904        assert_eq!(registry.get("todos/list").unwrap(), "List content");
905        assert_eq!(registry.get("todos/detail").unwrap(), "Detail content");
906    }
907
908    #[test]
909    fn test_registry_access_with_extension() {
910        let temp_dir = TempDir::new().unwrap();
911        create_file(temp_dir.path(), "config.tmpl", "Content");
912
913        let mut registry = FileRegistry::new(string_config());
914        registry.add_dir(temp_dir.path()).unwrap();
915
916        // Both with and without extension should work
917        assert!(registry.get("config").is_ok());
918        assert!(registry.get("config.tmpl").is_ok());
919    }
920
921    #[test]
922    fn test_registry_extension_priority() {
923        let temp_dir = TempDir::new().unwrap();
924        create_file(temp_dir.path(), "config.j2", "From j2");
925        create_file(temp_dir.path(), "config.tmpl", "From tmpl");
926
927        let mut registry = FileRegistry::new(string_config());
928        registry.add_dir(temp_dir.path()).unwrap();
929
930        // Extensionless should resolve to .tmpl (higher priority)
931        let content = registry.get("config").unwrap();
932        assert_eq!(content, "From tmpl");
933
934        // Explicit extension access still works
935        assert_eq!(registry.get("config.j2").unwrap(), "From j2");
936        assert_eq!(registry.get("config.tmpl").unwrap(), "From tmpl");
937    }
938
939    #[test]
940    #[should_panic(expected = "Collision")]
941    fn test_registry_collision_panics() {
942        let temp_dir1 = TempDir::new().unwrap();
943        let temp_dir2 = TempDir::new().unwrap();
944
945        create_file(temp_dir1.path(), "config.tmpl", "From dir1");
946        create_file(temp_dir2.path(), "config.tmpl", "From dir2");
947
948        let mut registry = FileRegistry::new(string_config());
949        registry.add_dir(temp_dir1.path()).unwrap();
950        registry.add_dir(temp_dir2.path()).unwrap();
951
952        // This should panic due to collision
953        registry.refresh().unwrap();
954    }
955
956    #[test]
957    fn test_registry_embedded_shadows_file() {
958        let temp_dir = TempDir::new().unwrap();
959        create_file(temp_dir.path(), "config.tmpl", "From file");
960
961        let mut registry = FileRegistry::new(string_config());
962        registry.add_dir(temp_dir.path()).unwrap();
963        registry.add_embedded("config", "From embedded".to_string());
964
965        // Embedded should shadow file
966        let content = registry.get("config").unwrap();
967        assert_eq!(content, "From embedded");
968    }
969
970    #[test]
971    fn test_registry_hot_reload() {
972        let temp_dir = TempDir::new().unwrap();
973        create_file(temp_dir.path(), "hot.tmpl", "Version 1");
974
975        let mut registry = FileRegistry::new(string_config());
976        registry.add_dir(temp_dir.path()).unwrap();
977
978        // First read
979        assert_eq!(registry.get("hot").unwrap(), "Version 1");
980
981        // Modify file
982        create_file(temp_dir.path(), "hot.tmpl", "Version 2");
983
984        // Second read should see change (hot reload)
985        assert_eq!(registry.get("hot").unwrap(), "Version 2");
986    }
987
988    #[test]
989    fn test_registry_names_iterator() {
990        let mut registry = FileRegistry::new(string_config());
991        registry.add_embedded("a", "content a".to_string());
992        registry.add_embedded("b", "content b".to_string());
993
994        let names: Vec<&str> = registry.names().collect();
995        assert!(names.contains(&"a"));
996        assert!(names.contains(&"b"));
997    }
998
999    #[test]
1000    fn test_registry_clear() {
1001        let mut registry = FileRegistry::new(string_config());
1002        registry.add_embedded("a", "content".to_string());
1003
1004        assert!(!registry.is_empty());
1005        registry.clear();
1006        assert!(registry.is_empty());
1007    }
1008
1009    #[test]
1010    fn test_registry_refresh_picks_up_new_files() {
1011        let temp_dir = TempDir::new().unwrap();
1012        create_file(temp_dir.path(), "first.tmpl", "First content");
1013
1014        let mut registry = FileRegistry::new(string_config());
1015        registry.add_dir(temp_dir.path()).unwrap();
1016        registry.refresh().unwrap();
1017
1018        assert!(registry.get("first").is_ok());
1019        assert!(registry.get("second").is_err());
1020
1021        // Add new file
1022        create_file(temp_dir.path(), "second.tmpl", "Second content");
1023
1024        // Refresh to pick up new file
1025        registry.refresh().unwrap();
1026
1027        assert!(registry.get("second").is_ok());
1028        assert_eq!(registry.get("second").unwrap(), "Second content");
1029    }
1030
1031    // =========================================================================
1032    // Transform tests
1033    // =========================================================================
1034
1035    #[test]
1036    fn test_registry_transform_success() {
1037        let config = FileRegistryConfig {
1038            extensions: &[".num"],
1039            transform: |content| {
1040                content
1041                    .trim()
1042                    .parse::<i32>()
1043                    .map_err(|e| LoadError::Transform {
1044                        name: String::new(),
1045                        message: e.to_string(),
1046                    })
1047            },
1048        };
1049
1050        let temp_dir = TempDir::new().unwrap();
1051        create_file(temp_dir.path(), "value.num", "42");
1052
1053        let mut registry = FileRegistry::new(config);
1054        registry.add_dir(temp_dir.path()).unwrap();
1055
1056        let value = registry.get("value").unwrap();
1057        assert_eq!(value, 42);
1058    }
1059
1060    #[test]
1061    fn test_registry_transform_failure() {
1062        let config = FileRegistryConfig {
1063            extensions: &[".num"],
1064            transform: |content| {
1065                content
1066                    .trim()
1067                    .parse::<i32>()
1068                    .map_err(|e| LoadError::Transform {
1069                        name: String::new(),
1070                        message: e.to_string(),
1071                    })
1072            },
1073        };
1074
1075        let temp_dir = TempDir::new().unwrap();
1076        create_file(temp_dir.path(), "bad.num", "not a number");
1077
1078        let mut registry = FileRegistry::new(config);
1079        registry.add_dir(temp_dir.path()).unwrap();
1080
1081        let result = registry.get("bad");
1082        assert!(matches!(result, Err(LoadError::Transform { name, .. }) if name == "bad"));
1083    }
1084
1085    // =========================================================================
1086    // walk_dir tests
1087    // =========================================================================
1088
1089    #[test]
1090    fn test_walk_dir_empty() {
1091        let temp_dir = TempDir::new().unwrap();
1092        let files = walk_dir(temp_dir.path(), &[".tmpl"]).unwrap();
1093        assert!(files.is_empty());
1094    }
1095
1096    #[test]
1097    fn test_walk_dir_filters_extensions() {
1098        let temp_dir = TempDir::new().unwrap();
1099        create_file(temp_dir.path(), "good.tmpl", "content");
1100        create_file(temp_dir.path(), "bad.txt", "content");
1101        create_file(temp_dir.path(), "also_good.j2", "content");
1102
1103        let files = walk_dir(temp_dir.path(), &[".tmpl", ".j2"]).unwrap();
1104
1105        assert_eq!(files.len(), 2);
1106        let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
1107        assert!(names.contains(&"good"));
1108        assert!(names.contains(&"also_good"));
1109    }
1110
1111    #[test]
1112    fn test_walk_dir_nested() {
1113        let temp_dir = TempDir::new().unwrap();
1114        create_file(temp_dir.path(), "root.tmpl", "content");
1115        create_file(temp_dir.path(), "sub/nested.tmpl", "content");
1116        create_file(temp_dir.path(), "sub/deep/very.tmpl", "content");
1117
1118        let files = walk_dir(temp_dir.path(), &[".tmpl"]).unwrap();
1119
1120        assert_eq!(files.len(), 3);
1121        let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
1122        assert!(names.contains(&"root"));
1123        assert!(names.contains(&"sub/nested"));
1124        assert!(names.contains(&"sub/deep/very"));
1125    }
1126
1127    // =========================================================================
1128    // Error display tests
1129    // =========================================================================
1130
1131    #[test]
1132    fn test_error_display_directory_not_found() {
1133        let err = LoadError::DirectoryNotFound {
1134            path: PathBuf::from("/missing"),
1135        };
1136        assert!(err.to_string().contains("/missing"));
1137    }
1138
1139    #[test]
1140    fn test_error_display_not_found() {
1141        let err = LoadError::NotFound {
1142            name: "missing".to_string(),
1143        };
1144        assert!(err.to_string().contains("missing"));
1145    }
1146
1147    #[test]
1148    fn test_error_display_collision() {
1149        let err = LoadError::Collision {
1150            name: "config".to_string(),
1151            existing_path: PathBuf::from("/a/config.tmpl"),
1152            existing_dir: PathBuf::from("/a"),
1153            conflicting_path: PathBuf::from("/b/config.tmpl"),
1154            conflicting_dir: PathBuf::from("/b"),
1155        };
1156
1157        let display = err.to_string();
1158        assert!(display.contains("config"));
1159        assert!(display.contains("/a/config.tmpl"));
1160        assert!(display.contains("/b/config.tmpl"));
1161    }
1162
1163    #[test]
1164    fn test_error_display_transform() {
1165        let err = LoadError::Transform {
1166            name: "bad".to_string(),
1167            message: "parse error".to_string(),
1168        };
1169        let display = err.to_string();
1170        assert!(display.contains("bad"));
1171        assert!(display.contains("parse error"));
1172    }
1173}