Skip to main content

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}