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}