Skip to main content

standout_render/
environment.rs

1//! Injectable environment detection.
2//!
3//! This module centralizes process-global detection of terminal properties
4//! — width, TTY status, and ANSI color capability — behind overridable
5//! function pointers so tests can force specific values without touching
6//! real environment state.
7//!
8//! It follows the same pattern used by
9//! [`set_theme_detector`](crate::set_theme_detector) and
10//! [`set_icon_detector`](crate::set_icon_detector).
11//!
12//! # Usage
13//!
14//! In application code, call the `detect_*` functions. They resolve to real
15//! terminal queries by default:
16//!
17//! ```rust
18//! use standout_render::{detect_terminal_width, detect_is_tty, detect_color_capability};
19//!
20//! let _width = detect_terminal_width();
21//! let _tty = detect_is_tty();
22//! let _color = detect_color_capability();
23//! ```
24//!
25//! In tests, override any of them with a function pointer or a non-capturing
26//! closure (both coerce to `fn(...) -> T`):
27//!
28//! ```rust
29//! use standout_render::{set_terminal_width_detector, detect_terminal_width};
30//!
31//! set_terminal_width_detector(|| Some(80));
32//! assert_eq!(detect_terminal_width(), Some(80));
33//! ```
34//!
35//! Capturing closures are not supported — if you need per-test state, route
36//! it through a thread-local or a static the detector reads from.
37//!
38//! Overrides are process-global, so tests that set them should be annotated
39//! with `#[serial]` (via the `serial_test` crate) and should use
40//! [`DetectorGuard`] to guarantee cleanup even when the test panics.
41
42use console::Term;
43use once_cell::sync::Lazy;
44use std::sync::Mutex;
45
46type WidthDetector = fn() -> Option<usize>;
47type TtyDetector = fn() -> bool;
48type ColorDetector = fn() -> bool;
49
50static WIDTH_DETECTOR: Lazy<Mutex<WidthDetector>> =
51    Lazy::new(|| Mutex::new(default_width_detector));
52static TTY_DETECTOR: Lazy<Mutex<TtyDetector>> = Lazy::new(|| Mutex::new(default_tty_detector));
53static COLOR_DETECTOR: Lazy<Mutex<ColorDetector>> =
54    Lazy::new(|| Mutex::new(default_color_detector));
55
56/// Overrides the detector used to query terminal width.
57///
58/// Accepts a `fn` pointer or a non-capturing closure. The detector returns
59/// `Some(cols)` when a width can be determined and `None` when output is not
60/// a terminal. Useful to force a fixed width in snapshot tests.
61pub fn set_terminal_width_detector(detector: WidthDetector) {
62    *WIDTH_DETECTOR.lock().unwrap() = detector;
63}
64
65/// Overrides the detector used to check whether stdout is a TTY.
66///
67/// Accepts a `fn` pointer or a non-capturing closure.
68pub fn set_tty_detector(detector: TtyDetector) {
69    *TTY_DETECTOR.lock().unwrap() = detector;
70}
71
72/// Overrides the detector used to check whether ANSI color is supported on
73/// stdout.
74///
75/// Accepts a `fn` pointer or a non-capturing closure. This is what
76/// [`OutputMode::Auto`](crate::OutputMode::Auto) consults to decide between
77/// applying and stripping style tags.
78pub fn set_color_capability_detector(detector: ColorDetector) {
79    *COLOR_DETECTOR.lock().unwrap() = detector;
80}
81
82/// Returns the current terminal width in columns, or `None` when unavailable.
83pub fn detect_terminal_width() -> Option<usize> {
84    // Copy the fn pointer out and release the lock before invoking the
85    // detector. Holding the mutex across the call would poison it on panic
86    // and deadlock if the detector re-entered `set_*`/`reset_*`.
87    let detector = *WIDTH_DETECTOR.lock().unwrap();
88    detector()
89}
90
91/// Returns `true` when stdout is attached to a terminal.
92pub fn detect_is_tty() -> bool {
93    let detector = *TTY_DETECTOR.lock().unwrap();
94    detector()
95}
96
97/// Returns `true` when ANSI color output is supported on stdout.
98pub fn detect_color_capability() -> bool {
99    let detector = *COLOR_DETECTOR.lock().unwrap();
100    detector()
101}
102
103fn default_width_detector() -> Option<usize> {
104    terminal_size::terminal_size().map(|(w, _)| w.0 as usize)
105}
106
107fn default_tty_detector() -> bool {
108    Term::stdout().is_term()
109}
110
111fn default_color_detector() -> bool {
112    Term::stdout().features().colors_supported()
113}
114
115/// Resets every environment detector in this module to its default
116/// (real-terminal) implementation.
117///
118/// Tests that installed overrides should call this in teardown to avoid
119/// leaking state into sibling tests. For panic-safe cleanup, prefer
120/// [`DetectorGuard`] instead of calling this manually.
121pub fn reset_detectors() {
122    set_terminal_width_detector(default_width_detector);
123    set_tty_detector(default_tty_detector);
124    set_color_capability_detector(default_color_detector);
125}
126
127/// RAII guard that calls [`reset_detectors`] when dropped.
128///
129/// Install at the start of a test to guarantee the overrides are torn down
130/// on normal exit *and* on panic-induced unwind, so a failing assertion
131/// doesn't leak state into the next serial test.
132///
133/// ```rust
134/// use standout_render::environment::{DetectorGuard, set_terminal_width_detector, detect_terminal_width};
135///
136/// let _guard = DetectorGuard::new();
137/// set_terminal_width_detector(|| Some(80));
138/// assert_eq!(detect_terminal_width(), Some(80));
139/// // `_guard` resets everything when it goes out of scope.
140/// ```
141#[must_use = "the guard only resets detectors when dropped; bind it to a variable"]
142pub struct DetectorGuard {
143    _private: (),
144}
145
146impl DetectorGuard {
147    /// Creates a guard that will reset all environment detectors on drop.
148    pub fn new() -> Self {
149        Self { _private: () }
150    }
151}
152
153impl Default for DetectorGuard {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159impl Drop for DetectorGuard {
160    fn drop(&mut self) {
161        reset_detectors();
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use serial_test::serial;
169
170    #[test]
171    #[serial]
172    fn width_override_is_honored() {
173        let _guard = DetectorGuard::new();
174        set_terminal_width_detector(|| Some(42));
175        assert_eq!(detect_terminal_width(), Some(42));
176        set_terminal_width_detector(|| None);
177        assert_eq!(detect_terminal_width(), None);
178    }
179
180    #[test]
181    #[serial]
182    fn tty_override_is_honored() {
183        let _guard = DetectorGuard::new();
184        set_tty_detector(|| true);
185        assert!(detect_is_tty());
186        set_tty_detector(|| false);
187        assert!(!detect_is_tty());
188    }
189
190    #[test]
191    #[serial]
192    fn color_override_is_honored() {
193        let _guard = DetectorGuard::new();
194        set_color_capability_detector(|| true);
195        assert!(detect_color_capability());
196        set_color_capability_detector(|| false);
197        assert!(!detect_color_capability());
198    }
199
200    #[test]
201    #[serial]
202    fn reset_replaces_panicking_overrides() {
203        let _guard = DetectorGuard::new();
204
205        fn boom_width() -> Option<usize> {
206            panic!("width detector must not be called after reset")
207        }
208        fn boom_bool() -> bool {
209            panic!("bool detector must not be called after reset")
210        }
211
212        set_terminal_width_detector(boom_width);
213        set_tty_detector(boom_bool);
214        set_color_capability_detector(boom_bool);
215
216        reset_detectors();
217
218        // If reset were a no-op the panicking detectors would still be
219        // installed and these calls would unwind.
220        let _ = detect_terminal_width();
221        let _ = detect_is_tty();
222        let _ = detect_color_capability();
223    }
224
225    #[test]
226    #[serial]
227    fn guard_restores_on_drop() {
228        {
229            let _guard = DetectorGuard::new();
230            set_terminal_width_detector(|| Some(1));
231            set_tty_detector(|| true);
232            set_color_capability_detector(|| true);
233            assert_eq!(detect_terminal_width(), Some(1));
234        }
235
236        // Guard dropped — a fresh panicking detector should be reachable
237        // again (i.e. the override is gone) via reset_detectors. We verify
238        // reset was effective by installing panicking detectors, dropping a
239        // new guard, and confirming calls don't panic.
240        fn boom() -> Option<usize> {
241            panic!("override leaked past guard drop")
242        }
243        set_terminal_width_detector(boom);
244        drop(DetectorGuard::new());
245        let _ = detect_terminal_width();
246    }
247}