standout_render/style/
registry.rs

1//! Style registry for managing named styles.
2
3use console::Style;
4use std::collections::HashMap;
5
6use super::error::StyleValidationError;
7use super::value::StyleValue;
8
9/// Default prefix shown when a style name is not found.
10pub const DEFAULT_MISSING_STYLE_INDICATOR: &str = "(!?)";
11
12/// A collection of named styles.
13///
14/// Styles are registered by name and applied via the `style` filter in templates.
15/// Styles can be concrete (with actual formatting) or aliases to other styles,
16/// enabling layered styling (semantic -> presentation -> visual).
17///
18/// When a style name is not found, a configurable indicator is prepended to the text
19/// to help catch typos in templates (defaults to `(!?)`).
20///
21/// # Example
22///
23/// ```rust
24/// use standout::Styles;
25/// use console::Style;
26///
27/// let styles = Styles::new()
28///     // Concrete styles
29///     .add("error", Style::new().bold().red())
30///     .add("warning", Style::new().yellow())
31///     .add("dim", Style::new().dim())
32///     // Alias styles
33///     .add("muted", "dim");
34///
35/// // Apply a style (returns styled string)
36/// let styled = styles.apply("error", "Something went wrong");
37///
38/// // Aliases resolve to their target
39/// let muted = styles.apply("muted", "Quiet");  // Uses "dim" style
40///
41/// // Unknown style shows indicator
42/// let unknown = styles.apply("typo", "Hello");
43/// assert!(unknown.starts_with("(!?)"));
44/// ```
45#[derive(Debug, Clone)]
46pub struct Styles {
47    styles: HashMap<String, StyleValue>,
48    missing_indicator: String,
49}
50
51impl Default for Styles {
52    fn default() -> Self {
53        Self {
54            styles: HashMap::new(),
55            missing_indicator: DEFAULT_MISSING_STYLE_INDICATOR.to_string(),
56        }
57    }
58}
59
60impl Styles {
61    /// Creates an empty style registry with the default missing style indicator.
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Sets a custom indicator to prepend when a style name is not found.
67    ///
68    /// This helps catch typos in templates. Set to empty string to disable.
69    ///
70    /// # Example
71    ///
72    /// ```rust
73    /// use standout::Styles;
74    ///
75    /// let styles = Styles::new()
76    ///     .missing_indicator("[MISSING]")
77    ///     .add("ok", console::Style::new().green());
78    ///
79    /// // Typo in style name
80    /// let output = styles.apply("typo", "Hello");
81    /// assert_eq!(output, "[MISSING] Hello");
82    /// ```
83    pub fn missing_indicator(mut self, indicator: &str) -> Self {
84        self.missing_indicator = indicator.to_string();
85        self
86    }
87
88    /// Adds a named style. Returns self for chaining.
89    ///
90    /// The value can be either a concrete `Style` or a `&str`/`String` alias
91    /// to another style name.
92    ///
93    /// # Example
94    ///
95    /// ```rust
96    /// use standout::Styles;
97    /// use console::Style;
98    ///
99    /// let styles = Styles::new()
100    ///     .add("dim", Style::new().dim())      // Concrete style
101    ///     .add("muted", "dim");                 // Alias to "dim"
102    /// ```
103    ///
104    /// If a style with the same name exists, it is replaced.
105    pub fn add<V: Into<StyleValue>>(mut self, name: &str, value: V) -> Self {
106        self.styles.insert(name.to_string(), value.into());
107        self
108    }
109
110    /// Resolves a style name to a concrete `Style`, following alias chains.
111    ///
112    /// Returns `None` if the style doesn't exist or if a cycle is detected.
113    /// For detailed error information, use `validate()` instead.
114    pub(crate) fn resolve(&self, name: &str) -> Option<&Style> {
115        let mut current = name;
116        let mut visited = std::collections::HashSet::new();
117
118        loop {
119            if !visited.insert(current) {
120                return None; // Cycle detected
121            }
122            match self.styles.get(current)? {
123                StyleValue::Concrete(style) => return Some(style),
124                StyleValue::Alias(next) => current = next,
125            }
126        }
127    }
128
129    /// Checks if a style name can be resolved (exists and has no cycles).
130    fn can_resolve(&self, name: &str) -> bool {
131        self.resolve(name).is_some()
132    }
133
134    /// Validates that all style aliases resolve correctly.
135    ///
136    /// Returns `Ok(())` if all aliases point to existing styles with no cycles.
137    /// Returns an error describing the first problem found.
138    ///
139    /// # Example
140    ///
141    /// ```rust
142    /// use standout::{Styles, StyleValidationError};
143    /// use console::Style;
144    ///
145    /// // Valid: alias chain resolves
146    /// let valid = Styles::new()
147    ///     .add("dim", Style::new().dim())
148    ///     .add("muted", "dim");
149    /// assert!(valid.validate().is_ok());
150    ///
151    /// // Invalid: dangling alias
152    /// let dangling = Styles::new()
153    ///     .add("orphan", "nonexistent");
154    /// assert!(matches!(
155    ///     dangling.validate(),
156    ///     Err(StyleValidationError::UnresolvedAlias { .. })
157    /// ));
158    ///
159    /// // Invalid: cycle
160    /// let cycle = Styles::new()
161    ///     .add("a", "b")
162    ///     .add("b", "a");
163    /// assert!(matches!(
164    ///     cycle.validate(),
165    ///     Err(StyleValidationError::CycleDetected { .. })
166    /// ));
167    /// ```
168    pub fn validate(&self) -> Result<(), StyleValidationError> {
169        for (name, value) in &self.styles {
170            if let StyleValue::Alias(target) = value {
171                self.validate_alias_chain(name, target)?;
172            }
173        }
174        Ok(())
175    }
176
177    /// Validates a single alias chain starting from `name` -> `target`.
178    fn validate_alias_chain(&self, name: &str, target: &str) -> Result<(), StyleValidationError> {
179        let mut current = target;
180        let mut path = vec![name.to_string()];
181
182        loop {
183            // Check if target exists
184            let value =
185                self.styles
186                    .get(current)
187                    .ok_or_else(|| StyleValidationError::UnresolvedAlias {
188                        from: path.last().unwrap().clone(),
189                        to: current.to_string(),
190                    })?;
191
192            path.push(current.to_string());
193
194            // Check for cycle (if we've seen this name before in our path)
195            if path[..path.len() - 1].contains(&current.to_string()) {
196                return Err(StyleValidationError::CycleDetected { path });
197            }
198
199            match value {
200                StyleValue::Concrete(_) => return Ok(()),
201                StyleValue::Alias(next) => current = next,
202            }
203        }
204    }
205
206    /// Applies a named style to text.
207    ///
208    /// Resolves aliases to find the concrete style, then applies it.
209    /// If the style doesn't exist or can't be resolved, prepends the missing indicator.
210    pub fn apply(&self, name: &str, text: &str) -> String {
211        match self.resolve(name) {
212            Some(style) => style.apply_to(text).to_string(),
213            None if self.missing_indicator.is_empty() => text.to_string(),
214            None => format!("{} {}", self.missing_indicator, text),
215        }
216    }
217
218    /// Applies style checking without ANSI codes (plain text mode).
219    ///
220    /// If the style exists and resolves, returns the text unchanged.
221    /// If not found or unresolvable, prepends the missing indicator (unless it's empty).
222    pub fn apply_plain(&self, name: &str, text: &str) -> String {
223        if self.can_resolve(name) || self.missing_indicator.is_empty() {
224            text.to_string()
225        } else {
226            format!("{} {}", self.missing_indicator, text)
227        }
228    }
229
230    /// Applies a style based on the output mode.
231    ///
232    /// - `Term` - Applies ANSI styling
233    /// - `Text` - Returns plain text (no ANSI codes)
234    /// - `Auto` - Should be resolved before calling this method
235    ///
236    /// Note: For `Auto` mode, call `OutputMode::should_use_color()` first
237    /// to determine whether to use `Term` or `Text`.
238    pub fn apply_with_mode(&self, name: &str, text: &str, use_color: bool) -> String {
239        if use_color {
240            self.apply(name, text)
241        } else {
242            self.apply_plain(name, text)
243        }
244    }
245
246    /// Applies a style in debug mode, rendering as bracket tags.
247    ///
248    /// Returns `[name]text[/name]` for styles that resolve correctly,
249    /// or applies the missing indicator for unknown/unresolvable styles.
250    ///
251    /// # Example
252    ///
253    /// ```rust
254    /// use standout::Styles;
255    /// use console::Style;
256    ///
257    /// let styles = Styles::new()
258    ///     .add("bold", Style::new().bold())
259    ///     .add("emphasis", "bold");  // Alias
260    ///
261    /// // Direct style renders as bracket tags
262    /// assert_eq!(styles.apply_debug("bold", "hello"), "[bold]hello[/bold]");
263    ///
264    /// // Alias also renders with its own name (not the target)
265    /// assert_eq!(styles.apply_debug("emphasis", "hello"), "[emphasis]hello[/emphasis]");
266    ///
267    /// // Unknown style shows indicator
268    /// assert_eq!(styles.apply_debug("unknown", "hello"), "(!?) hello");
269    /// ```
270    pub fn apply_debug(&self, name: &str, text: &str) -> String {
271        if self.can_resolve(name) {
272            format!("[{}]{}[/{}]", name, text, name)
273        } else if self.missing_indicator.is_empty() {
274            text.to_string()
275        } else {
276            format!("{} {}", self.missing_indicator, text)
277        }
278    }
279
280    /// Returns true if a style with the given name exists (concrete or alias).
281    pub fn has(&self, name: &str) -> bool {
282        self.styles.contains_key(name)
283    }
284
285    /// Returns the number of registered styles (both concrete and aliases).
286    pub fn len(&self) -> usize {
287        self.styles.len()
288    }
289
290    /// Returns true if no styles are registered.
291    pub fn is_empty(&self) -> bool {
292        self.styles.is_empty()
293    }
294
295    /// Returns a map of all style names to their resolved concrete styles.
296    ///
297    /// This is useful for passing styles to external processors like BBParser.
298    /// Aliases are resolved to their target concrete styles, and styles that
299    /// cannot be resolved (cycles, dangling aliases) are omitted.
300    ///
301    /// # Example
302    ///
303    /// ```rust
304    /// use standout::Styles;
305    /// use console::Style;
306    ///
307    /// let styles = Styles::new()
308    ///     .add("bold", Style::new().bold())
309    ///     .add("emphasis", "bold");  // Alias
310    ///
311    /// let resolved = styles.to_resolved_map();
312    /// assert!(resolved.contains_key("bold"));
313    /// assert!(resolved.contains_key("emphasis"));
314    /// assert_eq!(resolved.len(), 2);
315    /// ```
316    pub fn to_resolved_map(&self) -> HashMap<String, Style> {
317        let mut result = HashMap::new();
318        for name in self.styles.keys() {
319            if let Some(style) = self.resolve(name) {
320                result.insert(name.clone(), style.clone());
321            }
322        }
323        result
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_styles_new_is_empty() {
333        let styles = Styles::new();
334        assert!(styles.is_empty());
335        assert_eq!(styles.len(), 0);
336    }
337
338    #[test]
339    fn test_styles_add_and_has() {
340        let styles = Styles::new()
341            .add("error", Style::new().red())
342            .add("ok", Style::new().green());
343
344        assert!(styles.has("error"));
345        assert!(styles.has("ok"));
346        assert!(!styles.has("warning"));
347        assert_eq!(styles.len(), 2);
348    }
349
350    #[test]
351    fn test_styles_apply_unknown_shows_indicator() {
352        let styles = Styles::new();
353        let result = styles.apply("nonexistent", "hello");
354        assert_eq!(result, "(!?) hello");
355    }
356
357    #[test]
358    fn test_styles_apply_unknown_with_empty_indicator() {
359        let styles = Styles::new().missing_indicator("");
360        let result = styles.apply("nonexistent", "hello");
361        assert_eq!(result, "hello");
362    }
363
364    #[test]
365    fn test_styles_apply_unknown_with_custom_indicator() {
366        let styles = Styles::new().missing_indicator("[MISSING]");
367        let result = styles.apply("nonexistent", "hello");
368        assert_eq!(result, "[MISSING] hello");
369    }
370
371    #[test]
372    fn test_styles_apply_plain_known_style() {
373        let styles = Styles::new().add("bold", Style::new().bold());
374        let result = styles.apply_plain("bold", "hello");
375        // apply_plain returns text without ANSI codes
376        assert_eq!(result, "hello");
377    }
378
379    #[test]
380    fn test_styles_apply_plain_unknown_shows_indicator() {
381        let styles = Styles::new();
382        let result = styles.apply_plain("nonexistent", "hello");
383        assert_eq!(result, "(!?) hello");
384    }
385
386    #[test]
387    fn test_styles_apply_known_style() {
388        let styles = Styles::new().add("bold", Style::new().bold().force_styling(true));
389        let result = styles.apply("bold", "hello");
390        // The result should contain ANSI codes for bold
391        assert!(result.contains("hello"));
392        // Bold ANSI code is \x1b[1m
393        assert!(result.contains("\x1b[1m"));
394    }
395
396    #[test]
397    fn test_styles_can_be_replaced() {
398        let styles = Styles::new()
399            .add("x", Style::new().red())
400            .add("x", Style::new().green()); // Replace
401
402        // Should only have one style
403        assert_eq!(styles.len(), 1);
404        assert!(styles.has("x"));
405    }
406
407    #[test]
408    fn test_styles_apply_with_mode_color() {
409        let styles = Styles::new().add("bold", Style::new().bold().force_styling(true));
410        let result = styles.apply_with_mode("bold", "hello", true);
411        // Should contain ANSI codes
412        assert!(result.contains("\x1b[1m"));
413        assert!(result.contains("hello"));
414    }
415
416    #[test]
417    fn test_styles_apply_with_mode_no_color() {
418        let styles = Styles::new().add("bold", Style::new().bold());
419        let result = styles.apply_with_mode("bold", "hello", false);
420        // Should not contain ANSI codes
421        assert_eq!(result, "hello");
422    }
423
424    #[test]
425    fn test_styles_apply_with_mode_missing_style() {
426        let styles = Styles::new();
427        // With color
428        let result = styles.apply_with_mode("nonexistent", "hello", true);
429        assert_eq!(result, "(!?) hello");
430        // Without color
431        let result = styles.apply_with_mode("nonexistent", "hello", false);
432        assert_eq!(result, "(!?) hello");
433    }
434
435    #[test]
436    fn test_styles_apply_debug_known_style() {
437        let styles = Styles::new().add("bold", Style::new().bold());
438        let result = styles.apply_debug("bold", "hello");
439        assert_eq!(result, "[bold]hello[/bold]");
440    }
441
442    #[test]
443    fn test_styles_apply_debug_unknown_style() {
444        let styles = Styles::new();
445        let result = styles.apply_debug("unknown", "hello");
446        assert_eq!(result, "(!?) hello");
447    }
448
449    #[test]
450    fn test_styles_apply_debug_unknown_empty_indicator() {
451        let styles = Styles::new().missing_indicator("");
452        let result = styles.apply_debug("unknown", "hello");
453        assert_eq!(result, "hello");
454    }
455
456    // --- Resolution Tests ---
457
458    #[test]
459    fn test_resolve_concrete_style() {
460        let styles = Styles::new().add("bold", Style::new().bold());
461        assert!(styles.resolve("bold").is_some());
462    }
463
464    #[test]
465    fn test_resolve_nonexistent_style() {
466        let styles = Styles::new();
467        assert!(styles.resolve("nonexistent").is_none());
468    }
469
470    #[test]
471    fn test_resolve_single_alias() {
472        let styles = Styles::new()
473            .add("base", Style::new().dim())
474            .add("alias", "base");
475
476        assert!(styles.resolve("alias").is_some());
477        assert!(styles.resolve("base").is_some());
478    }
479
480    #[test]
481    fn test_resolve_chained_aliases() {
482        let styles = Styles::new()
483            .add("visual", Style::new().cyan())
484            .add("presentation", "visual")
485            .add("semantic", "presentation");
486
487        // All should resolve to the same concrete style
488        assert!(styles.resolve("visual").is_some());
489        assert!(styles.resolve("presentation").is_some());
490        assert!(styles.resolve("semantic").is_some());
491    }
492
493    #[test]
494    fn test_resolve_deep_alias_chain() {
495        let styles = Styles::new()
496            .add("level0", Style::new().bold())
497            .add("level1", "level0")
498            .add("level2", "level1")
499            .add("level3", "level2")
500            .add("level4", "level3");
501
502        assert!(styles.resolve("level4").is_some());
503    }
504
505    #[test]
506    fn test_resolve_dangling_alias_returns_none() {
507        let styles = Styles::new().add("orphan", "nonexistent");
508        assert!(styles.resolve("orphan").is_none());
509    }
510
511    #[test]
512    fn test_resolve_cycle_returns_none() {
513        let styles = Styles::new().add("a", "b").add("b", "a");
514
515        assert!(styles.resolve("a").is_none());
516        assert!(styles.resolve("b").is_none());
517    }
518
519    #[test]
520    fn test_resolve_self_referential_returns_none() {
521        let styles = Styles::new().add("self", "self");
522        assert!(styles.resolve("self").is_none());
523    }
524
525    #[test]
526    fn test_resolve_three_way_cycle() {
527        let styles = Styles::new().add("a", "b").add("b", "c").add("c", "a");
528
529        assert!(styles.resolve("a").is_none());
530        assert!(styles.resolve("b").is_none());
531        assert!(styles.resolve("c").is_none());
532    }
533
534    // --- Validation Tests ---
535
536    #[test]
537    fn test_validate_empty_styles() {
538        let styles = Styles::new();
539        assert!(styles.validate().is_ok());
540    }
541
542    #[test]
543    fn test_validate_only_concrete_styles() {
544        let styles = Styles::new()
545            .add("a", Style::new().bold())
546            .add("b", Style::new().dim())
547            .add("c", Style::new().red());
548
549        assert!(styles.validate().is_ok());
550    }
551
552    #[test]
553    fn test_validate_valid_alias() {
554        let styles = Styles::new()
555            .add("base", Style::new().dim())
556            .add("alias", "base");
557
558        assert!(styles.validate().is_ok());
559    }
560
561    #[test]
562    fn test_validate_valid_alias_chain() {
563        let styles = Styles::new()
564            .add("visual", Style::new().cyan())
565            .add("presentation", "visual")
566            .add("semantic", "presentation");
567
568        assert!(styles.validate().is_ok());
569    }
570
571    #[test]
572    fn test_validate_dangling_alias_error() {
573        let styles = Styles::new().add("orphan", "nonexistent");
574
575        let result = styles.validate();
576        assert!(result.is_err());
577
578        match result.unwrap_err() {
579            StyleValidationError::UnresolvedAlias { from, to } => {
580                assert_eq!(from, "orphan");
581                assert_eq!(to, "nonexistent");
582            }
583            _ => panic!("Expected UnresolvedAlias error"),
584        }
585    }
586
587    #[test]
588    fn test_validate_dangling_in_chain() {
589        let styles = Styles::new()
590            .add("level1", "level2")
591            .add("level2", "missing");
592
593        let result = styles.validate();
594        assert!(result.is_err());
595
596        match result.unwrap_err() {
597            StyleValidationError::UnresolvedAlias { from: _, to } => {
598                assert_eq!(to, "missing");
599            }
600            _ => panic!("Expected UnresolvedAlias error"),
601        }
602    }
603
604    #[test]
605    fn test_validate_cycle_error() {
606        let styles = Styles::new().add("a", "b").add("b", "a");
607
608        let result = styles.validate();
609        assert!(result.is_err());
610
611        match result.unwrap_err() {
612            StyleValidationError::CycleDetected { path } => {
613                assert!(path.contains(&"a".to_string()));
614                assert!(path.contains(&"b".to_string()));
615            }
616            _ => panic!("Expected CycleDetected error"),
617        }
618    }
619
620    #[test]
621    fn test_validate_self_referential_cycle() {
622        let styles = Styles::new().add("self", "self");
623
624        let result = styles.validate();
625        assert!(result.is_err());
626
627        match result.unwrap_err() {
628            StyleValidationError::CycleDetected { path } => {
629                assert!(path.contains(&"self".to_string()));
630            }
631            _ => panic!("Expected CycleDetected error"),
632        }
633    }
634
635    #[test]
636    fn test_validate_three_way_cycle() {
637        let styles = Styles::new().add("a", "b").add("b", "c").add("c", "a");
638
639        let result = styles.validate();
640        assert!(result.is_err());
641
642        match result.unwrap_err() {
643            StyleValidationError::CycleDetected { path } => {
644                assert!(path.len() >= 3);
645            }
646            _ => panic!("Expected CycleDetected error"),
647        }
648    }
649
650    #[test]
651    fn test_validate_mixed_valid_and_invalid() {
652        let styles = Styles::new()
653            .add("valid1", Style::new().bold())
654            .add("valid2", "valid1")
655            .add("invalid", "missing");
656
657        assert!(styles.validate().is_err());
658    }
659
660    // --- Apply with Aliases Tests ---
661
662    #[test]
663    fn test_apply_through_alias() {
664        let styles = Styles::new()
665            .add("base", Style::new().bold().force_styling(true))
666            .add("alias", "base");
667
668        let result = styles.apply("alias", "text");
669        assert!(result.contains("\x1b[1m"));
670        assert!(result.contains("text"));
671    }
672
673    #[test]
674    fn test_apply_through_chain() {
675        let styles = Styles::new()
676            .add("visual", Style::new().red().force_styling(true))
677            .add("presentation", "visual")
678            .add("semantic", "presentation");
679
680        let result = styles.apply("semantic", "error");
681        assert!(result.contains("\x1b[31m"));
682        assert!(result.contains("error"));
683    }
684
685    #[test]
686    fn test_apply_dangling_alias_shows_indicator() {
687        let styles = Styles::new().add("orphan", "missing");
688        let result = styles.apply("orphan", "text");
689        assert_eq!(result, "(!?) text");
690    }
691
692    #[test]
693    fn test_apply_cycle_shows_indicator() {
694        let styles = Styles::new().add("a", "b").add("b", "a");
695
696        let result = styles.apply("a", "text");
697        assert_eq!(result, "(!?) text");
698    }
699
700    #[test]
701    fn test_apply_plain_through_alias() {
702        let styles = Styles::new()
703            .add("base", Style::new().bold())
704            .add("alias", "base");
705
706        let result = styles.apply_plain("alias", "text");
707        assert_eq!(result, "text");
708    }
709
710    #[test]
711    fn test_apply_debug_through_alias() {
712        let styles = Styles::new()
713            .add("base", Style::new().bold())
714            .add("alias", "base");
715
716        let result = styles.apply_debug("alias", "text");
717        assert_eq!(result, "[alias]text[/alias]");
718    }
719
720    #[test]
721    fn test_apply_debug_dangling_alias() {
722        let styles = Styles::new().add("orphan", "missing");
723        let result = styles.apply_debug("orphan", "text");
724        assert_eq!(result, "(!?) text");
725    }
726}