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