Skip to main content

ftui_core/
capability_override.rs

1#![forbid(unsafe_code)]
2
3//! Runtime capability override injection for testing (bd-k4lj.3).
4//!
5//! This module provides a thread-local override mechanism for terminal
6//! capabilities, enabling tests to simulate various terminal environments
7//! without modifying global state.
8//!
9//! # Overview
10//!
11//! - **Thread-local**: Overrides are scoped to the current thread, ensuring
12//!   test isolation in parallel test runs.
13//! - **Stackable**: Multiple overrides can be nested, with inner overrides
14//!   taking precedence.
15//! - **RAII-based**: Overrides are automatically removed when the guard is
16//!   dropped, even on panic.
17//!
18//! # Invariants
19//!
20//! 1. **Thread isolation**: Overrides on one thread never affect another.
21//! 2. **Stack ordering**: Later pushes override earlier ones; pops restore
22//!    the previous state.
23//! 3. **Cleanup guarantee**: Guards implement Drop to ensure cleanup even
24//!    on panic or early return.
25//! 4. **No runtime cost when unused**: If no overrides are active, capability
26//!    resolution has minimal overhead (just checking the thread-local stack).
27//!
28//! # Failure Modes
29//!
30//! | Mode | Condition | Behavior |
31//! |------|-----------|----------|
32//! | Guard leaked | Guard moved without dropping | Override persists until thread exit |
33//! | Stack underflow | Bug in guard management | Panics (debug) or no-op (release) |
34//! | Thread exit | Thread terminates with active overrides | TLS destructor cleans up |
35//!
36//! # Example
37//!
38//! ```
39//! use ftui_core::capability_override::{with_capability_override, CapabilityOverride};
40//! use ftui_core::terminal_capabilities::TerminalCapabilities;
41//!
42//! // Simulate a dumb terminal
43//! let override_cfg = CapabilityOverride::new()
44//!     .true_color(Some(false))
45//!     .colors_256(Some(false))
46//!     .mouse_sgr(Some(false));
47//!
48//! with_capability_override(override_cfg, || {
49//!     let caps = TerminalCapabilities::with_overrides();
50//!     assert!(!caps.true_color);
51//!     assert!(!caps.mouse_sgr);
52//! });
53//! ```
54
55use crate::terminal_capabilities::TerminalCapabilities;
56use std::cell::RefCell;
57
58// ============================================================================
59// Capability Override
60// ============================================================================
61
62/// Override specification for terminal capabilities.
63///
64/// Each field is `Option<bool>`:
65/// - `Some(true)` - Force capability ON
66/// - `Some(false)` - Force capability OFF
67/// - `None` - Don't override (use base or previous override)
68#[derive(Debug, Clone, Default)]
69pub struct CapabilityOverride {
70    // Color
71    pub true_color: Option<bool>,
72    pub colors_256: Option<bool>,
73
74    // Advanced features
75    pub sync_output: Option<bool>,
76    pub osc8_hyperlinks: Option<bool>,
77    pub scroll_region: Option<bool>,
78
79    // Multiplexer flags
80    pub in_tmux: Option<bool>,
81    pub in_screen: Option<bool>,
82    pub in_zellij: Option<bool>,
83
84    // Input features
85    pub kitty_keyboard: Option<bool>,
86    pub focus_events: Option<bool>,
87    pub bracketed_paste: Option<bool>,
88    pub mouse_sgr: Option<bool>,
89
90    // Optional features
91    pub osc52_clipboard: Option<bool>,
92}
93
94impl CapabilityOverride {
95    /// Create a new empty override (no fields overridden).
96    #[must_use]
97    pub const fn new() -> Self {
98        Self {
99            true_color: None,
100            colors_256: None,
101            sync_output: None,
102            osc8_hyperlinks: None,
103            scroll_region: None,
104            in_tmux: None,
105            in_screen: None,
106            in_zellij: None,
107            kitty_keyboard: None,
108            focus_events: None,
109            bracketed_paste: None,
110            mouse_sgr: None,
111            osc52_clipboard: None,
112        }
113    }
114
115    /// Create an override that disables all capabilities (dumb terminal).
116    #[must_use]
117    pub const fn dumb() -> Self {
118        Self {
119            true_color: Some(false),
120            colors_256: Some(false),
121            sync_output: Some(false),
122            osc8_hyperlinks: Some(false),
123            scroll_region: Some(false),
124            in_tmux: Some(false),
125            in_screen: Some(false),
126            in_zellij: Some(false),
127            kitty_keyboard: Some(false),
128            focus_events: Some(false),
129            bracketed_paste: Some(false),
130            mouse_sgr: Some(false),
131            osc52_clipboard: Some(false),
132        }
133    }
134
135    /// Create an override that enables all capabilities (modern terminal).
136    #[must_use]
137    pub const fn modern() -> Self {
138        Self {
139            true_color: Some(true),
140            colors_256: Some(true),
141            sync_output: Some(true),
142            osc8_hyperlinks: Some(true),
143            scroll_region: Some(true),
144            in_tmux: Some(false),
145            in_screen: Some(false),
146            in_zellij: Some(false),
147            kitty_keyboard: Some(true),
148            focus_events: Some(true),
149            bracketed_paste: Some(true),
150            mouse_sgr: Some(true),
151            osc52_clipboard: Some(true),
152        }
153    }
154
155    /// Create an override that simulates running inside tmux.
156    #[must_use]
157    pub const fn tmux() -> Self {
158        Self {
159            true_color: None,
160            colors_256: Some(true),
161            sync_output: Some(false),
162            osc8_hyperlinks: Some(false),
163            scroll_region: Some(true),
164            in_tmux: Some(true),
165            in_screen: Some(false),
166            in_zellij: Some(false),
167            kitty_keyboard: Some(false),
168            focus_events: Some(false),
169            bracketed_paste: Some(true),
170            mouse_sgr: Some(true),
171            osc52_clipboard: Some(false),
172        }
173    }
174
175    // ── Builder Methods ────────────────────────────────────────────────
176
177    /// Override true color support.
178    #[must_use]
179    pub const fn true_color(mut self, value: Option<bool>) -> Self {
180        self.true_color = value;
181        self
182    }
183
184    /// Override 256-color support.
185    #[must_use]
186    pub const fn colors_256(mut self, value: Option<bool>) -> Self {
187        self.colors_256 = value;
188        self
189    }
190
191    /// Override synchronized output support.
192    #[must_use]
193    pub const fn sync_output(mut self, value: Option<bool>) -> Self {
194        self.sync_output = value;
195        self
196    }
197
198    /// Override OSC 8 hyperlinks support.
199    #[must_use]
200    pub const fn osc8_hyperlinks(mut self, value: Option<bool>) -> Self {
201        self.osc8_hyperlinks = value;
202        self
203    }
204
205    /// Override scroll region support.
206    #[must_use]
207    pub const fn scroll_region(mut self, value: Option<bool>) -> Self {
208        self.scroll_region = value;
209        self
210    }
211
212    /// Override tmux detection.
213    #[must_use]
214    pub const fn in_tmux(mut self, value: Option<bool>) -> Self {
215        self.in_tmux = value;
216        self
217    }
218
219    /// Override GNU screen detection.
220    #[must_use]
221    pub const fn in_screen(mut self, value: Option<bool>) -> Self {
222        self.in_screen = value;
223        self
224    }
225
226    /// Override Zellij detection.
227    #[must_use]
228    pub const fn in_zellij(mut self, value: Option<bool>) -> Self {
229        self.in_zellij = value;
230        self
231    }
232
233    /// Override Kitty keyboard protocol support.
234    #[must_use]
235    pub const fn kitty_keyboard(mut self, value: Option<bool>) -> Self {
236        self.kitty_keyboard = value;
237        self
238    }
239
240    /// Override focus events support.
241    #[must_use]
242    pub const fn focus_events(mut self, value: Option<bool>) -> Self {
243        self.focus_events = value;
244        self
245    }
246
247    /// Override bracketed paste mode support.
248    #[must_use]
249    pub const fn bracketed_paste(mut self, value: Option<bool>) -> Self {
250        self.bracketed_paste = value;
251        self
252    }
253
254    /// Override SGR mouse protocol support.
255    #[must_use]
256    pub const fn mouse_sgr(mut self, value: Option<bool>) -> Self {
257        self.mouse_sgr = value;
258        self
259    }
260
261    /// Override OSC 52 clipboard support.
262    #[must_use]
263    pub const fn osc52_clipboard(mut self, value: Option<bool>) -> Self {
264        self.osc52_clipboard = value;
265        self
266    }
267
268    /// Check if any capability is overridden.
269    #[must_use]
270    pub const fn is_empty(&self) -> bool {
271        self.true_color.is_none()
272            && self.colors_256.is_none()
273            && self.sync_output.is_none()
274            && self.osc8_hyperlinks.is_none()
275            && self.scroll_region.is_none()
276            && self.in_tmux.is_none()
277            && self.in_screen.is_none()
278            && self.in_zellij.is_none()
279            && self.kitty_keyboard.is_none()
280            && self.focus_events.is_none()
281            && self.bracketed_paste.is_none()
282            && self.mouse_sgr.is_none()
283            && self.osc52_clipboard.is_none()
284    }
285
286    /// Apply this override on top of base capabilities.
287    #[must_use]
288    pub fn apply_to(&self, mut caps: TerminalCapabilities) -> TerminalCapabilities {
289        if let Some(v) = self.true_color {
290            caps.true_color = v;
291        }
292        if let Some(v) = self.colors_256 {
293            caps.colors_256 = v;
294        }
295        if let Some(v) = self.sync_output {
296            caps.sync_output = v;
297        }
298        if let Some(v) = self.osc8_hyperlinks {
299            caps.osc8_hyperlinks = v;
300        }
301        if let Some(v) = self.scroll_region {
302            caps.scroll_region = v;
303        }
304        if let Some(v) = self.in_tmux {
305            caps.in_tmux = v;
306        }
307        if let Some(v) = self.in_screen {
308            caps.in_screen = v;
309        }
310        if let Some(v) = self.in_zellij {
311            caps.in_zellij = v;
312        }
313        if let Some(v) = self.kitty_keyboard {
314            caps.kitty_keyboard = v;
315        }
316        if let Some(v) = self.focus_events {
317            caps.focus_events = v;
318        }
319        if let Some(v) = self.bracketed_paste {
320            caps.bracketed_paste = v;
321        }
322        if let Some(v) = self.mouse_sgr {
323            caps.mouse_sgr = v;
324        }
325        if let Some(v) = self.osc52_clipboard {
326            caps.osc52_clipboard = v;
327        }
328        caps
329    }
330}
331
332// ============================================================================
333// Thread-Local Override Stack
334// ============================================================================
335
336thread_local! {
337    /// Stack of active capability overrides for this thread.
338    static OVERRIDE_STACK: RefCell<Vec<CapabilityOverride>> = const { RefCell::new(Vec::new()) };
339}
340
341/// RAII guard that removes an override when dropped.
342///
343/// Do not leak this guard - it must be dropped to restore the previous state.
344#[must_use]
345pub struct OverrideGuard {
346    /// Marker to prevent Send/Sync (thread-local data)
347    _marker: std::marker::PhantomData<*const ()>,
348}
349
350impl Drop for OverrideGuard {
351    fn drop(&mut self) {
352        // Silently ignore if stack is empty - this can happen if clear_all_overrides()
353        // was called while guards were still active. This is documented behavior.
354        OVERRIDE_STACK.with(|stack| {
355            stack.borrow_mut().pop();
356        });
357    }
358}
359
360/// Push an override onto the thread-local stack.
361///
362/// Returns a guard that will pop the override when dropped.
363///
364/// # Example
365///
366/// ```
367/// use ftui_core::capability_override::{push_override, CapabilityOverride};
368///
369/// let _guard = push_override(CapabilityOverride::dumb());
370/// // Override is active here
371/// // Automatically removed when _guard is dropped
372/// ```
373#[must_use = "the override is removed when the guard is dropped"]
374pub fn push_override(over: CapabilityOverride) -> OverrideGuard {
375    OVERRIDE_STACK.with(|stack| {
376        stack.borrow_mut().push(over);
377    });
378    OverrideGuard {
379        _marker: std::marker::PhantomData,
380    }
381}
382
383/// Execute a closure with a capability override active.
384///
385/// The override is automatically removed when the closure returns,
386/// even if it panics.
387///
388/// # Example
389///
390/// ```
391/// use ftui_core::capability_override::{with_capability_override, CapabilityOverride};
392/// use ftui_core::terminal_capabilities::TerminalCapabilities;
393///
394/// with_capability_override(CapabilityOverride::dumb(), || {
395///     let caps = TerminalCapabilities::with_overrides();
396///     assert!(!caps.true_color);
397/// });
398/// ```
399pub fn with_capability_override<F, R>(over: CapabilityOverride, f: F) -> R
400where
401    F: FnOnce() -> R,
402{
403    let _guard = push_override(over);
404    f()
405}
406
407/// Get the current effective capabilities with all overrides applied.
408///
409/// This starts with `TerminalCapabilities::detect()` and applies each
410/// override in the stack from bottom to top.
411#[must_use]
412pub fn current_capabilities() -> TerminalCapabilities {
413    let base = TerminalCapabilities::detect();
414    current_capabilities_with_base(base)
415}
416
417/// Get effective capabilities starting from a specified base.
418#[must_use]
419pub fn current_capabilities_with_base(base: TerminalCapabilities) -> TerminalCapabilities {
420    OVERRIDE_STACK.with(|stack| {
421        let stack = stack.borrow();
422        stack.iter().fold(base, |caps, over| over.apply_to(caps))
423    })
424}
425
426/// Check if any overrides are currently active on this thread.
427#[must_use]
428pub fn has_active_overrides() -> bool {
429    OVERRIDE_STACK.with(|stack| !stack.borrow().is_empty())
430}
431
432/// Get the number of active overrides on this thread.
433#[must_use]
434pub fn override_depth() -> usize {
435    OVERRIDE_STACK.with(|stack| stack.borrow().len())
436}
437
438/// Clear all overrides on this thread.
439///
440/// **Warning**: This bypasses RAII guards and should only be used for
441/// cleanup in test harnesses, not in production code.
442pub fn clear_all_overrides() {
443    OVERRIDE_STACK.with(|stack| {
444        stack.borrow_mut().clear();
445    });
446}
447
448// ============================================================================
449// Extension to TerminalCapabilities
450// ============================================================================
451
452impl TerminalCapabilities {
453    /// Detect capabilities and apply any active thread-local overrides.
454    ///
455    /// This is the recommended way to get capabilities in code that may
456    /// be running under test with overrides.
457    #[must_use]
458    pub fn with_overrides() -> Self {
459        current_capabilities()
460    }
461
462    /// Apply overrides to these capabilities.
463    #[must_use]
464    pub fn with_overrides_from(self, base: Self) -> Self {
465        current_capabilities_with_base(base)
466    }
467}
468
469// ============================================================================
470// Tests
471// ============================================================================
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn override_new_is_empty() {
479        let over = CapabilityOverride::new();
480        assert!(over.is_empty());
481    }
482
483    #[test]
484    fn override_dumb_disables_all() {
485        let over = CapabilityOverride::dumb();
486        assert!(!over.is_empty());
487        assert_eq!(over.true_color, Some(false));
488        assert_eq!(over.colors_256, Some(false));
489        assert_eq!(over.sync_output, Some(false));
490        assert_eq!(over.mouse_sgr, Some(false));
491    }
492
493    #[test]
494    fn override_modern_enables_all() {
495        let over = CapabilityOverride::modern();
496        assert_eq!(over.true_color, Some(true));
497        assert_eq!(over.colors_256, Some(true));
498        assert_eq!(over.sync_output, Some(true));
499        assert_eq!(over.kitty_keyboard, Some(true));
500        // But mux flags are false
501        assert_eq!(over.in_tmux, Some(false));
502    }
503
504    #[test]
505    fn override_tmux_sets_mux() {
506        let over = CapabilityOverride::tmux();
507        assert_eq!(over.in_tmux, Some(true));
508        assert_eq!(over.sync_output, Some(false));
509        assert_eq!(over.osc52_clipboard, Some(false));
510    }
511
512    #[test]
513    fn override_builder_chain() {
514        let over = CapabilityOverride::new()
515            .true_color(Some(true))
516            .colors_256(Some(true))
517            .mouse_sgr(Some(false));
518
519        assert_eq!(over.true_color, Some(true));
520        assert_eq!(over.colors_256, Some(true));
521        assert_eq!(over.mouse_sgr, Some(false));
522        assert!(over.sync_output.is_none());
523    }
524
525    #[test]
526    fn apply_to_overrides_caps() {
527        let base = TerminalCapabilities::dumb();
528        let over = CapabilityOverride::new()
529            .true_color(Some(true))
530            .colors_256(Some(true));
531
532        let result = over.apply_to(base);
533        assert!(result.true_color);
534        assert!(result.colors_256);
535        // Unchanged fields remain from base
536        assert!(!result.mouse_sgr);
537    }
538
539    #[test]
540    fn apply_to_none_keeps_original() {
541        let base = TerminalCapabilities::modern();
542        let over = CapabilityOverride::new(); // All None
543
544        let result = over.apply_to(base);
545        assert_eq!(result.true_color, base.true_color);
546        assert_eq!(result.mouse_sgr, base.mouse_sgr);
547    }
548
549    #[test]
550    fn push_pop_override() {
551        clear_all_overrides();
552        assert!(!has_active_overrides());
553        assert_eq!(override_depth(), 0);
554
555        {
556            let _guard = push_override(CapabilityOverride::dumb());
557            assert!(has_active_overrides());
558            assert_eq!(override_depth(), 1);
559        }
560
561        assert!(!has_active_overrides());
562        assert_eq!(override_depth(), 0);
563    }
564
565    #[test]
566    fn nested_overrides() {
567        clear_all_overrides();
568
569        {
570            let _outer = push_override(
571                CapabilityOverride::new()
572                    .true_color(Some(true))
573                    .mouse_sgr(Some(true)),
574            );
575            assert_eq!(override_depth(), 1);
576
577            {
578                let _inner = push_override(CapabilityOverride::new().true_color(Some(false)));
579                assert_eq!(override_depth(), 2);
580
581                // Inner override takes precedence
582                let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
583                assert!(!caps.true_color); // Inner: false
584                assert!(caps.mouse_sgr); // Outer: true
585            }
586
587            // Inner dropped, outer still active
588            assert_eq!(override_depth(), 1);
589            let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
590            assert!(caps.true_color); // Outer: true
591        }
592
593        assert_eq!(override_depth(), 0);
594    }
595
596    #[test]
597    fn with_capability_override_scope() {
598        clear_all_overrides();
599
600        let result = with_capability_override(CapabilityOverride::modern(), || {
601            assert!(has_active_overrides());
602            let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
603            caps.true_color
604        });
605
606        assert!(result);
607        assert!(!has_active_overrides());
608    }
609
610    #[test]
611    fn with_capability_override_nested() {
612        clear_all_overrides();
613
614        with_capability_override(CapabilityOverride::new().true_color(Some(true)), || {
615            with_capability_override(CapabilityOverride::new().mouse_sgr(Some(false)), || {
616                let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
617                assert!(caps.true_color);
618                assert!(!caps.mouse_sgr);
619            });
620        });
621    }
622
623    #[test]
624    fn with_overrides_method() {
625        clear_all_overrides();
626
627        with_capability_override(CapabilityOverride::dumb(), || {
628            let caps = TerminalCapabilities::with_overrides();
629            assert!(!caps.true_color);
630            assert!(!caps.colors_256);
631        });
632    }
633
634    #[test]
635    fn clear_all_overrides_works() {
636        let _g1 = push_override(CapabilityOverride::dumb());
637        let _g2 = push_override(CapabilityOverride::modern());
638        assert_eq!(override_depth(), 2);
639
640        clear_all_overrides();
641        assert_eq!(override_depth(), 0);
642    }
643
644    #[test]
645    fn default_override_is_empty() {
646        let over = CapabilityOverride::default();
647        assert!(over.is_empty());
648    }
649}