Skip to main content

cli_forge/
registry.rs

1//! Named styles: define once, reuse anywhere.
2//!
3//! [`define_tag`] stores a [`Style`]'s attributes under a name; [`tag`] looks
4//! that name back up and applies it to fresh text. This is the DRY styling path
5//! — a style is described in one place and recalled from anywhere in the program
6//! without repeating its color and attributes at every call site.
7//!
8//! The store is process-global because that is the point: a name defined in one
9//! module must resolve in another. It is a small read-mostly map behind an
10//! [`RwLock`], guarded so a poisoned lock degrades to plain output instead of
11//! taking down the program — styling is never critical enough to panic over.
12
13use std::collections::HashMap;
14use std::sync::{OnceLock, RwLock};
15
16use crate::Style;
17use crate::style::{StyleAttrs, write_styled};
18use crate::terminal;
19
20/// The global name → attributes map, created on first use.
21fn store() -> &'static RwLock<HashMap<String, StyleAttrs>> {
22    static STORE: OnceLock<RwLock<HashMap<String, StyleAttrs>>> = OnceLock::new();
23    STORE.get_or_init(|| RwLock::new(HashMap::new()))
24}
25
26/// Define a reusable named style.
27///
28/// Only the color and attributes of `style` are stored; its text is ignored, so
29/// the idiom is to define from an empty [`style`](crate::style). Defining the
30/// same name again replaces the previous definition.
31///
32/// # Examples
33///
34/// ```
35/// use cli_forge::{define_tag, out, style, tag};
36///
37/// define_tag("error", style("").red().bold());
38/// define_tag("hint", style("").cyan());
39///
40/// out(tag("error").render_with("build failed"));
41/// out(tag("hint").render_with("try `--release`"));
42/// ```
43pub fn define_tag<S: Into<String>>(name: S, style: Style) {
44    let attrs = style.attrs();
45    if let Ok(mut map) = store().write() {
46        // Replacing any previous definition for this name is intended.
47        let _ = map.insert(name.into(), attrs);
48    }
49    // A poisoned lock means another thread panicked mid-write. Skipping the
50    // definition keeps this fire-and-forget call non-panicking.
51}
52
53/// Look up a named style defined by [`define_tag`].
54///
55/// An unknown name yields a [`Tag`] that renders its text plain, so missing
56/// definitions degrade gracefully rather than erroring.
57///
58/// # Examples
59///
60/// ```
61/// use cli_forge::{define_tag, style, tag};
62///
63/// define_tag("ok", style("").green());
64/// assert!(tag("ok").render_with("passed").contains("passed"));
65///
66/// // Undefined names still render the text, just without styling.
67/// assert_eq!(tag("never-defined").render_with("text"), "text");
68/// ```
69#[must_use]
70pub fn tag(name: &str) -> Tag {
71    let attrs = store().read().ok().and_then(|map| map.get(name).copied());
72    Tag { attrs }
73}
74
75/// A resolved named style, returned by [`tag`].
76///
77/// Holds a snapshot of the named style's attributes (or none, for an unknown
78/// name), so it can render text without holding the registry lock.
79#[derive(Clone, Copy, Debug)]
80pub struct Tag {
81    attrs: Option<StyleAttrs>,
82}
83
84impl Tag {
85    /// Render `text` with this named style, returning an owned `String`.
86    ///
87    /// Color depth matches the terminal detected for standard output. For an
88    /// unknown name the text is returned unchanged.
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use cli_forge::{define_tag, style, tag};
94    ///
95    /// define_tag("warn", style("").yellow().bold());
96    /// let line = tag("warn").render_with("disk almost full");
97    /// assert!(line.contains("disk almost full"));
98    /// ```
99    #[must_use]
100    pub fn render_with(&self, text: &str) -> String {
101        let mut buf = String::with_capacity(text.len() + 24);
102        // Writing to a `String` is infallible.
103        let _ = write_styled(
104            &mut buf,
105            self.attrs_or_empty(),
106            text,
107            terminal::color_level(),
108        );
109        buf
110    }
111
112    /// The captured attributes, or an empty set for an unknown name. Used by the
113    /// cross-path equality tests to render at an explicit color level.
114    #[cfg(test)]
115    pub(crate) fn attrs_or_empty(&self) -> StyleAttrs {
116        self.attrs.unwrap_or(StyleAttrs::EMPTY)
117    }
118
119    #[cfg(not(test))]
120    fn attrs_or_empty(&self) -> StyleAttrs {
121        self.attrs.unwrap_or(StyleAttrs::EMPTY)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    #![allow(clippy::unwrap_used)]
128
129    use super::*;
130    use crate::style::style;
131    use crate::terminal::ColorLevel;
132
133    /// Render a resolved tag's captured attributes at an explicit level.
134    fn render_resolved(resolved: &Tag, text: &str, level: ColorLevel) -> String {
135        let attrs = resolved.attrs.unwrap_or(StyleAttrs::EMPTY);
136        let mut s = String::new();
137        write_styled(&mut s, attrs, text, level).unwrap();
138        s
139    }
140
141    #[test]
142    fn test_define_and_recall_applies_attributes() {
143        define_tag("reg-error", style("").red().bold());
144        let resolved = tag("reg-error");
145        assert_eq!(
146            render_resolved(&resolved, "failed", ColorLevel::Ansi16),
147            "\x1b[1;31mfailed\x1b[0m"
148        );
149    }
150
151    #[test]
152    fn test_redefining_replaces() {
153        define_tag("reg-x", style("").red());
154        define_tag("reg-x", style("").green());
155        let resolved = tag("reg-x");
156        assert_eq!(
157            render_resolved(&resolved, "v", ColorLevel::Ansi16),
158            "\x1b[32mv\x1b[0m"
159        );
160    }
161
162    #[test]
163    fn test_unknown_tag_is_plain() {
164        assert_eq!(tag("reg-undefined").render_with("text"), "text");
165        let resolved = tag("reg-undefined");
166        assert_eq!(
167            render_resolved(&resolved, "text", ColorLevel::TrueColor),
168            "text"
169        );
170    }
171}