Skip to main content

standout_render/style/
file_registry.rs

1//! Stylesheet registry for file-based theme loading.
2//!
3//! This module provides [`StylesheetRegistry`], which manages theme resolution
4//! from multiple sources: inline YAML, filesystem directories, or embedded content.
5//!
6//! # Design
7//!
8//! The registry is a thin wrapper around [`FileRegistry<Theme>`](crate::file_loader::FileRegistry),
9//! providing stylesheet-specific functionality while reusing the generic file loading infrastructure.
10//!
11//! The registry uses a two-phase approach:
12//!
13//! 1. Collection: Stylesheets are collected from various sources (inline, directories, embedded)
14//! 2. Resolution: A unified map resolves theme names to their parsed `Theme` instances
15//!
16//! This separation enables:
17//! - Testability: Resolution logic can be tested without filesystem access
18//! - Flexibility: Same resolution rules apply regardless of stylesheet source
19//! - Hot reloading: Files are re-read and re-parsed on each access in development mode
20//!
21//! # Stylesheet Resolution
22//!
23//! Stylesheets are resolved by name using these rules:
24//!
25//! 1. Inline stylesheets (added via [`StylesheetRegistry::add_inline`]) have highest priority
26//! 2. File stylesheets are searched in directory registration order (first directory wins)
27//! 3. Names can be specified with or without extension: both `"darcula"` and `"darcula.yaml"` resolve
28//!
29//! # Supported Extensions
30//!
31//! Stylesheet files are recognized by extension, in priority order:
32//!
33//! | Priority | Extension | Description |
34//! |----------|-----------|-------------|
35//! | 1 (highest) | `.yaml` | Standard YAML extension |
36//! | 2 (lowest) | `.yml` | Short YAML extension |
37//!
38//! If multiple files exist with the same base name but different extensions
39//! (e.g., `darcula.yaml` and `darcula.yml`), the higher-priority extension wins.
40//!
41//! # Collision Handling
42//!
43//! The registry enforces strict collision rules:
44//!
45//! - Same-directory, different extensions: Higher priority extension wins (no error)
46//! - Cross-directory collisions: Panic with detailed message listing conflicting files
47//!
48//! # Example
49//!
50//! ```rust,ignore
51//! use standout_render::style::StylesheetRegistry;
52//!
53//! let mut registry = StylesheetRegistry::new();
54//! registry.add_dir("./themes")?;
55//!
56//! // Get a theme by name
57//! let theme = registry.get("darcula")?;
58//! ```
59
60use std::collections::HashMap;
61use std::path::Path;
62
63use super::super::theme::Theme;
64use crate::file_loader::{
65    build_embedded_registry, resolve_in_map, FileRegistry, FileRegistryConfig, LoadError,
66};
67
68use super::error::StylesheetError;
69
70/// Recognized stylesheet file extensions in priority order.
71///
72/// When multiple files exist with the same base name but different extensions,
73/// the extension appearing earlier in this list takes precedence.
74pub const STYLESHEET_EXTENSIONS: &[&str] = &[".yaml", ".yml"];
75
76/// Creates the file registry configuration for stylesheets.
77fn stylesheet_config() -> FileRegistryConfig<Theme> {
78    FileRegistryConfig {
79        extensions: STYLESHEET_EXTENSIONS,
80        transform: |content| {
81            Theme::from_yaml(content).map_err(|e| LoadError::Transform {
82                name: String::new(), // FileRegistry fills in the actual name
83                message: e.to_string(),
84            })
85        },
86    }
87}
88
89/// Registry for stylesheet/theme resolution from multiple sources.
90///
91/// The registry maintains a unified view of themes from:
92/// - Inline YAML strings (highest priority)
93/// - Multiple filesystem directories
94/// - Embedded content (for release builds)
95///
96/// # Resolution Order
97///
98/// When looking up a theme name:
99///
100/// 1. Check inline themes first
101/// 2. Check file-based themes in registration order
102/// 3. Return error if not found
103///
104/// # Hot Reloading
105///
106/// In development mode (debug builds), file-based themes are re-read and
107/// re-parsed on each access, enabling rapid iteration without restarts.
108///
109/// # Example
110///
111/// ```rust,ignore
112/// let mut registry = StylesheetRegistry::new();
113///
114/// // Add inline theme (highest priority)
115/// registry.add_inline("custom", r#"
116/// header:
117///   fg: cyan
118///   bold: true
119/// "#)?;
120///
121/// // Add from directory
122/// registry.add_dir("./themes")?;
123///
124/// // Get a theme
125/// let theme = registry.get("darcula")?;
126/// ```
127pub struct StylesheetRegistry {
128    /// The underlying file registry for directory-based file loading.
129    inner: FileRegistry<Theme>,
130
131    /// Inline themes (stored separately for highest priority).
132    inline: HashMap<String, Theme>,
133}
134
135impl Default for StylesheetRegistry {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl StylesheetRegistry {
142    /// Creates an empty stylesheet registry.
143    pub fn new() -> Self {
144        Self {
145            inner: FileRegistry::new(stylesheet_config()),
146            inline: HashMap::new(),
147        }
148    }
149
150    /// Adds an inline theme from a YAML string.
151    ///
152    /// Inline themes have the highest priority and will shadow any
153    /// file-based themes with the same name.
154    ///
155    /// # Arguments
156    ///
157    /// * `name` - The theme name for resolution
158    /// * `yaml` - The YAML content defining the theme
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the YAML content cannot be parsed.
163    ///
164    /// # Example
165    ///
166    /// ```rust,ignore
167    /// registry.add_inline("custom", r#"
168    /// header:
169    ///   fg: cyan
170    ///   bold: true
171    /// muted:
172    ///   dim: true
173    /// "#)?;
174    /// ```
175    pub fn add_inline(
176        &mut self,
177        name: impl Into<String>,
178        yaml: &str,
179    ) -> Result<(), StylesheetError> {
180        let theme = Theme::from_yaml(yaml)?;
181        self.inline.insert(name.into(), theme);
182        Ok(())
183    }
184
185    /// Adds a pre-parsed theme directly.
186    ///
187    /// This is useful when you have a `Theme` instance already constructed
188    /// programmatically and want to register it in the registry.
189    ///
190    /// # Arguments
191    ///
192    /// * `name` - The theme name for resolution
193    /// * `theme` - The pre-built theme instance
194    pub fn add_theme(&mut self, name: impl Into<String>, theme: Theme) {
195        self.inline.insert(name.into(), theme);
196    }
197
198    /// Adds a stylesheet directory to search for files.
199    ///
200    /// Themes in the directory are resolved by their filename without
201    /// extension. For example, with directory `./themes`:
202    ///
203    /// - `"darcula"` → `./themes/darcula.yaml`
204    /// - `"monokai"` → `./themes/monokai.yaml`
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if the directory doesn't exist.
209    ///
210    /// # Example
211    ///
212    /// ```rust,ignore
213    /// registry.add_dir("./themes")?;
214    /// let theme = registry.get("darcula")?;
215    /// ```
216    pub fn add_dir<P: AsRef<Path>>(&mut self, path: P) -> Result<(), StylesheetError> {
217        self.inner.add_dir(path).map_err(|e| StylesheetError::Load {
218            message: e.to_string(),
219        })
220    }
221
222    /// Adds pre-embedded themes (for release builds).
223    ///
224    /// Embedded themes are stored directly in memory without filesystem access.
225    /// This is typically used with `include_str!` to bundle themes at compile time.
226    ///
227    /// # Arguments
228    ///
229    /// * `themes` - Map of theme name to parsed Theme
230    pub fn add_embedded(&mut self, themes: HashMap<String, Theme>) {
231        for (name, theme) in themes {
232            self.inline.insert(name, theme);
233        }
234    }
235
236    /// Adds a pre-embedded theme by name.
237    ///
238    /// This is a convenience method for adding a single embedded theme.
239    ///
240    /// # Arguments
241    ///
242    /// * `name` - The theme name for resolution
243    /// * `theme` - The pre-built theme instance
244    pub fn add_embedded_theme(&mut self, name: impl Into<String>, theme: Theme) {
245        self.inner.add_embedded(&name.into(), theme);
246    }
247
248    /// Creates a registry from embedded stylesheet entries.
249    ///
250    /// This is the primary entry point for compile-time embedded stylesheets,
251    /// typically called by the `embed_styles!` macro.
252    ///
253    /// # Arguments
254    ///
255    /// * `entries` - Slice of `(name_with_ext, yaml_content)` pairs where `name_with_ext`
256    ///   is the relative path including extension (e.g., `"themes/dark.yaml"`)
257    ///
258    /// # Processing
259    ///
260    /// This method applies the same logic as runtime file loading:
261    ///
262    /// 1. YAML parsing: Each entry's content is parsed as a theme definition
263    /// 2. Extension stripping: `"themes/dark.yaml"` → `"themes/dark"`
264    /// 3. Extension priority: When multiple files share a base name, the
265    ///    higher-priority extension wins (see [`STYLESHEET_EXTENSIONS`])
266    /// 4. Dual registration: Each theme is accessible by both its base
267    ///    name and its full name with extension
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if any YAML content fails to parse.
272    ///
273    /// # Example
274    ///
275    /// ```rust
276    /// use standout_render::style::StylesheetRegistry;
277    ///
278    /// // Typically generated by embed_styles! macro
279    /// let entries: &[(&str, &str)] = &[
280    ///     ("default.yaml", "header:\n  fg: cyan\n  bold: true"),
281    ///     ("themes/dark.yaml", "panel:\n  fg: white"),
282    /// ];
283    ///
284    /// let mut registry = StylesheetRegistry::from_embedded_entries(entries).unwrap();
285    ///
286    /// // Access by base name or full name
287    /// assert!(registry.get("default").is_ok());
288    /// assert!(registry.get("default.yaml").is_ok());
289    /// assert!(registry.get("themes/dark").is_ok());
290    /// ```
291    pub fn from_embedded_entries(entries: &[(&str, &str)]) -> Result<Self, StylesheetError> {
292        let mut registry = Self::new();
293
294        // Use shared helper with YAML parsing transform
295        registry.inline =
296            build_embedded_registry(entries, STYLESHEET_EXTENSIONS, |yaml_content| {
297                Theme::from_yaml(yaml_content)
298            })?;
299
300        Ok(registry)
301    }
302
303    /// Gets a theme by name.
304    ///
305    /// Names are resolved with extension-agnostic fallback: if the exact name
306    /// isn't found and it has a recognized extension, the extension is stripped
307    /// and the base name is tried. This allows lookups like `"config.yml"` to
308    /// find a theme registered as `"config"` (from `config.yaml`).
309    ///
310    /// Looks up the theme in order: inline first, then file-based.
311    /// In development mode, file-based themes are re-read on each access.
312    ///
313    /// # Arguments
314    ///
315    /// * `name` - The theme name (with or without extension)
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if the theme is not found or cannot be parsed.
320    ///
321    /// # Example
322    ///
323    /// ```rust,ignore
324    /// let theme = registry.get("darcula")?;
325    /// ```
326    pub fn get(&mut self, name: &str) -> Result<Theme, StylesheetError> {
327        // Check inline first (with extension-agnostic fallback)
328        if let Some(theme) = resolve_in_map(&self.inline, name, STYLESHEET_EXTENSIONS) {
329            return Ok(theme.clone());
330        }
331
332        // Try file-based (FileRegistry has its own extension fallback)
333        let theme = self.inner.get(name).map_err(|e| StylesheetError::Load {
334            message: e.to_string(),
335        })?;
336
337        // Set the theme name from the lookup key (strip extension if present)
338        let base_name = crate::file_loader::strip_extension(name, STYLESHEET_EXTENSIONS);
339        Ok(theme.with_name(base_name))
340    }
341
342    /// Checks if a theme exists in the registry.
343    ///
344    /// # Arguments
345    ///
346    /// * `name` - The theme name to check
347    pub fn contains(&self, name: &str) -> bool {
348        resolve_in_map(&self.inline, name, STYLESHEET_EXTENSIONS).is_some()
349            || self.inner.get_entry(name).is_some()
350    }
351
352    /// Returns an iterator over all registered theme names.
353    pub fn names(&self) -> impl Iterator<Item = &str> {
354        self.inline
355            .keys()
356            .map(|s| s.as_str())
357            .chain(self.inner.names())
358    }
359
360    /// Returns the number of registered themes.
361    pub fn len(&self) -> usize {
362        self.inline.len() + self.inner.len()
363    }
364
365    /// Returns true if no themes are registered.
366    pub fn is_empty(&self) -> bool {
367        self.inline.is_empty() && self.inner.is_empty()
368    }
369
370    /// Clears all registered themes.
371    pub fn clear(&mut self) {
372        self.inline.clear();
373        self.inner.clear();
374    }
375
376    /// Refreshes file-based themes from disk.
377    ///
378    /// This re-walks all registered directories and updates the internal
379    /// cache. Useful in long-running applications that need to pick up
380    /// theme changes without restarting.
381    ///
382    /// # Errors
383    ///
384    /// Returns an error if any directory cannot be read.
385    pub fn refresh(&mut self) -> Result<(), StylesheetError> {
386        self.inner.refresh().map_err(|e| StylesheetError::Load {
387            message: e.to_string(),
388        })
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use std::fs;
396    use tempfile::TempDir;
397
398    #[test]
399    fn test_registry_new_is_empty() {
400        let registry = StylesheetRegistry::new();
401        assert!(registry.is_empty());
402        assert_eq!(registry.len(), 0);
403    }
404
405    #[test]
406    fn test_registry_add_inline() {
407        let mut registry = StylesheetRegistry::new();
408        registry
409            .add_inline(
410                "test",
411                r#"
412                header:
413                    fg: cyan
414                    bold: true
415                "#,
416            )
417            .unwrap();
418
419        assert!(!registry.is_empty());
420        assert_eq!(registry.len(), 1);
421        assert!(registry.contains("test"));
422    }
423
424    #[test]
425    fn test_registry_add_theme() {
426        let mut registry = StylesheetRegistry::new();
427        let theme = Theme::new().add("header", console::Style::new().cyan().bold());
428        registry.add_theme("custom", theme);
429
430        assert!(registry.contains("custom"));
431        let retrieved = registry.get("custom").unwrap();
432        assert!(retrieved.resolve_styles(None).has("header"));
433    }
434
435    #[test]
436    fn test_registry_get_inline() {
437        let mut registry = StylesheetRegistry::new();
438        registry
439            .add_inline(
440                "darcula",
441                r#"
442                header:
443                    fg: cyan
444                muted:
445                    dim: true
446                "#,
447            )
448            .unwrap();
449
450        let theme = registry.get("darcula").unwrap();
451        let styles = theme.resolve_styles(None);
452        assert!(styles.has("header"));
453        assert!(styles.has("muted"));
454    }
455
456    #[test]
457    fn test_registry_add_dir() {
458        let temp_dir = TempDir::new().unwrap();
459        let theme_path = temp_dir.path().join("monokai.yaml");
460        fs::write(
461            &theme_path,
462            r#"
463            keyword:
464                fg: magenta
465                bold: true
466            string:
467                fg: green
468            "#,
469        )
470        .unwrap();
471
472        let mut registry = StylesheetRegistry::new();
473        registry.add_dir(temp_dir.path()).unwrap();
474
475        let theme = registry.get("monokai").unwrap();
476        let styles = theme.resolve_styles(None);
477        assert!(styles.has("keyword"));
478        assert!(styles.has("string"));
479    }
480
481    #[test]
482    fn test_registry_inline_shadows_file() {
483        let temp_dir = TempDir::new().unwrap();
484        let theme_path = temp_dir.path().join("test.yaml");
485        fs::write(
486            &theme_path,
487            r#"
488            from_file:
489                fg: red
490            header:
491                fg: red
492            "#,
493        )
494        .unwrap();
495
496        let mut registry = StylesheetRegistry::new();
497        registry.add_dir(temp_dir.path()).unwrap();
498        registry
499            .add_inline(
500                "test",
501                r#"
502            from_inline:
503                fg: blue
504            header:
505                fg: blue
506            "#,
507            )
508            .unwrap();
509
510        // Inline should win
511        let theme = registry.get("test").unwrap();
512        let styles = theme.resolve_styles(None);
513        assert!(styles.has("from_inline"));
514        assert!(!styles.has("from_file"));
515    }
516
517    #[test]
518    fn test_registry_extension_priority() {
519        let temp_dir = TempDir::new().unwrap();
520
521        // Create both .yaml and .yml with different content
522        fs::write(
523            temp_dir.path().join("theme.yaml"),
524            r#"
525            from_yaml:
526                fg: cyan
527            source:
528                fg: cyan
529            "#,
530        )
531        .unwrap();
532
533        fs::write(
534            temp_dir.path().join("theme.yml"),
535            r#"
536            from_yml:
537                fg: red
538            source:
539                fg: red
540            "#,
541        )
542        .unwrap();
543
544        let mut registry = StylesheetRegistry::new();
545        registry.add_dir(temp_dir.path()).unwrap();
546
547        // .yaml should win over .yml
548        let theme = registry.get("theme").unwrap();
549        let styles = theme.resolve_styles(None);
550        assert!(styles.has("from_yaml"));
551        assert!(!styles.has("from_yml"));
552    }
553
554    #[test]
555    fn test_registry_names() {
556        let mut registry = StylesheetRegistry::new();
557        registry.add_inline("alpha", "header: bold").unwrap();
558        registry.add_inline("beta", "header: dim").unwrap();
559
560        let names: Vec<&str> = registry.names().collect();
561        assert!(names.contains(&"alpha"));
562        assert!(names.contains(&"beta"));
563    }
564
565    #[test]
566    fn test_registry_clear() {
567        let mut registry = StylesheetRegistry::new();
568        registry.add_inline("test", "header: bold").unwrap();
569        assert!(!registry.is_empty());
570
571        registry.clear();
572        assert!(registry.is_empty());
573    }
574
575    #[test]
576    fn test_registry_not_found() {
577        let mut registry = StylesheetRegistry::new();
578        let result = registry.get("nonexistent");
579        assert!(result.is_err());
580    }
581
582    #[test]
583    fn test_registry_invalid_yaml() {
584        let mut registry = StylesheetRegistry::new();
585        let result = registry.add_inline("bad", "not: [valid: yaml");
586        assert!(result.is_err());
587    }
588
589    #[test]
590    fn test_registry_hot_reload() {
591        let temp_dir = TempDir::new().unwrap();
592        let theme_path = temp_dir.path().join("dynamic.yaml");
593        fs::write(
594            &theme_path,
595            r#"
596            version_v1:
597                fg: red
598            header:
599                fg: red
600            "#,
601        )
602        .unwrap();
603
604        let mut registry = StylesheetRegistry::new();
605        registry.add_dir(temp_dir.path()).unwrap();
606
607        // First read
608        let theme1 = registry.get("dynamic").unwrap();
609        let styles1 = theme1.resolve_styles(None);
610        assert!(styles1.has("version_v1"));
611
612        // Update the file
613        fs::write(
614            &theme_path,
615            r#"
616            version_v2:
617                fg: green
618            updated_style:
619                fg: blue
620            header:
621                fg: blue
622            "#,
623        )
624        .unwrap();
625
626        // Refresh and read again
627        registry.refresh().unwrap();
628        let theme2 = registry.get("dynamic").unwrap();
629        let styles2 = theme2.resolve_styles(None);
630        assert!(styles2.has("updated_style"));
631    }
632
633    #[test]
634    fn test_registry_adaptive_theme() {
635        let mut registry = StylesheetRegistry::new();
636        registry
637            .add_inline(
638                "adaptive",
639                r#"
640            panel:
641                fg: gray
642                light:
643                    fg: black
644                dark:
645                    fg: white
646            "#,
647            )
648            .unwrap();
649
650        let theme = registry.get("adaptive").unwrap();
651
652        // Check light mode
653        let light_styles = theme.resolve_styles(Some(crate::ColorMode::Light));
654        assert!(light_styles.has("panel"));
655
656        // Check dark mode
657        let dark_styles = theme.resolve_styles(Some(crate::ColorMode::Dark));
658        assert!(dark_styles.has("panel"));
659    }
660
661    // =========================================================================
662    // from_embedded_entries tests
663    // =========================================================================
664
665    #[test]
666    fn test_from_embedded_entries_single() {
667        let entries: &[(&str, &str)] = &[("test.yaml", "header:\n    fg: cyan\n    bold: true")];
668        let registry = StylesheetRegistry::from_embedded_entries(entries).unwrap();
669
670        // Should be accessible by both names
671        assert!(registry.contains("test"));
672        assert!(registry.contains("test.yaml"));
673    }
674
675    #[test]
676    fn test_from_embedded_entries_multiple() {
677        let entries: &[(&str, &str)] = &[
678            ("light.yaml", "header:\n    fg: black"),
679            ("dark.yaml", "header:\n    fg: white"),
680        ];
681        let registry = StylesheetRegistry::from_embedded_entries(entries).unwrap();
682
683        assert_eq!(registry.len(), 4); // 2 base + 2 with ext
684        assert!(registry.contains("light"));
685        assert!(registry.contains("dark"));
686    }
687
688    #[test]
689    fn test_from_embedded_entries_nested_paths() {
690        let entries: &[(&str, &str)] = &[
691            ("themes/monokai.yaml", "keyword:\n    fg: magenta"),
692            ("themes/solarized.yaml", "keyword:\n    fg: cyan"),
693        ];
694        let registry = StylesheetRegistry::from_embedded_entries(entries).unwrap();
695
696        assert!(registry.contains("themes/monokai"));
697        assert!(registry.contains("themes/monokai.yaml"));
698        assert!(registry.contains("themes/solarized"));
699    }
700
701    #[test]
702    fn test_from_embedded_entries_extension_priority() {
703        // .yaml has higher priority than .yml (index 0 vs index 1)
704        let entries: &[(&str, &str)] = &[
705            ("config.yml", "from_yml:\n    fg: red"),
706            ("config.yaml", "from_yaml:\n    fg: cyan"),
707        ];
708        let mut registry = StylesheetRegistry::from_embedded_entries(entries).unwrap();
709
710        // Base name should resolve to higher priority (.yaml)
711        let theme = registry.get("config").unwrap();
712        let styles = theme.resolve_styles(None);
713        assert!(styles.has("from_yaml"));
714        assert!(!styles.has("from_yml"));
715
716        // Both can still be accessed by full name
717        let yml_theme = registry.get("config.yml").unwrap();
718        assert!(yml_theme.resolve_styles(None).has("from_yml"));
719    }
720
721    #[test]
722    fn test_from_embedded_entries_extension_priority_reverse_order() {
723        // Same test but with entries in reverse order to ensure sorting works
724        let entries: &[(&str, &str)] = &[
725            ("config.yaml", "from_yaml:\n    fg: cyan"),
726            ("config.yml", "from_yml:\n    fg: red"),
727        ];
728        let mut registry = StylesheetRegistry::from_embedded_entries(entries).unwrap();
729
730        // Base name should still resolve to higher priority (.yaml)
731        let theme = registry.get("config").unwrap();
732        let styles = theme.resolve_styles(None);
733        assert!(styles.has("from_yaml"));
734    }
735
736    #[test]
737    fn test_from_embedded_entries_names_iterator() {
738        let entries: &[(&str, &str)] =
739            &[("a.yaml", "header: bold"), ("nested/b.yaml", "header: dim")];
740        let registry = StylesheetRegistry::from_embedded_entries(entries).unwrap();
741
742        let names: Vec<&str> = registry.names().collect();
743        assert!(names.contains(&"a"));
744        assert!(names.contains(&"a.yaml"));
745        assert!(names.contains(&"nested/b"));
746        assert!(names.contains(&"nested/b.yaml"));
747    }
748
749    #[test]
750    fn test_from_embedded_entries_empty() {
751        let entries: &[(&str, &str)] = &[];
752        let registry = StylesheetRegistry::from_embedded_entries(entries).unwrap();
753
754        assert!(registry.is_empty());
755        assert_eq!(registry.len(), 0);
756    }
757
758    #[test]
759    fn test_from_embedded_entries_invalid_yaml() {
760        let entries: &[(&str, &str)] = &[("bad.yaml", "not: [valid: yaml")];
761        let result = StylesheetRegistry::from_embedded_entries(entries);
762
763        assert!(result.is_err());
764    }
765}