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