1pub const UTF8_LOCALE_ENVS: [&str; 3] = ["LC_ALL", "LC_CTYPE", "LANG"];
16
17#[must_use]
23pub fn unicode_env_enabled() -> bool {
24 UTF8_LOCALE_ENVS.iter().any(|key| {
25 std::env::var(key).is_ok_and(|value| {
26 let value = value.to_ascii_lowercase();
27 value.contains("utf-8") || value.contains("utf8")
28 })
29 })
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Glyphs {
40 unicode: bool,
41}
42
43impl Glyphs {
44 #[must_use]
46 pub const fn new(unicode: bool) -> Self {
47 Self { unicode }
48 }
49
50 #[must_use]
52 pub fn from_env() -> Self {
53 Self::new(unicode_env_enabled())
54 }
55
56 #[must_use]
58 pub const fn unicode(self) -> bool {
59 self.unicode
60 }
61
62 #[must_use]
64 pub const fn success(self) -> &'static str {
65 self.pick("✓", "v")
66 }
67
68 #[must_use]
70 pub const fn error(self) -> &'static str {
71 self.pick("✗", "x")
72 }
73
74 #[must_use]
76 pub const fn warning(self) -> &'static str {
77 self.pick("⚠", "!")
78 }
79
80 #[must_use]
82 pub const fn info(self) -> &'static str {
83 self.pick("ℹ", "i")
84 }
85
86 #[must_use]
88 pub const fn bullet(self) -> &'static str {
89 self.pick("•", "*")
90 }
91
92 #[must_use]
94 pub const fn arrow(self) -> &'static str {
95 self.pick("→", "->")
96 }
97
98 #[must_use]
100 pub const fn pointer(self) -> &'static str {
101 self.pick("❯", ">")
102 }
103
104 #[must_use]
106 pub const fn answer(self) -> &'static str {
107 self.pick("»", ">")
108 }
109
110 #[must_use]
112 pub const fn radio_on(self) -> &'static str {
113 self.pick("◉", "(*)")
114 }
115
116 #[must_use]
118 pub const fn radio_off(self) -> &'static str {
119 self.pick("○", "( )")
120 }
121
122 #[must_use]
124 pub const fn arrow_up(self) -> &'static str {
125 self.pick("↑", "^")
126 }
127
128 #[must_use]
130 pub const fn arrow_down(self) -> &'static str {
131 self.pick("↓", "v")
132 }
133
134 #[must_use]
136 pub const fn ellipsis(self) -> &'static str {
137 self.pick("…", "...")
138 }
139
140 const fn pick(self, unicode: &'static str, ascii: &'static str) -> &'static str {
142 if self.unicode { unicode } else { ascii }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::{Glyphs, unicode_env_enabled};
149
150 #[test]
151 fn env_probe_and_from_env_agree_and_are_callable() {
152 assert_eq!(Glyphs::from_env().unicode(), unicode_env_enabled());
156 }
157
158 #[test]
159 fn unicode_set_emits_symbols() {
160 let glyphs = Glyphs::new(true);
161 assert!(glyphs.unicode());
162 assert_eq!(glyphs.success(), "✓");
163 assert_eq!(glyphs.error(), "✗");
164 assert_eq!(glyphs.warning(), "⚠");
165 assert_eq!(glyphs.info(), "ℹ");
166 assert_eq!(glyphs.bullet(), "•");
167 assert_eq!(glyphs.arrow(), "→");
168 assert_eq!(glyphs.pointer(), "❯");
169 assert_eq!(glyphs.answer(), "»");
170 assert_eq!(glyphs.radio_on(), "◉");
171 assert_eq!(glyphs.radio_off(), "○");
172 assert_eq!(glyphs.arrow_up(), "↑");
173 assert_eq!(glyphs.arrow_down(), "↓");
174 assert_eq!(glyphs.ellipsis(), "…");
175 }
176
177 #[test]
178 fn ascii_fallback_is_byte_clean() {
179 let glyphs = Glyphs::new(false);
180 assert!(!glyphs.unicode());
181 for symbol in [
182 glyphs.success(),
183 glyphs.error(),
184 glyphs.warning(),
185 glyphs.info(),
186 glyphs.bullet(),
187 glyphs.arrow(),
188 glyphs.pointer(),
189 glyphs.answer(),
190 glyphs.radio_on(),
191 glyphs.radio_off(),
192 glyphs.arrow_up(),
193 glyphs.arrow_down(),
194 glyphs.ellipsis(),
195 ] {
196 assert!(symbol.is_ascii(), "fallback must be ASCII: {symbol:?}");
197 }
198 }
199}