Skip to main content

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}