amont_runtime/ui.rs
1//! The signs the hooks print.
2//!
3//! **Colour is the terminal's decision, not ours.** These used to be 256-colour
4//! codes — `38;5;112`, `38;5;160`, `38;5;208` — which live in the fixed xterm
5//! cube. A terminal theme remaps only indices 0–15; anything above renders
6//! identically whatever palette the user chose. So a carefully themed terminal
7//! was being overridden by three numbers picked years ago.
8//!
9//! The base ANSI colours ARE theme-controlled, so `32` means "whatever this
10//! terminal calls green". That is the whole fix: output follows a `vivid`
11//! palette, a Solarized profile or a high-contrast one, without reading any
12//! configuration.
13//!
14//! `LS_COLORS` is deliberately NOT consulted. It maps FILE TYPES — `di`, `ex`,
15//! `mi` — while this output needs ok/warning/error. There is no honest
16//! correspondence, and the nearest candidates are worse than nothing: under
17//! `vivid lava`, `mi` and `or` are dark grey on red, which as a foreground for
18//! `✗` is close to invisible.
19//!
20//! The earlier note here said the signs were "kept byte-identical" with the zsh
21//! originals so a user could not tell which implementation ran. That mattered
22//! during the migration and no longer does — no zsh hook remains to match.
23
24use std::sync::OnceLock;
25
26/// Base ANSI, deliberately. See the module docs.
27const GREEN: &str = "32";
28const RED: &str = "31";
29const YELLOW: &str = "33";
30
31/// Does the caller want colour at all?
32///
33/// `NO_COLOR` per no-color.org: present and NON-EMPTY disables it, whatever the
34/// value. `TERM=dumb` is a terminal that cannot render SGR.
35///
36/// Read once — a hook is a short-lived process and its environment does not
37/// change underneath it.
38pub fn colors_enabled() -> bool {
39 static ENABLED: OnceLock<bool> = OnceLock::new();
40 *ENABLED.get_or_init(|| {
41 let no_color = std::env::var_os("NO_COLOR")
42 .map(|v| !v.is_empty())
43 .unwrap_or(false);
44 let dumb = std::env::var("TERM").map(|t| t == "dumb").unwrap_or(false);
45 !no_color && !dumb
46 })
47}
48
49fn paint(text: &str, sgr: &str) -> String {
50 if colors_enabled() {
51 format!("\u{1b}[{sgr}m{text}\u{1b}[0m")
52 } else {
53 text.to_string()
54 }
55}
56
57fn sign(glyph: &str, sgr: &str) -> String {
58 format!(" {}", paint(glyph, sgr))
59}
60
61/// The glyph carries the meaning and the colour only reinforces it, so under
62/// `NO_COLOR` — and for the ~8% of men with red-green colour vision deficiency
63/// — `✓ ✗ !` stay distinguishable on their own.
64pub fn valid_sign() -> &'static str {
65 static S: OnceLock<String> = OnceLock::new();
66 S.get_or_init(|| sign("✓", GREEN))
67}
68
69pub fn error_sign() -> &'static str {
70 static S: OnceLock<String> = OnceLock::new();
71 S.get_or_init(|| sign("✗", RED))
72}
73
74pub fn warning_sign() -> &'static str {
75 static S: OnceLock<String> = OnceLock::new();
76 S.get_or_init(|| sign("!", YELLOW))
77}
78
79/// Emphasise a fragment inside a message, in the terminal's own accent.
80///
81/// Sanitises what it is given, because most of what is highlighted came from
82/// somewhere else: a declared check's name, a program it wants to run, a term
83/// matched in a staged file. An escape sequence inside would also break this
84/// function's own painting — the reset it emits is no longer the last word —
85/// so this is as much about the colouring being correct as about the text
86/// being safe.
87pub fn highlight(text: &str) -> String {
88 paint(&sanitize(text), YELLOW)
89}
90
91/// Text from a repository, made safe to hand a terminal.
92///
93/// A name or a command in `amont.conf` is chosen by whoever wrote the
94/// repository, and the trust prompt exists precisely so a person can read the
95/// declarations before accepting them. `\x1b[8m` is the conceal attribute:
96/// putting it in one declaration's name HID THE NEXT ONE from that prompt, so
97/// the reader saw two declarations, pressed y, and got three. The trust model
98/// was never bypassed — the rendering lied about what was being trusted, which
99/// is the same outcome by a shorter route.
100///
101/// What is escaped, and why each:
102/// * C0 (`< 0x20`) — ESC starts every sequence; CR redraws the line; BEL,
103/// backspace and the rest are all display control. TAB becomes a single
104/// space instead, because these strings sit in aligned columns and a real
105/// tab would break the layout it is trying to preserve.
106/// * DEL and C1 (`0x80..=0x9f`) — a terminal in 8-bit mode takes `0x9b` as
107/// CSI directly, with no ESC in sight.
108/// * The bidi overrides (`U+202A..=202E`, `U+2066..=2069`) and the
109/// directional marks — "Trojan Source": they reorder what is displayed
110/// without changing a byte of what is parsed.
111///
112/// Everything else passes through untouched, including all other UTF-8 and
113/// emoji: this is a display guard, not a charset policy.
114pub fn sanitize(text: &str) -> String {
115 let mut out = String::with_capacity(text.len());
116 for c in text.chars() {
117 match c {
118 '\t' => out.push(' '),
119 c if (c as u32) < 0x20 || c == '\u{7f}' => {
120 out.push_str(&format!("\\x{:02x}", c as u32));
121 }
122 c if ('\u{80}'..='\u{9f}').contains(&c) => {
123 out.push_str(&format!("\\x{:02x}", c as u32));
124 }
125 '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' => {
126 out.push_str(&format!("\\u{{{:04x}}}", c as u32));
127 }
128 c => out.push(c),
129 }
130 }
131 out
132}
133
134/// The same, for a path that came off a walk of somebody's disk.
135///
136/// `Path::display()` does not escape control bytes, and the fleet prints paths
137/// it found by scanning directories it does not own.
138pub fn sanitize_path(p: &std::path::Path) -> String {
139 sanitize(&p.display().to_string())
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 /// Every byte a terminal would act on, and nothing else.
147 #[test]
148 fn sanitize_escapes_what_a_terminal_would_obey() {
149 // The one that hid a declaration from the trust prompt.
150 assert_eq!(sanitize("a\u{1b}[8mb"), "a\\x1b[8mb");
151 assert_eq!(sanitize("bell\u{7}"), "bell\\x07");
152 assert_eq!(sanitize("cr\r"), "cr\\x0d");
153 assert_eq!(sanitize("del\u{7f}"), "del\\x7f");
154 // 8-bit CSI: no ESC in sight.
155 assert_eq!(sanitize("csi\u{9b}"), "csi\\x9b");
156 // Trojan Source.
157 assert_eq!(sanitize("rtl\u{202e}"), "rtl\\u{202e}");
158 assert_eq!(sanitize("iso\u{2066}"), "iso\\u{2066}");
159 // A tab keeps its width without keeping its behaviour.
160 assert_eq!(sanitize("a\tb"), "a b");
161 }
162
163 /// It is a display guard, not a charset policy.
164 #[test]
165 fn sanitize_leaves_ordinary_text_alone() {
166 for s in ["plain ascii", "café", "日本語", "✓ ✗ !", "a/b-c_d.e", "🐛"] {
167 assert_eq!(sanitize(s), s, "{s:?} should pass through");
168 }
169 }
170
171 /// `highlight` wraps text in its own escapes, so text that carries an
172 /// escape of its own would break the wrapping as well as the reader.
173 #[test]
174 fn highlight_emits_only_its_own_escapes() {
175 let out = highlight("a\u{1b}[0m\u{1b}[8mb");
176 let escapes = out.matches('\u{1b}').count();
177 // Either colour is off (0) or it is exactly the pair this function
178 // writes — never the ones that came in with the text.
179 assert!(
180 escapes == 0 || escapes == 2,
181 "{out:?} has {escapes} escapes"
182 );
183 // The `[8m` TEXT survives, harmlessly — what must not survive is the
184 // ESC that would make a terminal read it as a command.
185 assert!(
186 !out.contains("\u{1b}[8m"),
187 "a conceal sequence survived: {out:?}"
188 );
189 assert!(
190 !out.contains("\u{1b}[0m\u{1b}[8m"),
191 "an injected reset survived: {out:?}"
192 );
193 }
194
195 /// The point of the change: nothing emits a code above 15, because those
196 /// ignore the terminal's palette entirely.
197 #[test]
198 fn no_sign_uses_the_fixed_256_colour_cube() {
199 for s in [valid_sign(), error_sign(), warning_sign()] {
200 assert!(
201 !s.contains("38;5;"),
202 "a 256-colour code overrides the user's theme: {s:?}"
203 );
204 }
205 }
206
207 #[test]
208 fn signs_carry_a_distinct_glyph_not_only_a_colour() {
209 assert!(valid_sign().contains('✓'));
210 assert!(error_sign().contains('✗'));
211 assert!(warning_sign().contains('!'));
212 }
213
214 /// No source file may emit a 256-colour escape directly.
215 ///
216 /// The migration to base ANSI converted the sign helpers but missed a raw
217 /// `\u{1b}[38;5;208m` in the pre-push error line, which therefore ignored
218 /// the terminal theme for two more PRs. Checking the helpers was not enough
219 /// because the offender did not use them.
220 #[test]
221 fn no_source_file_emits_a_256_colour_escape() {
222 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
223 let mut offenders = Vec::new();
224 fn walk(d: &std::path::Path, out: &mut Vec<String>) {
225 for e in std::fs::read_dir(d).expect("src").flatten() {
226 let p = e.path();
227 if p.is_dir() {
228 walk(&p, out);
229 } else if p.extension().is_some_and(|x| x == "rs") {
230 for (i, line) in std::fs::read_to_string(&p)
231 .unwrap_or_default()
232 .lines()
233 .enumerate()
234 {
235 let mentions_the_pattern =
236 line.contains("offenders") || line.trim_start().starts_with("//");
237 if mentions_the_pattern {
238 continue;
239 }
240 // Split so this line is not itself an offender.
241 if line.contains(concat!("[38", ";5;")) {
242 out.push(format!("{}:{}", p.display(), i + 1));
243 }
244 }
245 }
246 }
247 }
248 walk(std::path::Path::new(dir), &mut offenders);
249 assert!(
250 offenders.is_empty(),
251 "a fixed 256-colour code overrides the user's terminal theme: {offenders:?}"
252 );
253 }
254
255 /// A success glyph must never be painted with the warning accent.
256 ///
257 /// The mechanical rewrite from 256-colour codes turned `color("✓", "112")`
258 /// into `highlight("✓")` in two places, which renders a tick in the warning
259 /// colour — the kind of damage a regex does quietly. Sign glyphs belong to
260 /// the sign functions; `highlight` is for emphasising words.
261 #[test]
262 fn glyphs_are_not_routed_through_highlight() {
263 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
264 let mut offenders = Vec::new();
265 fn walk(d: &std::path::Path, out: &mut Vec<String>) {
266 for e in std::fs::read_dir(d).expect("src").flatten() {
267 let p = e.path();
268 if p.is_dir() {
269 walk(&p, out);
270 } else if p.extension().is_some_and(|x| x == "rs") {
271 let body = std::fs::read_to_string(&p).unwrap_or_default();
272 for (i, line) in body.lines().enumerate() {
273 let is_test_or_doc =
274 line.trim_start().starts_with("//") || line.contains("offenders");
275 if is_test_or_doc {
276 continue;
277 }
278 for g in ["\\u{2713}", "\\u{2717}", "✓", "✗"] {
279 if line.contains("highlight(") && line.contains(g) {
280 out.push(format!("{}:{}", p.display(), i + 1));
281 }
282 }
283 }
284 }
285 }
286 }
287 walk(std::path::Path::new(dir), &mut offenders);
288 assert!(
289 offenders.is_empty(),
290 "a status glyph is being painted as emphasis: {offenders:?}"
291 );
292 }
293
294 /// `colors_enabled` memoises, so the predicate is tested directly rather
295 /// than through a cache whichever test happens to fill first.
296 #[test]
297 fn no_color_is_honoured_per_the_standard() {
298 fn decide(no_color: Option<&str>, term: &str) -> bool {
299 let nc = no_color.map(|v| !v.is_empty()).unwrap_or(false);
300 !nc && term != "dumb"
301 }
302 assert!(decide(None, "xterm-256color"), "colour by default");
303 assert!(!decide(Some("1"), "xterm-256color"), "any value disables");
304 assert!(!decide(Some("0"), "xterm-256color"), "even \"0\" disables");
305 assert!(
306 decide(Some(""), "xterm-256color"),
307 "an EMPTY value does NOT disable — no-color.org is explicit"
308 );
309 assert!(!decide(None, "dumb"), "a dumb terminal cannot render SGR");
310 }
311}