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}