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}