Skip to main content

heddle_cli_render/cli/
style.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tasteful terminal styling for Heddle CLI output.
3//!
4//! Heddle's brand voice ("precise, calm, conversational") translates
5//! to a deliberately restrained terminal palette: dim/bright contrast
6//! and bold weight do most of the structural work; saturated color
7//! appears only at semantic seams (success/warning/error,
8//! confidence band, identity vs. id). No rainbow output, no syntax
9//! highlighting density.
10//!
11//! Color decisions are made **once** at CLI startup via
12//! [`init_from_cli`], which consults — in precedence order:
13//!
14//! 1. `--no-color` CLI flag (force off)
15//! 2. `NO_COLOR` env var (per <https://no-color.org>) — force off
16//! 3. `CLICOLOR_FORCE=1` env var — force on, even on a non-TTY
17//! 4. stdout isatty — auto-detected default
18//!
19//! The decision is stored in a process-wide [`OnceLock`] so render
20//! sites consult a `bool` rather than re-querying the environment per
21//! line. JSON output is *always* uncolored; that decision happens at
22//! the print site, not here — `should_output_json` short-circuits
23//! before any styled helper runs.
24
25use std::{
26    io::IsTerminal,
27    sync::atomic::{AtomicI8, Ordering},
28};
29
30use anstyle::{Color, Style};
31use heddle_cli_args::Cli;
32
33/// Process-wide gate, encoded as a tristate atomic so tests can
34/// override the value freely without rebuilding the cell.
35///
36/// - `0`  — uninitialized (treat as "color off" so we never leak
37///   escapes into log files when `init_from_cli` was skipped)
38/// - `1`  — color enabled
39/// - `-1` — color disabled (explicit)
40///
41/// Atomic-relaxed is sufficient: the value is set once at startup
42/// before any rendering begins, and tests use a single thread.
43static COLOR_STATE: AtomicI8 = AtomicI8::new(0);
44
45const STATE_OFF: i8 = -1;
46const STATE_ON: i8 = 1;
47
48/// Resolve the color decision once at CLI startup.
49///
50/// Subsequent calls overwrite the previous decision — tests need
51/// this so they can flip the gate mid-process. Production only
52/// calls this once, from `main`.
53pub fn init_from_cli(cli: &Cli) {
54    let enabled = decide_color_enabled(cli, &EnvProbe::real());
55    COLOR_STATE.store(
56        if enabled { STATE_ON } else { STATE_OFF },
57        Ordering::Relaxed,
58    );
59}
60
61/// Returns the active color decision. If `init_from_cli` was never
62/// called (e.g. in a library test that bypasses `main`), this
63/// defaults to `false` to avoid leaking escapes.
64pub fn color_enabled() -> bool {
65    COLOR_STATE.load(Ordering::Relaxed) == STATE_ON
66}
67
68/// Test-only override. Use this from any test that wants to assert
69/// styled or unstyled output without depending on the ambient TTY
70/// state.
71#[cfg(any(test, feature = "test-utils"))]
72pub fn force_for_test(enabled: bool) {
73    COLOR_STATE.store(
74        if enabled { STATE_ON } else { STATE_OFF },
75        Ordering::Relaxed,
76    );
77}
78
79/// Tiny env-var indirection so the decision logic stays unit-testable
80/// without touching the real environment. Each closure-style accessor
81/// returns the env value if set; `EnvProbe::real()` is the only
82/// production constructor, but tests can build a literal struct.
83struct EnvProbe<'a> {
84    no_color: Option<&'a str>,
85    clicolor_force: Option<&'a str>,
86    is_tty: bool,
87}
88
89impl EnvProbe<'_> {
90    fn real() -> EnvProbe<'static> {
91        // We leak these strings deliberately — they live for the
92        // duration of one decision call and are never observed
93        // afterwards. The alternative (`String`) would require
94        // generic lifetimes that aren't worth the complexity here.
95        let no_color = std::env::var("NO_COLOR").ok().map(|s| {
96            let leaked: &'static str = Box::leak(s.into_boxed_str());
97            leaked
98        });
99        let clicolor_force = std::env::var("CLICOLOR_FORCE").ok().map(|s| {
100            let leaked: &'static str = Box::leak(s.into_boxed_str());
101            leaked
102        });
103        EnvProbe {
104            no_color,
105            clicolor_force,
106            is_tty: std::io::stdout().is_terminal(),
107        }
108    }
109}
110
111fn decide_color_enabled(cli: &Cli, env: &EnvProbe<'_>) -> bool {
112    // 1. Explicit CLI flag wins. The user typed `--no-color`; honour
113    //    it regardless of any env var.
114    if cli.no_color {
115        return false;
116    }
117    // 2. `NO_COLOR` is the cross-tool standard
118    //    (<https://no-color.org>). Any non-empty value disables.
119    if let Some(v) = env.no_color
120        && !v.is_empty()
121    {
122        return false;
123    }
124    // 3. `CLICOLOR_FORCE=1` is the conventional escape hatch for
125    //    pipes that want color preserved (e.g. piping to `less -R`).
126    //    We require literal "1" to match the convention used by
127    //    `ls`, `grep`, and bat.
128    if let Some(v) = env.clicolor_force
129        && v == "1"
130    {
131        return true;
132    }
133    // 4. Otherwise: color iff stdout is an interactive TTY.
134    env.is_tty
135}
136
137// =====================================================================
138// Palette
139// =====================================================================
140//
141// Brand calls for warm/technical, never the saturated 16-color
142// defaults. We use anstyle's 8-bit (256-color) palette to land on
143// muted, deliberate hues:
144//
145// - `accent`: ANSI 8-bit 71 — a warm sage/green, used for success,
146//   "current", and confidence ≥ 0.9. Cooler than 34 (lime) and warmer
147//   than 28 (forest); reads well on both light and dark terminals.
148// - `warn`:   ANSI 8-bit 178 — a warm amber, mid-warning. Avoids the
149//   safety-vest 220 (yellow) and the orange 208 which reads as error.
150// - `error`:  ANSI 8-bit 167 — a muted rust/terracotta. Cooler and
151//   more deliberate than the default red 9; signals failure without
152//   shouting.
153// - `dim`:    standard "faint" weight — terminal-theme aware, since
154//   8-bit grays clash with light backgrounds.
155// - `bold`:   standard bold weight, no color shift.
156
157const ACCENT_COLOR: Color = Color::Ansi256(anstyle::Ansi256Color(71));
158const WARN_COLOR: Color = Color::Ansi256(anstyle::Ansi256Color(178));
159const ERROR_COLOR: Color = Color::Ansi256(anstyle::Ansi256Color(167));
160
161fn accent_style() -> Style {
162    Style::new().fg_color(Some(ACCENT_COLOR))
163}
164
165fn warn_style() -> Style {
166    Style::new().fg_color(Some(WARN_COLOR))
167}
168
169fn error_style() -> Style {
170    Style::new().fg_color(Some(ERROR_COLOR))
171}
172
173fn dim_style() -> Style {
174    Style::new().dimmed()
175}
176
177fn bold_style() -> Style {
178    Style::new().bold()
179}
180
181// =====================================================================
182// Helpers
183// =====================================================================
184//
185// All helpers return `String`. We could return `impl Display` to
186// avoid the allocation, but `Style` doesn't implement `Display` on its
187// own — it expects a wrapped payload — and the call-site ergonomics
188// (passing into `format!`/`println!`) are cleaner with a concrete
189// `String`. Cost is one heap allocation per styled fragment, which
190// is negligible against the syscall cost of writing to a terminal.
191
192fn paint(style: Style, s: &str) -> String {
193    if !color_enabled() {
194        return s.to_string();
195    }
196    format!("{}{}{}", style.render(), s, style.render_reset())
197}
198
199/// Success/positive/current — warm sage/green (ANSI 8-bit 71).
200pub fn accent(s: &str) -> String {
201    paint(accent_style(), s)
202}
203
204/// Mid-warning — warm amber (ANSI 8-bit 178).
205pub fn warn(s: &str) -> String {
206    paint(warn_style(), s)
207}
208
209/// Hard error — muted rust (ANSI 8-bit 167).
210pub fn error(s: &str) -> String {
211    paint(error_style(), s)
212}
213
214/// De-emphasis — used for IDs, timestamps, paths, and other text
215/// that's structurally important but shouldn't draw the eye.
216pub fn dim(s: &str) -> String {
217    paint(dim_style(), s)
218}
219
220/// Structural emphasis — intent text, headers, the principal name.
221pub fn bold(s: &str) -> String {
222    paint(bold_style(), s)
223}
224
225/// Section heading used for human output blocks.
226pub fn section(s: &str) -> String {
227    bold(s)
228}
229
230/// Small successful status marker. Keep the word short so it scans
231/// like a status glyph but still works in plain terminals.
232pub fn ok_marker() -> String {
233    accent("[ok]")
234}
235
236/// Small in-progress status marker.
237pub fn working_marker() -> String {
238    warn("[working]")
239}
240
241/// Small warning status marker.
242pub fn warn_marker() -> String {
243    warn("[warn]")
244}
245
246/// Small failure status marker.
247pub fn error_marker() -> String {
248    error("[error]")
249}
250
251/// Render a calm label/value row.
252pub fn field(label: &str, value: &str) -> String {
253    format!("{} {}", dim(&format!("{label}:")), value)
254}
255
256/// Render a compact count with the number emphasized.
257pub fn count(value: usize, noun: &str) -> String {
258    let suffix = if value == 1 { "" } else { "s" };
259    format!("{} {noun}{suffix}", bold(&value.to_string()))
260}
261
262/// Confidence band: maps the recorded numeric value to a semantic
263/// color. Render the formatted text yourself (e.g. via
264/// `format_confidence`) and pass it here; this keeps the formatting
265/// rule in `repo` and the styling rule here.
266pub fn confidence(value: Option<f32>, formatted: &str) -> String {
267    match value {
268        None => dim(formatted),
269        Some(v) if v >= 0.9 => accent(formatted),
270        Some(v) if v >= 0.75 => warn(formatted),
271        Some(_) => error(formatted),
272    }
273}
274
275/// Change-id styling: dim. We don't apply a monospace marker here —
276/// terminals already render text monospaced. The "dim+monospace"
277/// label in the spec was about *visual treatment*, which the
278/// terminal grants for free.
279pub fn state_id(id: &str) -> String {
280    dim(&human_text(id))
281}
282
283/// Keep opaque identities in machine output; terminal lines use short labels.
284pub fn human_text(text: &str) -> String {
285    let mut out = String::with_capacity(text.len());
286    let mut rest = text;
287    while let Some((start, end)) = verbs::machine_identity_span(rest) {
288        out.push_str(&rest[..start]);
289        let id = &rest[start..end];
290        if !["hs-", "hc-", "id-"]
291            .iter()
292            .any(|prefix| out.ends_with(prefix))
293        {
294            out.push_str(if id.len() >= 64 { "hs-" } else { "id-" });
295        }
296        out.push_str(&id[..8]);
297        rest = &rest[end..];
298    }
299    out.push_str(rest);
300    out
301}
302
303/// A thread's visible name, falling back to its task for opaque names.
304pub fn thread_label(name: &str, task: Option<&str>) -> String {
305    if !verbs::looks_like_machine_identity(name) {
306        return human_text(name);
307    }
308    task.filter(|task| !verbs::looks_like_machine_identity(task))
309        .map(human_text)
310        .unwrap_or_else(|| "Untitled thread".to_string())
311}
312
313/// Principal styling: name in bold, email dimmed. Returns the
314/// pre-composed `"Name <email>"` string so callers don't have to
315/// thread two fragments through `println!` arguments.
316pub fn principal(name: &str, email: &str) -> String {
317    if !color_enabled() {
318        return format!("{} <{}>", name, email);
319    }
320    format!("{} <{}>", bold(name), dim(email))
321}
322
323/// Thread-state styling: `active`/`ready`/`promoted` are accent;
324/// `merged`/`abandoned` are dim (historical, not current);
325/// `blocked`/`stale`/`draft` are warn. Unknown variants fall back
326/// to plain text. The matcher is case-insensitive against the
327/// `Display` form so callers can pass `state.to_string()` directly.
328pub fn thread_state(state: &str) -> String {
329    match state.to_ascii_lowercase().as_str() {
330        "active" | "ready" | "promoted" | "current" => accent(state),
331        "merged" | "abandoned" => dim(state),
332        "blocked" | "stale" | "draft" | "diverged" => warn(state),
333        _ => state.to_string(),
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use serial_test::serial;
340
341    use super::*;
342
343    /// All helpers must return ANSI-free strings when color is off.
344    /// Important: every render site relies on this — if the gate
345    /// regresses, escape codes leak into log files, JSON pipelines,
346    /// and test fixtures.
347    ///
348    /// Tests in this module touch a shared atomic (`COLOR_STATE`)
349    /// so we serialize them under a single name to keep one test's
350    /// `force_for_test` from racing another's read.
351    #[test]
352    #[serial(color_state)]
353    fn helpers_emit_no_ansi_when_disabled() {
354        force_for_test(false);
355        for s in [
356            accent("ok"),
357            warn("careful"),
358            error("boom"),
359            dim("hs-abc123"),
360            bold("Capture audit pipeline"),
361            confidence(Some(0.95), "0.95"),
362            confidence(None, "—"),
363            state_id("hs-abc123"),
364            principal("Ada Lovelace", "ada@analytical.engine"),
365            thread_state("active"),
366        ] {
367            assert!(!s.contains('\x1b'), "expected no ANSI escape in {:?}", s);
368        }
369    }
370
371    /// With color enabled, each helper emits an escape prefix.
372    #[test]
373    #[serial(color_state)]
374    fn helpers_emit_ansi_when_enabled() {
375        force_for_test(true);
376        for s in [
377            accent("ok"),
378            warn("careful"),
379            error("boom"),
380            dim("hs-abc123"),
381            bold("Capture audit pipeline"),
382            confidence(Some(0.95), "0.95"),
383            state_id("hs-abc123"),
384            principal("Ada Lovelace", "ada@analytical.engine"),
385            thread_state("active"),
386        ] {
387            assert!(s.contains('\x1b'), "expected ANSI escape in {:?}", s);
388        }
389    }
390
391    /// Unknown thread-state strings render plain — we don't want
392    /// to invent semantics for a state the matcher doesn't know.
393    #[test]
394    #[serial(color_state)]
395    fn thread_state_unknown_is_plain() {
396        force_for_test(true);
397        let out = thread_state("zorblax");
398        assert_eq!(out, "zorblax", "unknown state should not be styled");
399    }
400
401    /// Confidence bands map to the documented thresholds.
402    #[test]
403    #[serial(color_state)]
404    fn confidence_bands() {
405        force_for_test(true);
406        // None → dim
407        let none = confidence(None, "—");
408        assert!(
409            none.contains("\x1b[2m"),
410            "None should be dimmed: {:?}",
411            none
412        );
413
414        // ≥0.9 → accent (sage 71)
415        let high = confidence(Some(0.95), "0.95");
416        assert!(high.contains("38;5;71"), "high should be sage: {:?}", high);
417
418        // ≥0.75 and <0.9 → warn (amber 178)
419        let mid = confidence(Some(0.80), "0.80");
420        assert!(mid.contains("38;5;178"), "mid should be amber: {:?}", mid);
421
422        // <0.75 → error (rust 167)
423        let low = confidence(Some(0.50), "0.50");
424        assert!(low.contains("38;5;167"), "low should be rust: {:?}", low);
425    }
426
427    /// Decision logic: `--no-color` overrides every other signal,
428    /// `NO_COLOR` overrides `CLICOLOR_FORCE`, and TTY auto-detect
429    /// is the fallback.
430    #[test]
431    fn decision_no_color_flag_wins() {
432        let cli = test_cli(true);
433        let env = EnvProbe {
434            no_color: None,
435            clicolor_force: Some("1"),
436            is_tty: true,
437        };
438        assert!(!decide_color_enabled(&cli, &env));
439    }
440
441    #[test]
442    fn decision_no_color_env_overrides_force() {
443        let cli = test_cli(false);
444        let env = EnvProbe {
445            no_color: Some("1"),
446            clicolor_force: Some("1"),
447            is_tty: true,
448        };
449        assert!(
450            !decide_color_enabled(&cli, &env),
451            "NO_COLOR must beat CLICOLOR_FORCE per no-color.org precedence"
452        );
453    }
454
455    #[test]
456    fn decision_force_color_overrides_non_tty() {
457        let cli = test_cli(false);
458        let env = EnvProbe {
459            no_color: None,
460            clicolor_force: Some("1"),
461            is_tty: false,
462        };
463        assert!(decide_color_enabled(&cli, &env));
464    }
465
466    #[test]
467    fn decision_non_tty_default_off() {
468        let cli = test_cli(false);
469        let env = EnvProbe {
470            no_color: None,
471            clicolor_force: None,
472            is_tty: false,
473        };
474        assert!(!decide_color_enabled(&cli, &env));
475    }
476
477    #[test]
478    fn decision_tty_default_on() {
479        let cli = test_cli(false);
480        let env = EnvProbe {
481            no_color: None,
482            clicolor_force: None,
483            is_tty: true,
484        };
485        assert!(decide_color_enabled(&cli, &env));
486    }
487
488    /// Empty `NO_COLOR` is the documented opt-out — per
489    /// no-color.org, "the value of `NO_COLOR` is irrelevant if it's
490    /// non-empty"; an empty string is *not* a disable. We honour
491    /// that subtlety so users can `NO_COLOR= cargo run` to reset
492    /// without unsetting.
493    #[test]
494    fn decision_empty_no_color_is_not_disable() {
495        let cli = test_cli(false);
496        let env = EnvProbe {
497            no_color: Some(""),
498            clicolor_force: None,
499            is_tty: true,
500        };
501        assert!(decide_color_enabled(&cli, &env));
502    }
503
504    fn test_cli(no_color: bool) -> Cli {
505        // We can't easily construct `Cli` directly because it has a
506        // mandatory subcommand; route through clap's parser with a
507        // minimal valid argv. `--no-color` is a global flag so it
508        // lands regardless of which subcommand we pick.
509        use clap::Parser;
510        let mut argv = vec!["heddle".to_string()];
511        if no_color {
512            argv.push("--no-color".to_string());
513        }
514        argv.push("status".to_string());
515        Cli::try_parse_from(argv).expect("parse minimal cli")
516    }
517
518    /// Crucial: `principal()` with color off returns *exactly* the
519    /// same string the un-styled call site would have produced.
520    /// Render-site tests rely on this byte-for-byte equivalence.
521    #[test]
522    #[serial(color_state)]
523    fn principal_uncolored_is_identity() {
524        force_for_test(false);
525        let out = principal("Ada Lovelace", "ada@analytical.engine");
526        assert_eq!(out, "Ada Lovelace <ada@analytical.engine>");
527    }
528
529    #[test]
530    #[serial(color_state)]
531    fn state_id_uncolored_is_identity() {
532        force_for_test(false);
533        assert_eq!(state_id("hs-abc123"), "hs-abc123");
534    }
535
536    #[test]
537    fn embedded_machine_ids_are_shortened_in_human_text() {
538        let hex = "a".repeat(64);
539        let git_oid = "b".repeat(40);
540        let uuid = "12345678-1234-1234-1234-123456789abc";
541        let rendered = human_text(&format!(
542            "state=hs-{hex}; git={git_oid}; thread={uuid}; x-{uuid}"
543        ));
544        assert!(!rendered.contains(&hex), "{rendered}");
545        assert!(!rendered.contains(&git_oid), "{rendered}");
546        assert!(!rendered.contains(uuid), "{rendered}");
547        assert!(rendered.contains("state=hs-aaaaaaaa"), "{rendered}");
548    }
549}