Skip to main content

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}