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}