Skip to main content

ai_memory/
color.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! ANSI color output for CLI — zero dependencies.
5//!
6//! pm-v3.1 PR8 (issue #1174) — color enablement is determined ONCE at
7//! process boot from `std::io::stdout().is_terminal()` and frozen for
8//! the lifetime of the process via `OnceLock<bool>`. The pre-PR8
9//! shape used a mutable `AtomicBool` which gave production callers no
10//! protection against accidental late mutation. Tests that need to
11//! force colour off route through a thread-local override consulted
12//! by `enabled()` (compiled out of non-test builds).
13
14use std::io::IsTerminal;
15use std::sync::OnceLock;
16
17/// One-shot snapshot of the boot-time `stdout` is-a-terminal probe.
18/// Set exactly once by [`init`]; subsequent calls are no-ops thanks
19/// to `OnceLock::set` semantics (first-writer-wins). Production code
20/// SHOULD call [`init`] exactly once at process start (see
21/// `src/main.rs`).
22static COLOR_ENABLED: OnceLock<bool> = OnceLock::new();
23
24pub fn init() {
25    // `OnceLock::set` returns `Err` if already initialised; benign
26    // for double-init paths (tests, repeat embedder bootstrap), so
27    // the return value is intentionally discarded.
28    let _ = COLOR_ENABLED.set(std::io::stdout().is_terminal());
29}
30
31fn enabled() -> bool {
32    #[cfg(test)]
33    if let Some(forced) = test_override::get() {
34        return forced;
35    }
36    // Default true matches the pre-PR8 `AtomicBool::new(true)` posture
37    // — if production code reads colour state before `init` runs the
38    // colourised path stays on (the prior behaviour).
39    *COLOR_ENABLED.get().unwrap_or(&true)
40}
41
42#[cfg(test)]
43mod test_override {
44    use std::cell::Cell;
45
46    thread_local! {
47        static OVERRIDE: Cell<Option<bool>> = const { Cell::new(None) };
48    }
49
50    pub(super) fn get() -> Option<bool> {
51        OVERRIDE.with(Cell::get)
52    }
53
54    pub(super) fn set(value: Option<bool>) {
55        OVERRIDE.with(|cell| cell.set(value));
56    }
57}
58
59fn wrap(code: &str, text: &str) -> String {
60    if enabled() {
61        format!("\x1b[{code}m{text}\x1b[0m")
62    } else {
63        text.to_string()
64    }
65}
66
67// Tier colors
68pub fn short(text: &str) -> String {
69    wrap("91", text)
70} // red
71pub fn mid(text: &str) -> String {
72    wrap("93", text)
73} // yellow
74pub fn long(text: &str) -> String {
75    wrap("92", text)
76} // green
77
78// Semantic colors
79pub fn dim(text: &str) -> String {
80    wrap("2", text)
81}
82pub fn bold(text: &str) -> String {
83    wrap("1", text)
84}
85pub fn cyan(text: &str) -> String {
86    wrap("96", text)
87}
88
89/// Colorize `text` according to the caller-supplied tier wire string.
90///
91/// The string literals in the match arms below are the **canonical
92/// deserializer** for the `Tier` enum's wire form — they pair with
93/// `crate::models::Tier::as_str` (Short → "short" / Mid → "mid" /
94/// Long → "long"). They MUST stay as raw literals here because this
95/// is the boundary where a caller-supplied `&str` (config, CLI flag,
96/// JSON value) gets dispatched; the enum has nothing to plug in at
97/// this point. Anywhere else that constructs a tier wire value should
98/// route through `Tier::<X>.as_str()`. See pm-v3.1 PR6 (#1174) for the
99/// sweep that pinned this invariant.
100pub fn tier_color(tier: &str, text: &str) -> String {
101    match tier {
102        "short" => short(text),
103        "mid" => mid(text),
104        "long" => long(text),
105        _ => text.to_string(),
106    }
107}
108
109/// Priority as a colored bar: ████░░░░░░
110pub fn priority_bar(p: i32) -> String {
111    // B4 (R2-LOW) — clamp range is 1..=10 so try_from is infallible;
112    // use `unwrap_or` to align with the campaign's no-panic discipline
113    // (defensive against future refactors that drop the `clamp` call).
114    let filled = usize::try_from(p.clamp(1, 10)).unwrap_or(1);
115    let empty = 10 - filled;
116    let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
117    if p >= 8 {
118        wrap("92", &bar)
119    } else if p >= 5 {
120        wrap("93", &bar)
121    } else {
122        wrap("91", &bar)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    /// pm-v3.1 PR8 (issue #1174) — thread-local override removes the
131    /// need for a process-wide `Mutex<()>` to serialise tests. Each
132    /// `cargo test` worker gets its own override slot, so the
133    /// previously-required `--test-threads=1` style of serialisation
134    /// is gone.
135    fn with_color_off<F: FnOnce()>(f: F) {
136        test_override::set(Some(false));
137        f();
138        test_override::set(None);
139    }
140
141    #[test]
142    fn tier_colors_no_ansi() {
143        with_color_off(|| {
144            assert_eq!(short("test"), "test");
145            assert_eq!(mid("test"), "test");
146            assert_eq!(long("test"), "test");
147        });
148    }
149
150    #[test]
151    fn semantic_colors_no_ansi() {
152        with_color_off(|| {
153            assert_eq!(dim("test"), "test");
154            assert_eq!(bold("test"), "test");
155            assert_eq!(cyan("test"), "test");
156        });
157    }
158
159    #[test]
160    fn tier_color_dispatch() {
161        use crate::models::Tier;
162        with_color_off(|| {
163            assert_eq!(tier_color(Tier::Short.as_str(), "x"), "x");
164            assert_eq!(tier_color(Tier::Mid.as_str(), "x"), "x");
165            assert_eq!(tier_color(Tier::Long.as_str(), "x"), "x");
166            assert_eq!(tier_color("unknown", "x"), "x");
167        });
168    }
169
170    #[test]
171    fn priority_bar_length() {
172        with_color_off(|| {
173            let bar = priority_bar(5);
174            // 5 filled + 5 empty = 10 chars (each is multi-byte unicode)
175            assert!(bar.contains("█"));
176            assert!(bar.contains("░"));
177        });
178    }
179
180    #[test]
181    fn priority_bar_clamps() {
182        with_color_off(|| {
183            let bar_min = priority_bar(0); // clamps to 1
184            let bar_max = priority_bar(15); // clamps to 10
185            assert!(bar_min.contains("░"));
186            assert!(!bar_max.contains("░")); // all filled
187        });
188    }
189
190    #[test]
191    fn wrap_with_color_enabled() {
192        test_override::set(Some(true));
193        let result = wrap("91", "red");
194        assert!(result.contains("\x1b[91m"));
195        assert!(result.contains("\x1b[0m"));
196        assert!(result.contains("red"));
197        test_override::set(None);
198    }
199}