headwater_check/paint.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 lives in `headwater-check` and not in `headwater-cli`
6//!
7//! [`Finding::render`](crate::finding::Finding::render) and
8//! [`Run::render`](crate::Run::render) are in this crate, and
9//! `headwater-query`'s `explain` and `headwater-sweep`'s `plan` and `intake`
10//! each depend on this crate already. `headwater-cli` depends on all three the
11//! other way, so a type or a function every one of them needs to color a
12//! finding, a path or a heading can only live where every one of them can
13//! reach it without a cycle — here, beside [`crate::fill`], which the same
14//! four crates already share for the one fold this engine has.
15//!
16//! `engine/crates/cli/src/paint.rs` re-exports everything below under
17//! `headwater_cli::paint::*`, so a caller who wrote `paint::Role::Error` before
18//! this module existed still compiles unchanged, and `main.rs` stays the one
19//! place that decides *whether* a stream renders color at all —
20//! [`stdout_color`](../../../../engine/crates/cli/src/paint.rs) and its
21//! `stderr` twin stay in `headwater-cli`, because the terminal a process is
22//! attached to is a fact about the binary, not about a corpus.
23//!
24//! # `ColorMode` is a parameter, never a read
25//!
26//! Every function here is pure: given the same `Role` or the same
27//! [`Severity`](crate::Severity) and the same [`ColorMode`], it returns the
28//! same bytes. Nothing in this module opens a stream or reads an environment
29//! variable, which is what makes `Finding::render`, `Run::render`, `explain`'s
30//! own renderer and the two sweep renderers unit-testable with a `Case` table
31//! and no real terminal, the pattern `paint::color_of`'s own tests already set
32//! for `--wide`/`COLUMNS`.
33//!
34//! # `Plain` writes no escape sequence, ever
35//!
36//! [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)
37//! is explicit that the fallback is "no escape sequence at all", and
38//! `NO_COLOR_TEXT` (`engine/crates/cli/src/lib.rs`) promises the same thing to
39//! every caller of `--no-color`. So [`paint`] and [`dim`] write `text` back
40//! unchanged under [`ColorMode::Plain`], the same as before this module
41//! existed. [`glyph`] is the other half of the fallback: a literal character,
42//! never wrapped in an escape sequence, that a caller prints beside a severity
43//! word so the distinction survives even where hue cannot carry it.
44
45use crate::Severity;
46
47/// Whether a stream renders the palette below, or its plain-text fallback.
48///
49/// Moved here from `headwater-cli` unchanged — see the module comment for why
50/// the crate moved and `engine/crates/cli/src/paint.rs` for the re-export that
51/// keeps every existing caller compiling.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ColorMode {
54 Ansi,
55 Plain,
56}
57
58/// The mode `--no-color`, `NO_COLOR` and a stream's own terminal state come
59/// to, decided once so every caller reads the same answer the same way.
60///
61/// `--no-color` or a set `NO_COLOR` forces [`ColorMode::Plain`] regardless of
62/// `is_terminal`. There is no third state: a caller who wants color forced
63/// into a pipe has no lever here.
64#[must_use]
65pub fn color_of(no_color_flag: bool, no_color_env: bool, is_terminal: bool) -> ColorMode {
66 if no_color_flag || no_color_env {
67 return ColorMode::Plain;
68 }
69 match is_terminal {
70 true => ColorMode::Ansi,
71 false => ColorMode::Plain,
72 }
73}
74
75/// One semantic role [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)'s
76/// palette names.
77///
78/// `Verb` carries two rows of the decision's table at once — "a verb name or a
79/// `fix:` label" is one role, green and bold, so a second variant for the
80/// second noun would be a distinction the palette itself does not draw.
81/// `Heading` and structural or already-stated text are the two rows with no
82/// hue at all — a heading is bold in the default color and the other is dim in
83/// it — so the second reaches no variant here: [`dim`] is a sibling function
84/// rather than a seventh role, the same shape `engine/crates/cli/src/paint.rs`
85/// already set before this module existed.
86#[derive(Debug, Clone, Copy)]
87pub enum Role {
88 Error,
89 Warn,
90 Info,
91 /// A file path or a flag name.
92 Path,
93 /// A verb name or a `fix:` label.
94 Verb,
95 /// An obligation or adoption-task identifier (`OB-…`, `AD-…`).
96 Obligation,
97 /// A section heading.
98 Heading,
99}
100
101/// The role a severity renders under, so a caller never hand-maps the three
102/// [`Severity`] variants onto [`Role`] a second time.
103#[must_use]
104pub fn severity_role(severity: Severity) -> Role {
105 match severity {
106 Severity::Error => Role::Error,
107 Severity::Warn => Role::Warn,
108 Severity::Info => Role::Info,
109 }
110}
111
112/// `text`, painted for `role` under `mode`.
113///
114/// `Ansi` writes the standard SGR codes the decision names, which are
115/// remapped by whatever theme the caller's terminal already runs — the reason
116/// the decision refuses a truecolor hex. `Plain` writes `text` back unchanged:
117/// no escape sequence, on the rule the module comment states.
118#[must_use]
119pub fn paint(role: Role, text: &str, mode: ColorMode) -> String {
120 let (open, close) = match (role, mode) {
121 (Role::Error, ColorMode::Ansi) => ("\x1b[1;31m", "\x1b[0m"),
122 (Role::Warn, ColorMode::Ansi) => ("\x1b[1;33m", "\x1b[0m"),
123 (Role::Info, ColorMode::Ansi) => ("\x1b[34m", "\x1b[0m"),
124 (Role::Path, ColorMode::Ansi) => ("\x1b[36m", "\x1b[0m"),
125 (Role::Verb, ColorMode::Ansi) => ("\x1b[1;32m", "\x1b[0m"),
126 (Role::Obligation, ColorMode::Ansi) => ("\x1b[35m", "\x1b[0m"),
127 (Role::Heading, ColorMode::Ansi) => ("\x1b[1m", "\x1b[0m"),
128 (_, ColorMode::Plain) => ("", ""),
129 };
130 format!("{open}{text}{close}")
131}
132
133/// Dim weight, the one part of the `Plain` fallback that is not color.
134#[must_use]
135pub fn dim(text: &str, mode: ColorMode) -> String {
136 match mode {
137 ColorMode::Ansi => format!("\x1b[2m{text}\x1b[0m"),
138 ColorMode::Plain => text.to_string(),
139 }
140}
141
142/// The literal glyph a severity prints beside its word under [`ColorMode::Plain`].
143///
144/// `✗`, `▲` and `·`, in [`Severity`]'s own order. Never wrapped in an escape
145/// sequence: the character alone is the whole of what carries the
146/// distinction where hue cannot.
147#[must_use]
148pub fn glyph(severity: Severity) -> &'static str {
149 match severity {
150 Severity::Error => "✗",
151 Severity::Warn => "▲",
152 Severity::Info => "·",
153 }
154}
155
156/// A severity word, in the shape every renderer prints it: colored under
157/// `Ansi`, and a glyph beside the bare word under `Plain`.
158///
159/// One function rather than a `paint`/`glyph` pair at every call site, because
160/// [`Finding::render`](crate::finding::Finding::render) and
161/// [`crate::Run::render`]'s severity counts both need exactly this pairing and
162/// a third copy of the pairing is the drift this module exists to refuse.
163#[must_use]
164pub fn severity_word(severity: Severity, mode: ColorMode) -> String {
165 let word = severity.to_string();
166 match mode {
167 ColorMode::Ansi => paint(severity_role(severity), &word, mode),
168 ColorMode::Plain => format!("{} {word}", glyph(severity)),
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::{color_of, dim, glyph, paint, severity_word, ColorMode, Role};
175 use crate::Severity;
176
177 #[test]
178 fn color_is_plain_off_a_terminal_and_ansi_on_one_unless_overridden() {
179 assert_eq!(color_of(false, false, false), ColorMode::Plain);
180 assert_eq!(color_of(false, false, true), ColorMode::Ansi);
181 assert_eq!(
182 color_of(true, false, true),
183 ColorMode::Plain,
184 "--no-color wins"
185 );
186 assert_eq!(
187 color_of(false, true, true),
188 ColorMode::Plain,
189 "NO_COLOR wins"
190 );
191 assert_eq!(color_of(true, true, false), ColorMode::Plain);
192 }
193
194 /// `Plain` never writes an escape sequence, for every role.
195 #[test]
196 fn plain_writes_no_escape_sequence_for_any_role() {
197 for role in [
198 Role::Error,
199 Role::Warn,
200 Role::Info,
201 Role::Path,
202 Role::Verb,
203 Role::Obligation,
204 Role::Heading,
205 ] {
206 assert_eq!(paint(role, "text", ColorMode::Plain), "text");
207 }
208 assert_eq!(dim("text", ColorMode::Plain), "text");
209 }
210
211 /// `Ansi` wraps the text in an SGR pair that closes with a reset, and
212 /// changes no byte of the text itself.
213 #[test]
214 fn ansi_wraps_every_role_in_an_opening_and_a_reset() {
215 for role in [
216 Role::Error,
217 Role::Warn,
218 Role::Info,
219 Role::Path,
220 Role::Verb,
221 Role::Obligation,
222 Role::Heading,
223 ] {
224 let written = paint(role, "text", ColorMode::Ansi);
225 assert!(written.starts_with("\x1b["), "{written:?}");
226 assert!(written.ends_with("\x1b[0m"), "{written:?}");
227 assert!(written.contains("text"), "{written:?}");
228 }
229 let dimmed = dim("text", ColorMode::Ansi);
230 assert_eq!(dimmed, "\x1b[2mtext\x1b[0m");
231 }
232
233 /// The three glyphs are distinct, so a reader who cannot see color still
234 /// tells the three severities apart.
235 #[test]
236 fn every_severity_has_its_own_glyph() {
237 let glyphs = [
238 glyph(Severity::Error),
239 glyph(Severity::Warn),
240 glyph(Severity::Info),
241 ];
242 assert_eq!(glyphs, ["✗", "▲", "·"]);
243 }
244
245 /// Under `Plain` the severity word carries its glyph and no escape
246 /// sequence. Under `Ansi` it carries color and no glyph — the glyph is the
247 /// fallback for where hue cannot render, not a second signal on top of it.
248 #[test]
249 fn a_severity_word_carries_a_glyph_in_plain_and_color_in_ansi() {
250 let plain = severity_word(Severity::Error, ColorMode::Plain);
251 assert_eq!(plain, "✗ error");
252 assert!(!plain.contains('\x1b'));
253
254 let ansi = severity_word(Severity::Error, ColorMode::Ansi);
255 assert!(ansi.contains("error"), "{ansi:?}");
256 assert!(ansi.starts_with("\x1b["), "{ansi:?}");
257 assert!(!ansi.contains('✗'), "{ansi:?}");
258 }
259}