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}