pub const UTF8_LOCALE_ENVS: [&str; 3] = ["LC_ALL", "LC_CTYPE", "LANG"];
#[must_use]
pub fn unicode_env_enabled() -> bool {
UTF8_LOCALE_ENVS.iter().any(|key| {
std::env::var(key).is_ok_and(|value| {
let value = value.to_ascii_lowercase();
value.contains("utf-8") || value.contains("utf8")
})
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Glyphs {
unicode: bool,
}
impl Glyphs {
#[must_use]
pub const fn new(unicode: bool) -> Self {
Self { unicode }
}
#[must_use]
pub fn from_env() -> Self {
Self::new(unicode_env_enabled())
}
#[must_use]
pub const fn unicode(self) -> bool {
self.unicode
}
#[must_use]
pub const fn success(self) -> &'static str {
self.pick("✓", "v")
}
#[must_use]
pub const fn error(self) -> &'static str {
self.pick("✗", "x")
}
#[must_use]
pub const fn warning(self) -> &'static str {
self.pick("⚠", "!")
}
#[must_use]
pub const fn info(self) -> &'static str {
self.pick("ℹ", "i")
}
#[must_use]
pub const fn bullet(self) -> &'static str {
self.pick("•", "*")
}
#[must_use]
pub const fn arrow(self) -> &'static str {
self.pick("→", "->")
}
#[must_use]
pub const fn pointer(self) -> &'static str {
self.pick("❯", ">")
}
#[must_use]
pub const fn answer(self) -> &'static str {
self.pick("»", ">")
}
#[must_use]
pub const fn radio_on(self) -> &'static str {
self.pick("◉", "(*)")
}
#[must_use]
pub const fn radio_off(self) -> &'static str {
self.pick("○", "( )")
}
#[must_use]
pub const fn arrow_up(self) -> &'static str {
self.pick("↑", "^")
}
#[must_use]
pub const fn arrow_down(self) -> &'static str {
self.pick("↓", "v")
}
#[must_use]
pub const fn ellipsis(self) -> &'static str {
self.pick("…", "...")
}
const fn pick(self, unicode: &'static str, ascii: &'static str) -> &'static str {
if self.unicode { unicode } else { ascii }
}
}
#[cfg(test)]
mod tests {
use super::{Glyphs, unicode_env_enabled};
#[test]
fn env_probe_and_from_env_agree_and_are_callable() {
assert_eq!(Glyphs::from_env().unicode(), unicode_env_enabled());
}
#[test]
fn unicode_set_emits_symbols() {
let glyphs = Glyphs::new(true);
assert!(glyphs.unicode());
assert_eq!(glyphs.success(), "✓");
assert_eq!(glyphs.error(), "✗");
assert_eq!(glyphs.warning(), "⚠");
assert_eq!(glyphs.info(), "ℹ");
assert_eq!(glyphs.bullet(), "•");
assert_eq!(glyphs.arrow(), "→");
assert_eq!(glyphs.pointer(), "❯");
assert_eq!(glyphs.answer(), "»");
assert_eq!(glyphs.radio_on(), "◉");
assert_eq!(glyphs.radio_off(), "○");
assert_eq!(glyphs.arrow_up(), "↑");
assert_eq!(glyphs.arrow_down(), "↓");
assert_eq!(glyphs.ellipsis(), "…");
}
#[test]
fn ascii_fallback_is_byte_clean() {
let glyphs = Glyphs::new(false);
assert!(!glyphs.unicode());
for symbol in [
glyphs.success(),
glyphs.error(),
glyphs.warning(),
glyphs.info(),
glyphs.bullet(),
glyphs.arrow(),
glyphs.pointer(),
glyphs.answer(),
glyphs.radio_on(),
glyphs.radio_off(),
glyphs.arrow_up(),
glyphs.arrow_down(),
glyphs.ellipsis(),
] {
assert!(symbol.is_ascii(), "fallback must be ASCII: {symbol:?}");
}
}
}