1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
//! Shared terminal styling — ANSI palette + TTY / `NO_COLOR` gating —
//! used by every user-facing CLI surface (error reports, status
//! reports, future report-style output). Centralising the palette and
//! the TTY-detection logic keeps the look consistent across surfaces
//! and means a future "force colour" / theme toggle only has to change
//! one place.
use std::io::IsTerminal;
/// ANSI escape strings, picked once per call to [`Palette::for_stderr`]
/// / [`Palette::for_stdout`]. When colour is suppressed (stream is not
/// a TTY, or `NO_COLOR` is set) every field is `""` so format strings
/// stay valid but emit nothing.
pub struct Palette {
/// Red. Used for the `error:` head and the `●` stopped indicator.
pub red: &'static str,
/// Green. Used for the `●` running indicator.
pub green: &'static str,
/// Cyan. Used for the `info:` / `hint:` heads.
pub cyan: &'static str,
/// Bold. Used to lift the state word ("running" / "stopped") and
/// the `error:` head.
pub bold: &'static str,
/// Dim (faint). Used for secondary labels (`while:`, `pid`, …) and
/// the hint sentence at the bottom of a stopped-status report.
pub dim: &'static str,
/// Reset to default attributes. Emit after every styled run so the
/// next text doesn't inherit colour.
pub reset: &'static str,
}
impl Palette {
/// Full ANSI palette — emit on TTYs without `NO_COLOR`.
pub fn ansi() -> Self {
Self {
red: "\x1b[31m",
green: "\x1b[32m",
cyan: "\x1b[36m",
bold: "\x1b[1m",
dim: "\x1b[2m",
reset: "\x1b[0m",
}
}
/// Empty palette — every field is `""`. Used when stdout/stderr is
/// redirected, `NO_COLOR` is set, or in unit tests.
pub fn none() -> Self {
Self {
red: "",
green: "",
cyan: "",
bold: "",
dim: "",
reset: "",
}
}
/// Pick the palette appropriate for stderr (used by error/info
/// surfaces). Honours `NO_COLOR` (<https://no-color.org/>).
pub fn for_stderr() -> Self {
if no_color() || !std::io::stderr().is_terminal() {
Self::none()
} else {
Self::ansi()
}
}
/// Pick the palette appropriate for stdout (used by status / other
/// structured-report surfaces). Piping `bitrouter status > file`
/// strips colour automatically via [`IsTerminal`].
pub fn for_stdout() -> Self {
if no_color() || !std::io::stdout().is_terminal() {
Self::none()
} else {
Self::ansi()
}
}
}
/// True when `NO_COLOR` is set to a non-empty value. The variable's
/// presence (even empty) suggesting opt-out is the older draft of the
/// convention; the modern reading (no-color.org) is that an empty value
/// has no effect.
fn no_color() -> bool {
std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ansi_and_none_have_matching_field_shapes() {
let a = Palette::ansi();
let n = Palette::none();
// Every styled field has a non-empty escape under `ansi` and an
// empty string under `none` — that asymmetry is the whole point.
for (field_a, field_n) in [
(a.red, n.red),
(a.green, n.green),
(a.cyan, n.cyan),
(a.bold, n.bold),
(a.dim, n.dim),
(a.reset, n.reset),
] {
assert!(!field_a.is_empty(), "ansi field unexpectedly empty");
assert!(field_n.is_empty(), "none field unexpectedly populated");
}
}
}