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}