headwater_check/paint.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The palette of [`headwater_paint`], re-exported, plus the three functions
3//! that read a [`Severity`] and so could not move down with it.
4//!
5//! # Where the primitives went, and why they left
6//!
7//! [`ColorMode`], [`Role`], [`ROLES`], [`color_of`], [`paint`] and [`dim`]
8//! lived in this file until #479. They sat here, beside [`crate::fill`],
9//! because every renderer that wanted color sat above this crate:
10//! [`Finding::render`](crate::finding::Finding::render) and
11//! [`Run::render`](crate::Run::render) are here, and `headwater-query`'s
12//! `explain` and `headwater-sweep`'s `plan` and `intake` all depend on this
13//! crate already.
14//!
15//! That reasoning was sound and it had an edge nobody had read. This crate
16//! depends on `headwater-census`, `headwater-resolve` and `headwater-lock`, so
17//! a renderer inside any of those three could never name this module: cargo
18//! refuses the cycle. Six of the eleven command lines whose interface contract
19//! promises terminal sensing render in exactly those crates, starting with
20//! `headwater derived`. So the primitives moved to `headwater-paint`, a leaf
21//! with no dependencies, and this module re-exports them under the paths they
22//! already had. No import anywhere in this workspace moved.
23//!
24//! # What stayed
25//!
26//! [`glyph`], [`severity_role`] and [`severity_word`] read [`Severity`], which
27//! is a type of the check layer. Moving them would have moved `Severity` into
28//! a crate about a terminal palette, which is the swap the move was avoiding
29//! in the other direction.
30//!
31//! `engine/crates/cli/src/paint.rs` re-exports this module in turn, so
32//! `headwater_cli::paint::*` still answers, and `main.rs` stays the one place
33//! that decides *whether* a stream renders color at all — `stdout_color` and
34//! its `stderr` twin stay in `headwater-cli`, because the terminal a process
35//! is attached to is a fact about the binary rather than about a corpus.
36//!
37//! # `Plain` writes no escape sequence, ever
38//!
39//! [`glyph`] is the half of that fallback this module still owns: a literal
40//! character, never wrapped in an escape sequence, that a caller prints beside
41//! a severity word so the distinction survives where hue cannot carry it.
42
43use crate::Severity;
44
45pub use headwater_paint::{color_of, dim, paint, ColorMode, Role, ROLES};
46
47/// The role a severity renders under, so a caller never hand-maps the three
48/// [`Severity`] variants onto [`Role`] a second time.
49#[must_use]
50pub fn severity_role(severity: Severity) -> Role {
51 match severity {
52 Severity::Error => Role::Error,
53 Severity::Warn => Role::Warn,
54 Severity::Info => Role::Info,
55 }
56}
57
58/// The literal glyph a severity prints beside its word under [`ColorMode::Plain`].
59///
60/// `✗`, `▲` and `·`, in [`Severity`]'s own order. Never wrapped in an escape
61/// sequence: the character alone is the whole of what carries the
62/// distinction where hue cannot.
63#[must_use]
64pub fn glyph(severity: Severity) -> &'static str {
65 match severity {
66 Severity::Error => "✗",
67 Severity::Warn => "▲",
68 Severity::Info => "·",
69 }
70}
71
72/// A severity word, in the shape every renderer prints it: colored under
73/// `Ansi`, and a glyph beside the bare word under `Plain`.
74///
75/// One function rather than a `paint`/`glyph` pair at every call site, because
76/// [`Finding::render`](crate::finding::Finding::render) and
77/// [`crate::Run::render`]'s severity counts both need exactly this pairing and
78/// a third copy of the pairing is the drift this module exists to refuse.
79#[must_use]
80pub fn severity_word(severity: Severity, mode: ColorMode) -> String {
81 let word = severity.to_string();
82 match mode {
83 ColorMode::Ansi => paint(severity_role(severity), &word, mode),
84 ColorMode::Plain => format!("{} {word}", glyph(severity)),
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::{glyph, paint, severity_word, ColorMode};
91 use crate::Severity;
92
93 /// The three glyphs are distinct, so a reader who cannot see color still
94 /// tells the three severities apart.
95 #[test]
96 fn every_severity_has_its_own_glyph() {
97 let glyphs = [
98 glyph(Severity::Error),
99 glyph(Severity::Warn),
100 glyph(Severity::Info),
101 ];
102 assert_eq!(glyphs, ["✗", "▲", "·"]);
103 }
104
105 /// Under `Plain` the severity word carries its glyph and no escape
106 /// sequence. Under `Ansi` it carries color and no glyph — the glyph is the
107 /// fallback for where hue cannot render, not a second signal on top of it.
108 #[test]
109 fn a_severity_word_carries_a_glyph_in_plain_and_color_in_ansi() {
110 let plain = severity_word(Severity::Error, ColorMode::Plain);
111 assert_eq!(plain, "✗ error");
112 assert!(!plain.contains('\x1b'));
113
114 let ansi = severity_word(Severity::Error, ColorMode::Ansi);
115 assert!(ansi.contains("error"), "{ansi:?}");
116 assert!(ansi.starts_with("\x1b["), "{ansi:?}");
117 assert!(!ansi.contains('✗'), "{ansi:?}");
118 }
119
120 /// The re-export answers under the path every caller in this workspace
121 /// already wrote, so the move below this crate is invisible to them.
122 ///
123 /// Without this case the move is held only by whatever else happens to
124 /// import `headwater_check::paint`, which is the silent-success shape: a
125 /// re-export deleted by hand would fail to compile in some other crate and
126 /// name a file nobody was reading.
127 #[test]
128 fn the_moved_primitives_still_answer_under_this_module() {
129 assert_eq!(
130 paint(super::Role::Path, "docs/spec/05.md", ColorMode::Plain),
131 "docs/spec/05.md"
132 );
133 for role in super::ROLES {
134 assert_eq!(paint(role, "text", ColorMode::Plain), "text");
135 }
136 assert_eq!(super::color_of(false, false, true), ColorMode::Ansi);
137 assert_eq!(super::dim("text", ColorMode::Plain), "text");
138 }
139}