Skip to main content

soma_cli_core/
color.rs

1//! ANSI color/style helpers.
2//!
3//! Every helper takes an explicit `enabled` flag rather than sampling the
4//! environment itself — callers decide whether to colorize using
5//! [`crate::terminal`] and pass the result in. This keeps styling decisions
6//! testable and keeps a single source of truth for "should this stream be
7//! colored" per invocation.
8
9/// Wrap `text` in the given ANSI SGR `code` when `enabled`, otherwise return
10/// `text` unchanged.
11pub fn style(text: &str, code: &str, enabled: bool) -> String {
12    if enabled {
13        format!("\x1b[{code}m{text}\x1b[0m")
14    } else {
15        text.to_string()
16    }
17}
18
19/// Standard ANSI green (SGR 32) — conventionally "success" / "ok".
20pub fn green(text: &str, enabled: bool) -> String {
21    style(text, "32", enabled)
22}
23
24/// Standard ANSI red (SGR 31) — conventionally "error" / "failed".
25pub fn red(text: &str, enabled: bool) -> String {
26    style(text, "31", enabled)
27}
28
29/// Standard ANSI yellow (SGR 33) — conventionally "warning" / "hint".
30pub fn yellow(text: &str, enabled: bool) -> String {
31    style(text, "33", enabled)
32}
33
34/// Bold (SGR 1).
35pub fn bold(text: &str, enabled: bool) -> String {
36    style(text, "1", enabled)
37}
38
39/// Dim (SGR 2) — conventionally section rules and secondary text.
40pub fn dim(text: &str, enabled: bool) -> String {
41    style(text, "2", enabled)
42}
43
44/// The Aurora CLI token palette, kept as reusable shared defaults per
45/// repo convention (`aurora-design-system/themes/editors/claude-code/TOKENS.md`).
46///
47/// These are truecolor (24-bit) hex values with an ANSI-256 fallback for
48/// terminals that do not support 24-bit color. Nothing in this crate wires
49/// these into a default rendering path — a consumer opts in explicitly by
50/// picking a role and calling [`truecolor_fg`] or using the ANSI-256 code
51/// directly.
52pub mod aurora {
53    /// One Aurora CLI token: a truecolor hex value plus its ANSI-256
54    /// fallback code.
55    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
56    pub struct Token {
57        pub hex: (u8, u8, u8),
58        pub ansi256: u8,
59    }
60
61    pub const ACCENT: Token = Token {
62        hex: (0x29, 0xb6, 0xf6),
63        ansi256: 39,
64    };
65    pub const TERTIARY: Token = Token {
66        hex: (0x67, 0xcb, 0xfa),
67        ansi256: 81,
68    };
69    pub const PRIMARY: Token = Token {
70        hex: (0xe6, 0xf4, 0xfb),
71        ansi256: 255,
72    };
73    pub const MUTED: Token = Token {
74        hex: (0xa7, 0xbc, 0xc9),
75        ansi256: 250,
76    };
77    pub const SERVICE_NAME: Token = Token {
78        hex: (0xf9, 0xa8, 0xc4),
79        ansi256: 217,
80    };
81    pub const VIOLET: Token = Token {
82        hex: (0xa7, 0x8b, 0xfa),
83        ansi256: 141,
84    };
85    pub const BORDER: Token = Token {
86        hex: (0x1d, 0x3d, 0x4e),
87        ansi256: 239,
88    };
89    pub const INFO: Token = Token {
90        hex: (0x72, 0xc8, 0xf5),
91        ansi256: 117,
92    };
93    pub const SUCCESS: Token = Token {
94        hex: (0x7d, 0xd3, 0xc7),
95        ansi256: 115,
96    };
97    pub const WARN: Token = Token {
98        hex: (0xc6, 0xa3, 0x6b),
99        ansi256: 180,
100    };
101    pub const ERROR: Token = Token {
102        hex: (0xc7, 0x84, 0x90),
103        ansi256: 174,
104    };
105    pub const NEUTRAL: Token = Token {
106        hex: (0x91, 0xa8, 0xb6),
107        ansi256: 109,
108    };
109}
110
111/// Wrap `text` in a 24-bit truecolor foreground escape for `token` when
112/// `enabled`, otherwise return `text` unchanged.
113pub fn truecolor_fg(text: &str, token: aurora::Token, enabled: bool) -> String {
114    if enabled {
115        let (r, g, b) = token.hex;
116        format!("\x1b[38;2;{r};{g};{b}m{text}\x1b[0m")
117    } else {
118        text.to_string()
119    }
120}
121
122/// Wrap `text` in an ANSI-256 foreground escape for `token` when `enabled`,
123/// otherwise return `text` unchanged. Use as a fallback on terminals that do
124/// not support 24-bit truecolor.
125pub fn ansi256_fg(text: &str, token: aurora::Token, enabled: bool) -> String {
126    if enabled {
127        format!("\x1b[38;5;{}m{text}\x1b[0m", token.ansi256)
128    } else {
129        text.to_string()
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn style_wraps_when_enabled() {
139        assert_eq!(green("ok", true), "\x1b[32mok\x1b[0m");
140        assert_eq!(red("bad", true), "\x1b[31mbad\x1b[0m");
141        assert_eq!(yellow("hint", true), "\x1b[33mhint\x1b[0m");
142        assert_eq!(bold("hi", true), "\x1b[1mhi\x1b[0m");
143        assert_eq!(dim("lo", true), "\x1b[2mlo\x1b[0m");
144    }
145
146    #[test]
147    fn style_passes_through_when_disabled() {
148        assert_eq!(green("ok", false), "ok");
149        assert_eq!(red("bad", false), "bad");
150        assert_eq!(yellow("hint", false), "hint");
151        assert_eq!(bold("hi", false), "hi");
152        assert_eq!(dim("lo", false), "lo");
153    }
154
155    #[test]
156    fn truecolor_and_ansi256_wrap_when_enabled() {
157        assert_eq!(
158            truecolor_fg("x", aurora::ACCENT, true),
159            "\x1b[38;2;41;182;246mx\x1b[0m"
160        );
161        assert_eq!(
162            ansi256_fg("x", aurora::ACCENT, true),
163            "\x1b[38;5;39mx\x1b[0m"
164        );
165        assert_eq!(truecolor_fg("x", aurora::ACCENT, false), "x");
166        assert_eq!(ansi256_fg("x", aurora::ACCENT, false), "x");
167    }
168}