acme_proxy/cli/style.rs
1//! Colour for the admin CLI's human-readable output.
2//!
3//! Hand-rolled and dependency-free, for [`crate::metrics`]'s reason: an SGR
4//! sequence is `\x1b[<n>m` and a reset, which is a `write!`, and every colour
5//! crate in the ecosystem brings either a global state cell or a second opinion
6//! about what a terminal is.
7//!
8//! **Nothing here is reachable from `--json`.** A [`Palette`] is threaded into
9//! the text renderers in [`crate::cli::render`] and nowhere else; the JSON
10//! branches print a `serde_json::Value` that never passes through this module,
11//! so machine-readable output stays byte-identical whatever the terminal is.
12//!
13//! Two rules worth not rediscovering:
14//!
15//! - **Pad first, then colour.** Every listing renderer builds fixed columns
16//! with `{:<12}`, and a format width counts *bytes* — padding an
17//! already-wrapped field counts the eight-odd bytes of escape and the column
18//! collapses. Call sites therefore read `palette.status(&format!("{:<11}",
19//! status))`, never the other way round.
20//! - **Precedence deliberately differs from `logging.ansi`.**
21//! [`crate::cli::logging`] documents that neither its switch nor `NO_COLOR`
22//! can turn colour *on* against the other, which is right for a configuration
23//! file — an ambient setting should not override an ambient veto. A
24//! `--color always` is neither ambient nor a setting: it was typed by the
25//! person reading the output, one command ago, and it beats both the TTY test
26//! and `NO_COLOR`. That is what makes piping into `less -R` work.
27
28/// When to colour human-readable output — the `--color` flag's values.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
30pub enum ColorChoice {
31 /// Colour when stdout is a terminal and `NO_COLOR` is unset.
32 #[default]
33 Auto,
34 /// Always colour, whatever the stream and whatever the environment says.
35 Always,
36 /// Never colour.
37 Never,
38}
39
40/// Whether `NO_COLOR` is set to something that counts.
41///
42/// The convention counts only a **non-empty** value, so a `${NO_COLOR:-}`-style
43/// shell default does not silently turn colour off everywhere. One definition,
44/// shared with [`crate::cli::logging`]'s `logging.ansi` handling, so the two
45/// answers cannot drift.
46pub(crate) fn no_color_set(value: Option<&str>) -> bool {
47 value.is_some_and(|value| !value.is_empty())
48}
49
50/// The four roles the CLI paints, and the SGR code each renders as.
51#[derive(Clone, Copy)]
52enum Role {
53 Good,
54 Bad,
55 Busy,
56 Unknown,
57}
58
59impl Role {
60 /// The SGR parameter, so the escape is built in exactly one place.
61 fn code(self) -> u8 {
62 match self {
63 Self::Good => 32,
64 Self::Bad => 31,
65 Self::Busy => 33,
66 // Magenta rather than a second yellow: the filter engine's third
67 // truth value is a distinct answer from "in progress", and
68 // `pass`/`fail`/`unknown` have to read as three words at a glance.
69 Self::Unknown => 35,
70 }
71 }
72}
73
74/// Whether colour is on, and the vocabulary for painting with it.
75///
76/// `Copy`, so it threads through the command tree as a value rather than a
77/// borrow — it is one `bool`.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub struct Palette {
80 on: bool,
81}
82
83impl Palette {
84 /// A palette that colours nothing.
85 ///
86 /// What every test renders against, and therefore what pins the plain
87 /// output byte-for-byte: with colour off every method here returns its
88 /// argument unchanged.
89 #[must_use]
90 pub const fn plain() -> Self {
91 Self { on: false }
92 }
93
94 /// The palette a run of the CLI gets, from the flag, the stream and the
95 /// environment.
96 ///
97 /// Pure in all three, so the precedence documented at the top of this
98 /// module is testable without a terminal or a process environment.
99 #[must_use]
100 pub fn resolve(choice: ColorChoice, is_terminal: bool, no_color: Option<&str>) -> Self {
101 let on = match choice {
102 ColorChoice::Never => false,
103 ColorChoice::Always => true,
104 ColorChoice::Auto => is_terminal && !no_color_set(no_color),
105 };
106 Self { on }
107 }
108
109 /// Whether this palette emits anything.
110 #[must_use]
111 pub const fn is_on(self) -> bool {
112 self.on
113 }
114
115 /// Wraps `text` in one role's escape, or hands it back untouched.
116 fn paint(self, role: Role, text: &str) -> String {
117 if self.on {
118 format!("\x1b[{}m{text}\x1b[0m", role.code())
119 } else {
120 text.to_string()
121 }
122 }
123
124 /// Something that worked, or a state an operator wants to see.
125 #[must_use]
126 pub fn ok(self, text: &str) -> String {
127 self.paint(Role::Good, text)
128 }
129
130 /// Something that failed, was refused, or was withdrawn.
131 #[must_use]
132 pub fn bad(self, text: &str) -> String {
133 self.paint(Role::Bad, text)
134 }
135
136 /// Advisory: nothing failed, but read this line.
137 ///
138 /// The `advisory` outcome of the logging convention, applied to output.
139 #[must_use]
140 pub fn warn(self, text: &str) -> String {
141 self.paint(Role::Busy, text)
142 }
143
144 /// Undecided — a question that was asked and not answered.
145 #[must_use]
146 pub fn unknown(self, text: &str) -> String {
147 self.paint(Role::Unknown, text)
148 }
149
150 /// A status word, by what it means rather than by which table it came from.
151 ///
152 /// One vocabulary covers accounts, orders, authorizations, challenges, EAB
153 /// credentials, admin users, sessions and TOTP state: the words do not
154 /// collide in meaning across those domains, and an operator scanning a
155 /// column is asking the same question of all of them.
156 ///
157 /// An unrecognised word renders **plain**. A status this build has never
158 /// heard of is exactly the case where a guessed colour would mislead — and
159 /// `AuditEntry::event` deliberately comes back as the string it was stored
160 /// as, so an older binary reading a newer database gets here.
161 ///
162 /// `off` counts as bad on purpose: the only place it appears is an
163 /// operator's second factor, and `admin.require_mfa` exists because that is
164 /// a state somebody wants to notice in a listing.
165 #[must_use]
166 pub fn status(self, text: &str) -> String {
167 // Matched on the trimmed word so a call site may hand over its padded
168 // column and still be understood -- but note the *padding* is what gets
169 // wrapped, which is the whole point (see the module doc).
170 let role = match text.trim() {
171 "valid" | "ready" | "active" | "enabled" | "success" | "on" | "allow" | "allowed"
172 | "pass" => Role::Good,
173 "invalid" | "revoked" | "deactivated" | "expired" | "disabled" | "off" | "failure"
174 | "deny" | "denied" | "fail" => Role::Bad,
175 "pending" | "processing" | "pending_mfa" => Role::Busy,
176 "unknown" | "undecided" => Role::Unknown,
177 _ => return text.to_string(),
178 };
179 self.paint(role, text)
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 /// The precedence table in full. The row that matters is `Always` beating
188 /// `NO_COLOR`: that is where this deliberately parts company with
189 /// `logging.ansi`, and without it there is no way to colour into a pager.
190 #[test]
191 fn resolve_covers_every_choice_against_the_stream_and_the_environment() {
192 for (choice, is_terminal, no_color, expected) in [
193 (ColorChoice::Auto, true, None, true),
194 (ColorChoice::Auto, false, None, false),
195 (ColorChoice::Auto, true, Some("1"), false),
196 (ColorChoice::Auto, true, Some("anything"), false),
197 // The convention counts only a non-empty value.
198 (ColorChoice::Auto, true, Some(""), true),
199 (ColorChoice::Always, false, None, true),
200 (ColorChoice::Always, false, Some("1"), true),
201 (ColorChoice::Always, true, Some("1"), true),
202 (ColorChoice::Never, true, None, false),
203 (ColorChoice::Never, true, Some(""), false),
204 ] {
205 assert_eq!(
206 Palette::resolve(choice, is_terminal, no_color).is_on(),
207 expected,
208 "{choice:?} tty={is_terminal} NO_COLOR={no_color:?}"
209 );
210 }
211 }
212
213 /// `Auto` is the default, so a bare `acme-proxy account list` behaves the
214 /// way every other tool does.
215 #[test]
216 fn auto_is_the_default_choice() {
217 assert_eq!(ColorChoice::default(), ColorChoice::Auto);
218 }
219
220 /// The invariant the whole design rests on: with colour off, nothing here
221 /// changes a single byte. Every plain-output assertion elsewhere in the
222 /// crate is only as good as this one.
223 #[test]
224 fn a_plain_palette_returns_its_argument_untouched() {
225 let plain = Palette::plain();
226 for text in ["valid", "invalid", "pending", "unknown", "", " ready "] {
227 assert_eq!(plain.ok(text), text);
228 assert_eq!(plain.bad(text), text);
229 assert_eq!(plain.warn(text), text);
230 assert_eq!(plain.unknown(text), text);
231 assert_eq!(plain.status(text), text);
232 }
233 }
234
235 #[test]
236 fn each_role_emits_its_own_escape() {
237 let colour = Palette::resolve(ColorChoice::Always, false, None);
238 assert_eq!(colour.ok("x"), "\x1b[32mx\x1b[0m");
239 assert_eq!(colour.bad("x"), "\x1b[31mx\x1b[0m");
240 assert_eq!(colour.warn("x"), "\x1b[33mx\x1b[0m");
241 assert_eq!(colour.unknown("x"), "\x1b[35mx\x1b[0m");
242 }
243
244 /// The vocabulary, by meaning rather than by source table.
245 #[test]
246 fn the_status_vocabulary_maps_every_domains_words() {
247 let colour = Palette::resolve(ColorChoice::Always, false, None);
248 for good in ["valid", "ready", "active", "enabled", "success", "on"] {
249 assert_eq!(colour.status(good), format!("\x1b[32m{good}\x1b[0m"));
250 }
251 for bad in [
252 "invalid",
253 "revoked",
254 "deactivated",
255 "expired",
256 "disabled",
257 "off",
258 "failure",
259 ] {
260 assert_eq!(colour.status(bad), format!("\x1b[31m{bad}\x1b[0m"));
261 }
262 for busy in ["pending", "processing", "pending_mfa"] {
263 assert_eq!(colour.status(busy), format!("\x1b[33m{busy}\x1b[0m"));
264 }
265 }
266
267 /// An unrecognised word is left alone rather than guessed at — the case an
268 /// older binary reading a newer database lands in.
269 #[test]
270 fn an_unrecognised_status_is_never_painted() {
271 let colour = Palette::resolve(ColorChoice::Always, false, None);
272 assert_eq!(colour.status("quiescent"), "quiescent");
273 assert!(!colour.status("quiescent").contains('\x1b'));
274 }
275
276 /// A padded column keeps its width: the escape goes *around* the padding,
277 /// so stripping it recovers exactly the plain rendering.
278 #[test]
279 fn painting_a_padded_column_preserves_its_width() {
280 let colour = Palette::resolve(ColorChoice::Always, false, None);
281 let padded = format!("{:<11}", "valid");
282 let painted = colour.status(&padded);
283 assert_eq!(painted, format!("\x1b[32m{padded}\x1b[0m"));
284 assert_eq!(
285 painted
286 .trim_start_matches("\x1b[32m")
287 .trim_end_matches("\x1b[0m"),
288 Palette::plain().status(&padded)
289 );
290 }
291}