Skip to main content

headwater_paint/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The palette [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)
3//! rules on, and the pure functions every renderer applies it through.
4//!
5//! # Why this is a leaf crate and not a module of `headwater-check`
6//!
7//! These primitives lived in `headwater-check` until #479, beside
8//! `headwater_check::fill`, because the four crates that needed them then —
9//! `headwater-check` itself, `headwater-query`, `headwater-sweep` and
10//! `headwater-cli` — all sit above that crate and reach it without a cycle.
11//! That placement held for as long as every renderer that wanted color sat
12//! above `headwater-check`, and it stopped holding the moment one did not.
13//!
14//! `headwater-check` depends on `headwater-census`, `headwater-resolve` and
15//! `headwater-lock`. A renderer inside any of those three cannot name
16//! `headwater_check::paint` at all: cargo refuses the cycle before a line
17//! compiles. Six of the eleven command lines whose interface contract still
18//! promises terminal sensing render inside exactly those three crates —
19//! `headwater derived` in `headwater-census`, and `taxonomy validate`,
20//! `resolve`, `publish`, `vendor` and `migrate` in `headwater-resolve` and
21//! `headwater-lock`. Wiring any of them was not expensive, it was impossible,
22//! and nothing recorded that until the dependency graph was read against
23//! [HW-OBL-0180](../../../../docs/obligations/0180-a-renderer-s-color-mode-is-wired-at-a-call-site-that-no-type-forbids-from-being-wrong.md).
24//!
25//! So the palette moved to the bottom of the graph, where every renderer of
26//! this engine reaches it. This crate depends on nothing, for the reason
27//! `headwater-hash` and `headwater-mark` depend on nothing: a rule that two
28//! components on opposite sides of the engine both read must have one
29//! implementation, and a second copy of a palette is two palettes.
30//!
31//! # What stayed in `headwater-check`, and why
32//!
33//! `Severity` is a type of the check layer, so `glyph`, `severity_role` and
34//! `severity_word` stayed with it rather than dragging `Severity` down here
35//! behind them. `headwater_check::paint` re-exports everything below
36//! unchanged, so every caller that wrote `headwater_check::paint::Role` before
37//! the move still compiles — the same re-export chain
38//! `engine/crates/cli/src/paint.rs` already ran one level up.
39//!
40//! Deciding *whether* a stream renders color stays in `headwater-cli`:
41//! `stdout_color` and its `stderr` twin read the terminal a process is
42//! attached to, which is a fact about the binary rather than about a corpus.
43//!
44//! # `ColorMode` is a parameter, never a read
45//!
46//! Every function here is pure: given the same [`Role`] and the same
47//! [`ColorMode`], it returns the same bytes. Nothing here opens a stream or
48//! reads an environment variable, which is what makes every renderer that
49//! takes a mode unit-testable with a case table and no real terminal.
50//!
51//! That purity is also the defect [HW-OBL-0180](../../../../docs/obligations/0180-a-renderer-s-color-mode-is-wired-at-a-call-site-that-no-type-forbids-from-being-wrong.md)
52//! records: a renderer threaded with a mode and a call site that hands it
53//! [`ColorMode::Plain`] forever passes every headless test there is. What
54//! catches that is `tools/engine/color-fixtures.sh`, which attaches a
55//! pseudo-terminal and counts escape bytes, and nothing else in this
56//! repository can.
57//!
58//! # `Plain` writes no escape sequence, ever
59//!
60//! [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)
61//! is explicit that the fallback is "no escape sequence at all", and
62//! `NO_COLOR_TEXT` (`engine/crates/cli/src/lib.rs`) promises the same thing to
63//! every caller of `--no-color`. So [`paint`] and [`dim`] write `text` back
64//! unchanged under [`ColorMode::Plain`].
65
66/// Whether a stream renders the palette below, or its plain-text fallback.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ColorMode {
69    Ansi,
70    Plain,
71}
72
73/// The mode `--no-color`, `NO_COLOR` and a stream's own terminal state come
74/// to, decided once so every caller reads the same answer the same way.
75///
76/// `--no-color` or a set `NO_COLOR` forces [`ColorMode::Plain`] regardless of
77/// `is_terminal`. There is no third state: a caller who wants color forced
78/// into a pipe has no lever here.
79#[must_use]
80pub fn color_of(no_color_flag: bool, no_color_env: bool, is_terminal: bool) -> ColorMode {
81    if no_color_flag || no_color_env {
82        return ColorMode::Plain;
83    }
84    match is_terminal {
85        true => ColorMode::Ansi,
86        false => ColorMode::Plain,
87    }
88}
89
90/// One semantic role [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)'s
91/// palette names.
92///
93/// `Verb` carries two rows of the decision's table at once — "a verb name or a
94/// `fix:` label" is one role, green and bold, so a second variant for the
95/// second noun would be a distinction the palette itself does not draw.
96/// `Heading` and structural or already-stated text are the two rows with no
97/// hue at all — a heading is bold in the default color and the other is dim in
98/// it — so the second reaches no variant here: [`dim`] is a sibling function
99/// rather than a seventh role.
100#[derive(Debug, Clone, Copy)]
101pub enum Role {
102    Error,
103    Warn,
104    Info,
105    /// A file path or a flag name.
106    Path,
107    /// A verb name or a `fix:` label.
108    Verb,
109    /// An obligation or adoption-task identifier (`OB-…`, `AD-…`).
110    Obligation,
111    /// A section heading.
112    Heading,
113}
114
115/// Every [`Role`], for a case table that would otherwise hand-keep its own copy
116/// of the variants.
117///
118/// [HW-OBL-0172](../../../../docs/obligations/0172-nine-hand-kept-constants-enumerate-an-enum-and-nothing-holds-one-against-the-variants.md)
119/// records the shape this is: a hand-typed array whose length is part of its
120/// type, so a new variant leaves it short and compiles. It ships discharged
121/// under that record's own Discharge clause — `roles_carries_every_variant_once`
122/// maps this array through an exhaustive `match` on [`Role`], so a variant
123/// added to the enum and left out of here stops the crate compiling. One held
124/// list replaces the two unheld ones the tests below used to type by hand, and
125/// a renderer's color case table reads this rather than growing a third.
126pub const ROLES: [Role; 7] = [
127    Role::Error,
128    Role::Warn,
129    Role::Info,
130    Role::Path,
131    Role::Verb,
132    Role::Obligation,
133    Role::Heading,
134];
135
136/// `text`, painted for `role` under `mode`.
137///
138/// `Ansi` writes the standard SGR codes the decision names, which are
139/// remapped by whatever theme the caller's terminal already runs — the reason
140/// the decision refuses a truecolor hex. `Plain` writes `text` back unchanged:
141/// no escape sequence, on the rule the module comment states.
142#[must_use]
143pub fn paint(role: Role, text: &str, mode: ColorMode) -> String {
144    let (open, close) = match (role, mode) {
145        (Role::Error, ColorMode::Ansi) => ("\x1b[1;31m", "\x1b[0m"),
146        (Role::Warn, ColorMode::Ansi) => ("\x1b[1;33m", "\x1b[0m"),
147        (Role::Info, ColorMode::Ansi) => ("\x1b[34m", "\x1b[0m"),
148        (Role::Path, ColorMode::Ansi) => ("\x1b[36m", "\x1b[0m"),
149        (Role::Verb, ColorMode::Ansi) => ("\x1b[1;32m", "\x1b[0m"),
150        (Role::Obligation, ColorMode::Ansi) => ("\x1b[35m", "\x1b[0m"),
151        (Role::Heading, ColorMode::Ansi) => ("\x1b[1m", "\x1b[0m"),
152        (_, ColorMode::Plain) => ("", ""),
153    };
154    format!("{open}{text}{close}")
155}
156
157/// Dim weight, the one part of the `Plain` fallback that is not color.
158#[must_use]
159pub fn dim(text: &str, mode: ColorMode) -> String {
160    match mode {
161        ColorMode::Ansi => format!("\x1b[2m{text}\x1b[0m"),
162        ColorMode::Plain => text.to_string(),
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::{color_of, dim, paint, ColorMode, Role, ROLES};
169
170    #[test]
171    fn color_is_plain_off_a_terminal_and_ansi_on_one_unless_overridden() {
172        assert_eq!(color_of(false, false, false), ColorMode::Plain);
173        assert_eq!(color_of(false, false, true), ColorMode::Ansi);
174        assert_eq!(
175            color_of(true, false, true),
176            ColorMode::Plain,
177            "--no-color wins"
178        );
179        assert_eq!(
180            color_of(false, true, true),
181            ColorMode::Plain,
182            "NO_COLOR wins"
183        );
184        assert_eq!(color_of(true, true, false), ColorMode::Plain);
185    }
186
187    /// `Plain` never writes an escape sequence, for every role.
188    #[test]
189    fn plain_writes_no_escape_sequence_for_any_role() {
190        for role in ROLES {
191            assert_eq!(paint(role, "text", ColorMode::Plain), "text");
192        }
193        assert_eq!(dim("text", ColorMode::Plain), "text");
194    }
195
196    /// `Ansi` wraps the text in an SGR pair that closes with a reset, and
197    /// changes no byte of the text itself.
198    #[test]
199    fn ansi_wraps_every_role_in_an_opening_and_a_reset() {
200        for role in ROLES {
201            let written = paint(role, "text", ColorMode::Ansi);
202            assert!(written.starts_with("\x1b["), "{written:?}");
203            assert!(written.ends_with("\x1b[0m"), "{written:?}");
204            assert!(written.contains("text"), "{written:?}");
205        }
206        let dimmed = dim("text", ColorMode::Ansi);
207        assert_eq!(dimmed, "\x1b[2mtext\x1b[0m");
208    }
209
210    /// [`ROLES`] carries every variant of [`Role`], once each.
211    ///
212    /// The `match` below is the discharge HW-OBL-0172 names, and it works at
213    /// compile time rather than here: a variant added to [`Role`] leaves this
214    /// `match` non-exhaustive and the crate stops building. What this case
215    /// itself adds is the other half — that no variant is written twice and
216    /// none is silently dropped for a duplicate.
217    #[test]
218    fn roles_carries_every_variant_once() {
219        let mut seen = [false; ROLES.len()];
220        for role in ROLES {
221            let index = match role {
222                Role::Error => 0,
223                Role::Warn => 1,
224                Role::Info => 2,
225                Role::Path => 3,
226                Role::Verb => 4,
227                Role::Obligation => 5,
228                Role::Heading => 6,
229            };
230            assert!(!seen[index], "{role:?} appears twice in ROLES");
231            seen[index] = true;
232        }
233        assert!(
234            seen.iter().all(|one| *one),
235            "ROLES misses a variant of Role: {seen:?}"
236        );
237    }
238}