rusty_figlet/color_depth.rs
1//! Color depth detection + SGR emission for 24-bit truecolor and 256-color (E012 Phase 6).
2//!
3//! ## Capabilities
4//!
5//! - [`ColorDepth`] enum with three rungs (Truecolor → Color256 → Color16) per FR-010.
6//! - [`ColorDepth::detect`] reads `COLORTERM` + isatty per spec Edge Cases.
7//! - Truecolor SGR emission (`\x1b[38;2;R;G;Bm` / `\x1b[48;2;R;G;Bm`) gated behind
8//! `color-truecolor` per FR-008 (T036).
9//! - 256-color SGR emission (`\x1b[38;5;Nm` / `\x1b[48;5;Nm`) gated behind
10//! `color-256` per FR-009 (T037).
11//! - [`resolve_depth`] graceful downgrade with FIXED stderr warning string —
12//! no user bytes interpolated per FR-018 + spec Security Posture (T038).
13//!
14//! ## Security posture (FR-018, FR-029)
15//!
16//! When the requested color depth is unavailable AND warnings are not
17//! suppressed, [`resolve_depth`] emits a FIXED stderr warning string. The
18//! warning never includes the `$COLORTERM` value or any other byte that
19//! originated from the environment — per spec Edge Cases this protects
20//! against log injection from adversarial terminal-name strings.
21//!
22//! FR-029 zero-cost: when `suppress_warning = true` the warning branch
23//! short-circuits BEFORE the format-args evaluation, so the cost of the
24//! suppression path is a single `if` and one struct-tag comparison.
25//!
26//! ## Module entry points
27//!
28//! - [`ColorDepth::detect`] — env-var based detection (always available).
29//! - [`emit_truecolor_fg`] / [`emit_truecolor_bg`] — typed-`Color` SGR
30//! builders (under `color-truecolor`).
31//! - [`emit_256_fg`] / [`emit_256_bg`] — typed-index SGR builders
32//! (under `color-256`).
33//! - [`resolve_depth`] — requested vs detected reconciliation + warning.
34
35use std::env;
36
37/// Color depth rung used by the SGR emitters and the `Figlet` render path.
38///
39/// Ordering: `Truecolor > Color256 > Color16`. `ColorDepth::detect` returns
40/// the **highest** rung the current terminal advertises. `resolve_depth`
41/// downgrades a `requested` rung to the `detected` rung when the terminal
42/// cannot honor it, emitting a fixed warning unless suppressed.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
44pub enum ColorDepth {
45 /// 24-bit truecolor (`\x1b[38;2;R;G;Bm`). Advertised by `COLORTERM=truecolor`
46 /// or `COLORTERM=24bit`. Highest rung.
47 Truecolor,
48 /// 256-color indexed palette (`\x1b[38;5;Nm`). Middle rung.
49 Color256,
50 /// 16-color ANSI named palette (`\x1b[30m..37m`, `\x1b[90m..97m`). Lowest
51 /// rung; always available on any ANSI-compatible terminal.
52 #[default]
53 Color16,
54}
55
56impl ColorDepth {
57 /// Detect the highest color depth the current terminal advertises.
58 ///
59 /// ## Cache contract (FR-031)
60 ///
61 /// This function is intended to be called **once** at builder time
62 /// (`FigletBuilder::build`); the result is cached on the [`crate::Figlet`]
63 /// renderer for its lifetime. The render path NEVER calls `detect` —
64 /// invalidation is caller-driven only via [`crate::Figlet::set_color_depth`].
65 /// Calling `detect` is O(1) + one syscall (isatty) + one env-var read.
66 ///
67 /// Detection rules (per spec Edge Cases + FR-010):
68 ///
69 /// 1. If `COLORTERM` is set to `"truecolor"` or `"24bit"` (case-sensitive,
70 /// matching the upstream toilet convention), return [`Self::Truecolor`].
71 /// 2. Otherwise return [`Self::Color16`] — the safe lowest-common-denominator
72 /// rung that any ANSI-compatible terminal honors.
73 ///
74 /// 256-color is NOT auto-detected because no portable environment variable
75 /// reliably signals 256-color support — `TERM=xterm-256color` is a hint but
76 /// not a contract. Callers who want 256-color SHOULD pass it explicitly via
77 /// [`resolve_depth`].
78 ///
79 /// The isatty probe is performed only when [`std::io::IsTerminal`] is
80 /// available; non-TTY stdout (e.g., piped to a file) returns [`Self::Color16`]
81 /// regardless of `COLORTERM` so redirected output is not polluted with
82 /// 24-bit escapes that the consuming program won't strip.
83 pub fn detect() -> Self {
84 // Non-TTY stdout: never advertise truecolor (FR-010 Edge Case —
85 // piped output should never carry RGB escapes that downstream
86 // tools can't render).
87 if !is_stdout_tty() {
88 return Self::Color16;
89 }
90 match env::var("COLORTERM").as_deref() {
91 Ok("truecolor") | Ok("24bit") => Self::Truecolor,
92 _ => Self::Color16,
93 }
94 }
95}
96
97/// Resolve a caller's requested color depth against the detected terminal
98/// capability, downgrading gracefully when the terminal cannot honor the
99/// request.
100///
101/// ## Downgrade matrix
102///
103/// | requested | detected | result | warning? |
104/// |------------|------------|------------|--------------------|
105/// | Truecolor | Truecolor | Truecolor | no |
106/// | Truecolor | Color256 | Color256 | yes (if not suppr) |
107/// | Truecolor | Color16 | Color16 | yes (if not suppr) |
108/// | Color256 | Truecolor | Color256 | no |
109/// | Color256 | Color256 | Color256 | no |
110/// | Color256 | Color16 | Color16 | yes (if not suppr) |
111/// | Color16 | * | Color16 | no |
112///
113/// `suppress_warning = true` short-circuits BEFORE any format-args evaluation
114/// per FR-029 (zero-cost when suppressed).
115///
116/// The emitted warning is a **fixed** string — no `$COLORTERM` bytes, no
117/// terminal-name bytes — per FR-018 + spec Security Posture (defense against
118/// log-injection from adversarial environment variables).
119pub fn resolve_depth(
120 requested: ColorDepth,
121 detected: ColorDepth,
122 suppress_warning: bool,
123) -> ColorDepth {
124 let (result, downgraded) = match (requested, detected) {
125 (ColorDepth::Truecolor, ColorDepth::Truecolor) => (ColorDepth::Truecolor, false),
126 (ColorDepth::Truecolor, ColorDepth::Color256) => (ColorDepth::Color256, true),
127 (ColorDepth::Truecolor, ColorDepth::Color16) => (ColorDepth::Color16, true),
128 (ColorDepth::Color256, ColorDepth::Truecolor) => (ColorDepth::Color256, false),
129 (ColorDepth::Color256, ColorDepth::Color256) => (ColorDepth::Color256, false),
130 (ColorDepth::Color256, ColorDepth::Color16) => (ColorDepth::Color16, true),
131 (ColorDepth::Color16, _) => (ColorDepth::Color16, false),
132 };
133 if downgraded && !suppress_warning {
134 // FR-018: FIXED string only. Never interpolate $COLORTERM or any
135 // other byte that came from the environment.
136 emit_downgrade_warning();
137 }
138 result
139}
140
141/// Emit the fixed downgrade warning to stderr. Kept as a separate function
142/// so the format-args literal is a single static buffer the compiler can
143/// fold.
144#[cold]
145#[inline(never)]
146fn emit_downgrade_warning() {
147 eprintln!(
148 "rusty-figlet: requested color depth unavailable; downgrading to terminal capability"
149 );
150}
151
152/// Best-effort stdout-isatty probe. Returns `true` when stdout is attached
153/// to a terminal; `false` otherwise (piped, redirected, or detection failed).
154///
155/// Implemented via [`std::io::IsTerminal`] which is stable since Rust 1.70
156/// and is fully cross-platform (Windows + Unix).
157fn is_stdout_tty() -> bool {
158 use std::io::IsTerminal;
159 std::io::stdout().is_terminal()
160}
161
162// ---------------------------------------------------------------------------
163// T036 — Truecolor SGR emission (gated behind `color-truecolor`).
164// ---------------------------------------------------------------------------
165
166/// Emit a 24-bit truecolor foreground SGR for the typed RGB triple.
167///
168/// The output bytes are `\x1b[38;2;R;G;Bm` per FR-008. The `(r, g, b)`
169/// arguments are typed `u8` values — there is no path for user-controlled
170/// bytes to flow into the escape sequence per spec Security Posture.
171#[cfg(feature = "color-truecolor")]
172#[must_use]
173pub fn emit_truecolor_fg(r: u8, g: u8, b: u8) -> String {
174 // Preallocated capacity: longest is `\x1b[38;2;255;255;255m` = 19 bytes.
175 let mut s = String::with_capacity(20);
176 s.push_str("\x1b[38;2;");
177 push_u8(&mut s, r);
178 s.push(';');
179 push_u8(&mut s, g);
180 s.push(';');
181 push_u8(&mut s, b);
182 s.push('m');
183 s
184}
185
186/// Emit a 24-bit truecolor background SGR for the typed RGB triple.
187///
188/// The output bytes are `\x1b[48;2;R;G;Bm` per FR-008.
189#[cfg(feature = "color-truecolor")]
190#[must_use]
191pub fn emit_truecolor_bg(r: u8, g: u8, b: u8) -> String {
192 let mut s = String::with_capacity(20);
193 s.push_str("\x1b[48;2;");
194 push_u8(&mut s, r);
195 s.push(';');
196 push_u8(&mut s, g);
197 s.push(';');
198 push_u8(&mut s, b);
199 s.push('m');
200 s
201}
202
203// ---------------------------------------------------------------------------
204// T037 — 256-color SGR emission (gated behind `color-256`).
205// ---------------------------------------------------------------------------
206
207/// Emit a 256-color indexed foreground SGR (`\x1b[38;5;Nm`) per FR-009.
208///
209/// `n` is a typed `u8` (0..=255); no path exists for user bytes.
210#[cfg(feature = "color-256")]
211#[must_use]
212pub fn emit_256_fg(n: u8) -> String {
213 // Longest is `\x1b[38;5;255m` = 11 bytes.
214 let mut s = String::with_capacity(12);
215 s.push_str("\x1b[38;5;");
216 push_u8(&mut s, n);
217 s.push('m');
218 s
219}
220
221/// Emit a 256-color indexed background SGR (`\x1b[48;5;Nm`) per FR-009.
222#[cfg(feature = "color-256")]
223#[must_use]
224pub fn emit_256_bg(n: u8) -> String {
225 let mut s = String::with_capacity(12);
226 s.push_str("\x1b[48;5;");
227 push_u8(&mut s, n);
228 s.push('m');
229 s
230}
231
232/// Internal: push a `u8` decimal representation onto an existing `String`
233/// without going through `format!` — keeps the SGR emit path allocation-
234/// free beyond the single output buffer.
235#[cfg(any(feature = "color-truecolor", feature = "color-256"))]
236fn push_u8(s: &mut String, n: u8) {
237 if n >= 100 {
238 s.push(((n / 100) + b'0') as char);
239 s.push((((n / 10) % 10) + b'0') as char);
240 s.push(((n % 10) + b'0') as char);
241 } else if n >= 10 {
242 s.push(((n / 10) + b'0') as char);
243 s.push(((n % 10) + b'0') as char);
244 } else {
245 s.push((n + b'0') as char);
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn default_is_color16() {
255 assert_eq!(ColorDepth::default(), ColorDepth::Color16);
256 }
257
258 #[test]
259 fn resolve_truecolor_to_truecolor_no_warning() {
260 let r = resolve_depth(ColorDepth::Truecolor, ColorDepth::Truecolor, false);
261 assert_eq!(r, ColorDepth::Truecolor);
262 }
263
264 #[test]
265 fn resolve_truecolor_to_color16_downgrades() {
266 let r = resolve_depth(ColorDepth::Truecolor, ColorDepth::Color16, true);
267 assert_eq!(r, ColorDepth::Color16);
268 }
269
270 #[test]
271 fn resolve_truecolor_to_color256_downgrades() {
272 let r = resolve_depth(ColorDepth::Truecolor, ColorDepth::Color256, true);
273 assert_eq!(r, ColorDepth::Color256);
274 }
275
276 #[test]
277 fn resolve_color256_to_truecolor_uses_color256() {
278 let r = resolve_depth(ColorDepth::Color256, ColorDepth::Truecolor, false);
279 assert_eq!(r, ColorDepth::Color256);
280 }
281
282 #[test]
283 fn resolve_color16_always_color16() {
284 let r = resolve_depth(ColorDepth::Color16, ColorDepth::Truecolor, false);
285 assert_eq!(r, ColorDepth::Color16);
286 }
287
288 #[cfg(feature = "color-truecolor")]
289 #[test]
290 fn truecolor_fg_emits_canonical_sgr() {
291 let s = emit_truecolor_fg(255, 128, 0);
292 assert_eq!(s, "\x1b[38;2;255;128;0m");
293 }
294
295 #[cfg(feature = "color-truecolor")]
296 #[test]
297 fn truecolor_bg_emits_canonical_sgr() {
298 let s = emit_truecolor_bg(0, 0, 0);
299 assert_eq!(s, "\x1b[48;2;0;0;0m");
300 }
301
302 #[cfg(feature = "color-truecolor")]
303 #[test]
304 fn truecolor_no_extra_chars() {
305 // Defense against accidentally leaking environment bytes into
306 // the SGR sequence. The only non-printable byte permitted is
307 // the ESC (0x1B) introducer; every other char must be ASCII
308 // and printable.
309 let s = emit_truecolor_fg(1, 2, 3);
310 for ch in s.chars() {
311 assert!(ch.is_ascii(), "non-ASCII byte in SGR: {ch:?}");
312 assert!(
313 ch == '\x1b' || !ch.is_control(),
314 "control byte other than ESC in SGR: {ch:?}"
315 );
316 }
317 }
318
319 #[cfg(feature = "color-256")]
320 #[test]
321 fn color256_fg_emits_canonical_sgr() {
322 let s = emit_256_fg(196);
323 assert_eq!(s, "\x1b[38;5;196m");
324 }
325
326 #[cfg(feature = "color-256")]
327 #[test]
328 fn color256_bg_emits_canonical_sgr() {
329 let s = emit_256_bg(21);
330 assert_eq!(s, "\x1b[48;5;21m");
331 }
332
333 #[cfg(feature = "color-256")]
334 #[test]
335 fn color256_edge_indices() {
336 assert_eq!(emit_256_fg(0), "\x1b[38;5;0m");
337 assert_eq!(emit_256_fg(255), "\x1b[38;5;255m");
338 }
339}