Skip to main content

difflore_cli/
style.rs

1//! CLI style helpers. The public terminal surface uses an ASCII-safe
2//! 5-symbol set so Windows logs, CI, and screenshot captures stay aligned.
3//! NO_COLOR env-var disables coloring; support is detected once at startup via
4//! `detect_color_support()` and cached.
5
6use std::io::{IsTerminal, stdout};
7use std::sync::OnceLock;
8use std::time::{Duration, Instant};
9
10use colored::{ColoredString, Colorize};
11
12// ── Color palette ────────────────────────────────────────────────
13//
14// Two tuned ramps. The dark ramp is the original brand palette; the
15// light ramp is darkened so every role clears WCAG 4.5:1 on white and
16// cream backgrounds (the dark ramp's greens/ambers/blues fall to
17// ~2.2:1 on white). `background()` picks the ramp; see `Background`.
18
19pub fn pewter(s: &str) -> ColoredString {
20    match background() {
21        Background::Light => paint(s, 0x55, 0x5c, 0x5f),
22        Background::Dark => paint(s, 0x7d, 0x85, 0x88),
23    }
24}
25pub fn emerald(s: &str) -> ColoredString {
26    match background() {
27        Background::Light => paint(s, 0x15, 0x80, 0x3d),
28        Background::Dark => paint(s, 0x22, 0xc5, 0x5e),
29    }
30}
31pub fn amber(s: &str) -> ColoredString {
32    match background() {
33        Background::Light => paint(s, 0x8a, 0x62, 0x00),
34        Background::Dark => paint(s, 0xe0, 0xa5, 0x2a),
35    }
36}
37pub fn danger(s: &str) -> ColoredString {
38    match background() {
39        Background::Light => paint(s, 0xc0, 0x1c, 0x1c),
40        Background::Dark => paint(s, 0xef, 0x54, 0x54),
41    }
42}
43pub fn info(s: &str) -> ColoredString {
44    match background() {
45        Background::Light => paint(s, 0x1d, 0x6c, 0xd4),
46        Background::Dark => paint(s, 0x5a, 0xa0, 0xf2),
47    }
48}
49
50// Runnable commands get their own token color (info/blue) so "things
51// you can type" read as a distinct class, not as bold narration. Kept
52// non-bold: the hue carries the affordance, weight would over-shout.
53pub fn cmd(s: &str) -> ColoredString {
54    info(s)
55}
56// Primary-content emphasis for non-command text: result titles, recalled
57// rule names, verdict text. Bold default ink so it reads as the headline
58// of an entry without borrowing the blue "runnable command" affordance.
59pub fn title(s: &str) -> ColoredString {
60    match color_support() {
61        ColorSupport::None => s.normal(),
62        _ => s.bold(),
63    }
64}
65// Inline identifiers that are neither runnable commands nor headlines:
66// file paths, ids, repo names, counts, agent lists. Bold neutral (pewter)
67// so they stand out as distinct tokens in prose without the command blue.
68pub fn ident(s: &str) -> ColoredString {
69    pewter(s).bold()
70}
71// Bold variants — used for status verbs.
72pub fn ok(s: &str) -> ColoredString {
73    emerald(s).bold()
74}
75pub fn warn(s: &str) -> ColoredString {
76    amber(s).bold()
77}
78pub fn err(s: &str) -> ColoredString {
79    danger(s).bold()
80}
81
82fn paint(s: &str, r: u8, g: u8, b: u8) -> ColoredString {
83    match color_support() {
84        ColorSupport::None => s.normal(),
85        _ => s.truecolor(r, g, b),
86    }
87}
88
89// ── Symbols — ASCII-safe public status set ───────────────────────
90
91pub mod sym {
92    pub const OK: &str = "OK";
93    pub const ERR: &str = "X";
94    pub const WARN: &str = "!";
95    pub const TIP: &str = ">";
96    pub const BULLET: &str = "-";
97}
98
99// ── Color support detection ──────────────────────────────────────
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum ColorSupport {
103    TrueColor,
104    Ansi256,
105    None,
106}
107
108static COLOR_CACHE: OnceLock<ColorSupport> = OnceLock::new();
109
110// Cached after first call. Call once at startup from `main`.
111pub fn detect_color_support() -> ColorSupport {
112    *COLOR_CACHE.get_or_init(detect_color_support_uncached)
113}
114
115fn color_support() -> ColorSupport {
116    *COLOR_CACHE.get_or_init(detect_color_support_uncached)
117}
118
119// ── Background detection (light vs dark terminal) ────────────────
120//
121// The dark ramp is unreadable on a light terminal. We pick the light
122// ramp when the terminal advertises a light background. Detection is
123// best-effort and conservative — default to Dark, since most dev
124// terminals are dark and a wrong Light guess would wash colors out.
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum Background {
128    Dark,
129    Light,
130}
131
132static BG_CACHE: OnceLock<Background> = OnceLock::new();
133
134fn background() -> Background {
135    *BG_CACHE.get_or_init(detect_background_uncached)
136}
137
138fn detect_background_uncached() -> Background {
139    use difflore_core::env;
140    // Explicit override always wins.
141    if let Some(theme) = env::var(env::DIFFLORE_THEME) {
142        match theme.trim().to_ascii_lowercase().as_str() {
143            "light" => return Background::Light,
144            "dark" => return Background::Dark,
145            _ => {}
146        }
147    }
148    // COLORFGBG is "fg;bg" (sometimes "fg;default;bg"); bg is the last
149    // field. ANSI indices 0-6 and 8 are dark, 7/9-15 are light.
150    if let Some(fgbg) = env::var(env::COLORFGBG)
151        && let Some(bg) = fgbg.rsplit(';').next()
152        && let Ok(idx) = bg.trim().parse::<u8>()
153    {
154        return if matches!(idx, 0..=6 | 8) {
155            Background::Dark
156        } else {
157            Background::Light
158        };
159    }
160    Background::Dark
161}
162
163fn detect_color_support_uncached() -> ColorSupport {
164    use difflore_core::env;
165    if env::flag_set(env::NO_COLOR) {
166        return ColorSupport::None;
167    }
168    if !stdout().is_terminal() {
169        return ColorSupport::None;
170    }
171    match env::var(env::COLORTERM).as_deref() {
172        Some("truecolor" | "24bit") => ColorSupport::TrueColor,
173        _ => match env::var(env::TERM).as_deref() {
174            Some(t) if t.contains("256color") => ColorSupport::Ansi256,
175            _ => ColorSupport::TrueColor, // default optimistic
176        },
177    }
178}
179
180// ── Error reporter ───────────────────────────────────────────────
181
182/// Locked vocabulary for `Hint::label` — currently `"try"`.
183/// New labels require a CONTRACTS amendment.
184pub struct Hint {
185    pub label: &'static str,
186    pub body: String,
187}
188
189impl Hint {
190    pub(crate) fn try_(body: impl Into<String>) -> Self {
191        Self {
192            label: "try",
193            body: body.into(),
194        }
195    }
196}
197
198/// Print a uniform error block.
199///
200/// Layout:
201/// ```text
202/// X error - <summary>
203///
204///     <context line 1>
205///     <context line 2>
206///
207///   > try   <hint body>
208///   > docs  <hint body>
209/// ```
210pub fn report_error(summary: &str, context: &str, hints: &[Hint]) {
211    eprintln!(
212        "{} {} {} {}",
213        danger(sym::ERR),
214        err("error"),
215        pewter(sym::BULLET),
216        summary
217    );
218    eprintln!();
219    if !context.is_empty() {
220        for line in context.lines() {
221            eprintln!("    {line}");
222        }
223        eprintln!();
224    }
225    for h in hints {
226        eprintln!("  {} {} {}", emerald(sym::TIP), pewter(h.label), h.body);
227    }
228}
229
230// ── Spinner ──────────────────────────────────────────────────────
231
232const SPIN_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
233
234// Caller invokes `tick()` between async awaits; `finish_ok` /
235// `finish_err` clears the line and prints the final glyph.
236pub struct Spinner {
237    label: String,
238    frame: std::cell::Cell<usize>,
239    last_tick: std::cell::Cell<Instant>,
240    tick_interval: Duration,
241}
242
243impl Spinner {
244    pub(crate) fn new(label: &str) -> Self {
245        let s = Self {
246            label: label.to_owned(),
247            frame: std::cell::Cell::new(0),
248            last_tick: std::cell::Cell::new(Instant::now()),
249            tick_interval: Duration::from_millis(80),
250        };
251        // Render the first frame immediately so callers see the
252        // spinner without waiting for the first await boundary.
253        s.draw();
254        s
255    }
256
257    pub(crate) fn tick(&self) {
258        let now = Instant::now();
259        if now.duration_since(self.last_tick.get()) < self.tick_interval {
260            return;
261        }
262        self.last_tick.set(now);
263        self.frame.set((self.frame.get() + 1) % SPIN_FRAMES.len());
264        self.draw();
265    }
266
267    fn draw(&self) {
268        if color_support() == ColorSupport::None {
269            // No-color terminals: skip the animation; the final
270            // glyph + message in `finish_*` is enough.
271            return;
272        }
273        let glyph = SPIN_FRAMES[self.frame.get()];
274        // \r writes over the in-progress line; flush via println-
275        // family is intentional — spinner runs on stderr to keep
276        // stdout clean for piped JSON.
277        eprint!("\r{} {}  ", pewter(glyph), self.label);
278        let _ = std::io::Write::flush(&mut std::io::stderr());
279    }
280
281    pub(crate) fn set_message(&mut self, msg: &str) {
282        msg.clone_into(&mut self.label);
283        self.draw();
284    }
285
286    /// Final line: `OK <msg>` (emerald), spinner cleared.
287    pub(crate) fn finish_ok(self, msg: &str) {
288        self.clear_line();
289        eprintln!("{} {}", emerald(sym::OK), msg);
290    }
291
292    /// Final line: `X <msg>` (danger), spinner cleared.
293    pub(crate) fn finish_err(self, msg: &str) {
294        self.clear_line();
295        eprintln!("{} {}", danger(sym::ERR), msg);
296    }
297
298    fn clear_line(&self) {
299        if color_support() == ColorSupport::None {
300            return;
301        }
302        // Clear the spinner's line: `\r` to home, spaces to wipe,
303        // then `\r` again so the next write starts at column 0.
304        let pad = " ".repeat(self.label.chars().count() + 6);
305        eprint!("\r{pad}\r");
306        let _ = std::io::Write::flush(&mut std::io::stderr());
307    }
308}
309
310// Two-toned `difflore` wordmark; falls back to plain text under NO_COLOR.
311pub fn wordmark() -> String {
312    format!("{}{}", pewter("diff").bold(), emerald("lore").bold())
313}
314
315pub const DIVIDER: &str = "─────────────────────────────────────────────";
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn hint_helpers_use_locked_vocabulary() {
323        assert_eq!(Hint::try_("x").label, "try");
324    }
325
326    #[test]
327    fn symbols_match_contract() {
328        assert_eq!(sym::OK, "OK");
329        assert_eq!(sym::ERR, "X");
330        assert_eq!(sym::WARN, "!");
331        assert_eq!(sym::TIP, ">");
332        assert_eq!(sym::BULLET, "-");
333    }
334}