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    // Glyph support
75    pub unicode_box_drawing: Option<bool>,
76    pub unicode_emoji: Option<bool>,
77    pub double_width: Option<bool>,
78
79    // Advanced features
80    pub sync_output: Option<bool>,
81    pub osc8_hyperlinks: Option<bool>,
82    pub scroll_region: Option<bool>,
83
84    // Multiplexer flags
85    pub in_tmux: Option<bool>,
86    pub in_screen: Option<bool>,
87    pub in_zellij: Option<bool>,
88
89    // Input features
90    pub kitty_keyboard: Option<bool>,
91    pub focus_events: Option<bool>,
92    pub bracketed_paste: Option<bool>,
93    pub mouse_sgr: Option<bool>,
94
95    // Optional features
96    pub osc52_clipboard: Option<bool>,
97}
98
99impl CapabilityOverride {
100    /// Create a new empty override (no fields overridden).
101    #[must_use]
102    pub const fn new() -> Self {
103        Self {
104            true_color: None,
105            colors_256: None,
106            unicode_box_drawing: None,
107            unicode_emoji: None,
108            double_width: None,
109            sync_output: None,
110            osc8_hyperlinks: None,
111            scroll_region: None,
112            in_tmux: None,
113            in_screen: None,
114            in_zellij: None,
115            kitty_keyboard: None,
116            focus_events: None,
117            bracketed_paste: None,
118            mouse_sgr: None,
119            osc52_clipboard: None,
120        }
121    }
122
123    /// Create an override that disables all capabilities (dumb terminal).
124    #[must_use]
125    pub const fn dumb() -> Self {
126        Self {
127            true_color: Some(false),
128            colors_256: Some(false),
129            unicode_box_drawing: Some(false),
130            unicode_emoji: Some(false),
131            double_width: Some(false),
132            sync_output: Some(false),
133            osc8_hyperlinks: Some(false),
134            scroll_region: Some(false),
135            in_tmux: Some(false),
136            in_screen: Some(false),
137            in_zellij: Some(false),
138            kitty_keyboard: Some(false),
139            focus_events: Some(false),
140            bracketed_paste: Some(false),
141            mouse_sgr: Some(false),
142            osc52_clipboard: Some(false),
143        }
144    }
145
146    /// Create an override that enables all capabilities (modern terminal).
147    #[must_use]
148    pub const fn modern() -> Self {
149        Self {
150            true_color: Some(true),
151            colors_256: Some(true),
152            unicode_box_drawing: Some(true),
153            unicode_emoji: Some(true),
154            double_width: Some(true),
155            sync_output: Some(true),
156            osc8_hyperlinks: Some(true),
157            scroll_region: Some(true),
158            in_tmux: Some(false),
159            in_screen: Some(false),
160            in_zellij: Some(false),
161            kitty_keyboard: Some(true),
162            focus_events: Some(true),
163            bracketed_paste: Some(true),
164            mouse_sgr: Some(true),
165            osc52_clipboard: Some(true),
166        }
167    }
168
169    /// Create an override that simulates running inside tmux.
170    #[must_use]
171    pub const fn tmux() -> Self {
172        Self {
173            true_color: None,
174            colors_256: Some(true),
175            unicode_box_drawing: None,
176            unicode_emoji: None,
177            double_width: None,
178            sync_output: Some(false),
179            osc8_hyperlinks: Some(false),
180            scroll_region: Some(true),
181            in_tmux: Some(true),
182            in_screen: Some(false),
183            in_zellij: Some(false),
184            kitty_keyboard: Some(false),
185            focus_events: Some(false),
186            bracketed_paste: Some(true),
187            mouse_sgr: Some(true),
188            osc52_clipboard: Some(false),
189        }
190    }
191
192    // ── Builder Methods ────────────────────────────────────────────────
193
194    /// Override true color support.
195    #[must_use]
196    pub const fn true_color(mut self, value: Option<bool>) -> Self {
197        self.true_color = value;
198        self
199    }
200
201    /// Override 256-color support.
202    #[must_use]
203    pub const fn colors_256(mut self, value: Option<bool>) -> Self {
204        self.colors_256 = value;
205        self
206    }
207
208    /// Override Unicode box drawing support.
209    #[must_use]
210    pub const fn unicode_box_drawing(mut self, value: Option<bool>) -> Self {
211        self.unicode_box_drawing = value;
212        self
213    }
214
215    /// Override emoji glyph support.
216    #[must_use]
217    pub const fn unicode_emoji(mut self, value: Option<bool>) -> Self {
218        self.unicode_emoji = value;
219        self
220    }
221
222    /// Override double-width glyph support.
223    #[must_use]
224    pub const fn double_width(mut self, value: Option<bool>) -> Self {
225        self.double_width = value;
226        self
227    }
228
229    /// Override synchronized output support.
230    #[must_use]
231    pub const fn sync_output(mut self, value: Option<bool>) -> Self {
232        self.sync_output = value;
233        self
234    }
235
236    /// Override OSC 8 hyperlinks support.
237    #[must_use]
238    pub const fn osc8_hyperlinks(mut self, value: Option<bool>) -> Self {
239        self.osc8_hyperlinks = value;
240        self
241    }
242
243    /// Override scroll region support.
244    #[must_use]
245    pub const fn scroll_region(mut self, value: Option<bool>) -> Self {
246        self.scroll_region = value;
247        self
248    }
249
250    /// Override tmux detection.
251    #[must_use]
252    pub const fn in_tmux(mut self, value: Option<bool>) -> Self {
253        self.in_tmux = value;
254        self
255    }
256
257    /// Override GNU screen detection.
258    #[must_use]
259    pub const fn in_screen(mut self, value: Option<bool>) -> Self {
260        self.in_screen = value;
261        self
262    }
263
264    /// Override Zellij detection.
265    #[must_use]
266    pub const fn in_zellij(mut self, value: Option<bool>) -> Self {
267        self.in_zellij = value;
268        self
269    }
270
271    /// Override Kitty keyboard protocol support.
272    #[must_use]
273    pub const fn kitty_keyboard(mut self, value: Option<bool>) -> Self {
274        self.kitty_keyboard = value;
275        self
276    }
277
278    /// Override focus events support.
279    #[must_use]
280    pub const fn focus_events(mut self, value: Option<bool>) -> Self {
281        self.focus_events = value;
282        self
283    }
284
285    /// Override bracketed paste mode support.
286    #[must_use]
287    pub const fn bracketed_paste(mut self, value: Option<bool>) -> Self {
288        self.bracketed_paste = value;
289        self
290    }
291
292    /// Override SGR mouse protocol support.
293    #[must_use]
294    pub const fn mouse_sgr(mut self, value: Option<bool>) -> Self {
295        self.mouse_sgr = value;
296        self
297    }
298
299    /// Override OSC 52 clipboard support.
300    #[must_use]
301    pub const fn osc52_clipboard(mut self, value: Option<bool>) -> Self {
302        self.osc52_clipboard = value;
303        self
304    }
305
306    /// Check if any capability is overridden.
307    #[must_use]
308    pub const fn is_empty(&self) -> bool {
309        self.true_color.is_none()
310            && self.colors_256.is_none()
311            && self.unicode_box_drawing.is_none()
312            && self.unicode_emoji.is_none()
313            && self.double_width.is_none()
314            && self.sync_output.is_none()
315            && self.osc8_hyperlinks.is_none()
316            && self.scroll_region.is_none()
317            && self.in_tmux.is_none()
318            && self.in_screen.is_none()
319            && self.in_zellij.is_none()
320            && self.kitty_keyboard.is_none()
321            && self.focus_events.is_none()
322            && self.bracketed_paste.is_none()
323            && self.mouse_sgr.is_none()
324            && self.osc52_clipboard.is_none()
325    }
326
327    /// Apply this override on top of base capabilities.
328    #[must_use]
329    pub fn apply_to(&self, mut caps: TerminalCapabilities) -> TerminalCapabilities {
330        if let Some(v) = self.true_color {
331            caps.true_color = v;
332        }
333        if let Some(v) = self.colors_256 {
334            caps.colors_256 = v;
335        }
336        if let Some(v) = self.unicode_box_drawing {
337            caps.unicode_box_drawing = v;
338        }
339        if let Some(v) = self.unicode_emoji {
340            caps.unicode_emoji = v;
341        }
342        if let Some(v) = self.double_width {
343            caps.double_width = v;
344        }
345        if let Some(v) = self.sync_output {
346            caps.sync_output = v;
347        }
348        if let Some(v) = self.osc8_hyperlinks {
349            caps.osc8_hyperlinks = v;
350        }
351        if let Some(v) = self.scroll_region {
352            caps.scroll_region = v;
353        }
354        if let Some(v) = self.in_tmux {
355            caps.in_tmux = v;
356        }
357        if let Some(v) = self.in_screen {
358            caps.in_screen = v;
359        }
360        if let Some(v) = self.in_zellij {
361            caps.in_zellij = v;
362        }
363        if let Some(v) = self.kitty_keyboard {
364            caps.kitty_keyboard = v;
365        }
366        if let Some(v) = self.focus_events {
367            caps.focus_events = v;
368        }
369        if let Some(v) = self.bracketed_paste {
370            caps.bracketed_paste = v;
371        }
372        if let Some(v) = self.mouse_sgr {
373            caps.mouse_sgr = v;
374        }
375        if let Some(v) = self.osc52_clipboard {
376            caps.osc52_clipboard = v;
377        }
378        caps
379    }
380}
381
382// ============================================================================
383// Thread-Local Override Stack
384// ============================================================================
385
386thread_local! {
387    /// Stack of active capability overrides for this thread.
388    static OVERRIDE_STACK: RefCell<Vec<CapabilityOverride>> = const { RefCell::new(Vec::new()) };
389}
390
391/// RAII guard that removes an override when dropped.
392///
393/// Do not leak this guard - it must be dropped to restore the previous state.
394#[must_use]
395pub struct OverrideGuard {
396    /// Marker to prevent Send/Sync (thread-local data)
397    _marker: std::marker::PhantomData<*const ()>,
398}
399
400impl Drop for OverrideGuard {
401    fn drop(&mut self) {
402        // Silently ignore if stack is empty - this can happen if clear_all_overrides()
403        // was called while guards were still active. This is documented behavior.
404        OVERRIDE_STACK.with(|stack| {
405            stack.borrow_mut().pop();
406        });
407    }
408}
409
410/// Push an override onto the thread-local stack.
411///
412/// Returns a guard that will pop the override when dropped.
413///
414/// # Example
415///
416/// ```
417/// use ftui_core::capability_override::{push_override, CapabilityOverride};
418///
419/// let _guard = push_override(CapabilityOverride::dumb());
420/// // Override is active here
421/// // Automatically removed when _guard is dropped
422/// ```
423#[must_use = "the override is removed when the guard is dropped"]
424pub fn push_override(over: CapabilityOverride) -> OverrideGuard {
425    OVERRIDE_STACK.with(|stack| {
426        stack.borrow_mut().push(over);
427    });
428    OverrideGuard {
429        _marker: std::marker::PhantomData,
430    }
431}
432
433/// Execute a closure with a capability override active.
434///
435/// The override is automatically removed when the closure returns,
436/// even if it panics.
437///
438/// # Example
439///
440/// ```
441/// use ftui_core::capability_override::{with_capability_override, CapabilityOverride};
442/// use ftui_core::terminal_capabilities::TerminalCapabilities;
443///
444/// with_capability_override(CapabilityOverride::dumb(), || {
445///     let caps = TerminalCapabilities::with_overrides();
446///     assert!(!caps.true_color);
447/// });
448/// ```
449pub fn with_capability_override<F, R>(over: CapabilityOverride, f: F) -> R
450where
451    F: FnOnce() -> R,
452{
453    let _guard = push_override(over);
454    f()
455}
456
457/// Get the current effective capabilities with all overrides applied.
458///
459/// This starts with `TerminalCapabilities::detect()` and applies each
460/// override in the stack from bottom to top.
461#[must_use]
462pub fn current_capabilities() -> TerminalCapabilities {
463    let base = TerminalCapabilities::detect();
464    current_capabilities_with_base(base)
465}
466
467/// Get effective capabilities starting from a specified base.
468#[must_use]
469pub fn current_capabilities_with_base(base: TerminalCapabilities) -> TerminalCapabilities {
470    OVERRIDE_STACK.with(|stack| {
471        let stack = stack.borrow();
472        stack.iter().fold(base, |caps, over| over.apply_to(caps))
473    })
474}
475
476/// Check if any overrides are currently active on this thread.
477#[must_use]
478pub fn has_active_overrides() -> bool {
479    OVERRIDE_STACK.with(|stack| !stack.borrow().is_empty())
480}
481
482/// Get the number of active overrides on this thread.
483#[must_use]
484pub fn override_depth() -> usize {
485    OVERRIDE_STACK.with(|stack| stack.borrow().len())
486}
487
488/// Clear all overrides on this thread.
489///
490/// **Warning**: This bypasses RAII guards and should only be used for
491/// cleanup in test harnesses, not in production code.
492pub fn clear_all_overrides() {
493    OVERRIDE_STACK.with(|stack| {
494        stack.borrow_mut().clear();
495    });
496}
497
498// ============================================================================
499// Extension to TerminalCapabilities
500// ============================================================================
501
502impl TerminalCapabilities {
503    /// Detect capabilities and apply any active thread-local overrides.
504    ///
505    /// This is the recommended way to get capabilities in code that may
506    /// be running under test with overrides.
507    #[must_use]
508    pub fn with_overrides() -> Self {
509        current_capabilities()
510    }
511
512    /// Apply overrides to these capabilities.
513    #[must_use]
514    pub fn with_overrides_from(self, base: Self) -> Self {
515        current_capabilities_with_base(base)
516    }
517}
518
519// ============================================================================
520// Tests
521// ============================================================================
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn override_new_is_empty() {
529        let over = CapabilityOverride::new();
530        assert!(over.is_empty());
531    }
532
533    #[test]
534    fn override_dumb_disables_all() {
535        let over = CapabilityOverride::dumb();
536        assert!(!over.is_empty());
537        assert_eq!(over.true_color, Some(false));
538        assert_eq!(over.colors_256, Some(false));
539        assert_eq!(over.sync_output, Some(false));
540        assert_eq!(over.mouse_sgr, Some(false));
541    }
542
543    #[test]
544    fn override_modern_enables_all() {
545        let over = CapabilityOverride::modern();
546        assert_eq!(over.true_color, Some(true));
547        assert_eq!(over.colors_256, Some(true));
548        assert_eq!(over.sync_output, Some(true));
549        assert_eq!(over.kitty_keyboard, Some(true));
550        // But mux flags are false
551        assert_eq!(over.in_tmux, Some(false));
552    }
553
554    #[test]
555    fn override_tmux_sets_mux() {
556        let over = CapabilityOverride::tmux();
557        assert_eq!(over.in_tmux, Some(true));
558        assert_eq!(over.sync_output, Some(false));
559        assert_eq!(over.osc52_clipboard, Some(false));
560    }
561
562    #[test]
563    fn override_builder_chain() {
564        let over = CapabilityOverride::new()
565            .true_color(Some(true))
566            .colors_256(Some(true))
567            .unicode_box_drawing(Some(false))
568            .mouse_sgr(Some(false));
569
570        assert_eq!(over.true_color, Some(true));
571        assert_eq!(over.colors_256, Some(true));
572        assert_eq!(over.unicode_box_drawing, Some(false));
573        assert_eq!(over.mouse_sgr, Some(false));
574        assert!(over.sync_output.is_none());
575    }
576
577    #[test]
578    fn apply_to_overrides_caps() {
579        let base = TerminalCapabilities::dumb();
580        let over = CapabilityOverride::new()
581            .true_color(Some(true))
582            .colors_256(Some(true))
583            .unicode_box_drawing(Some(true));
584
585        let result = over.apply_to(base);
586        assert!(result.true_color);
587        assert!(result.colors_256);
588        assert!(result.unicode_box_drawing);
589        // Unchanged fields remain from base
590        assert!(!result.mouse_sgr);
591    }
592
593    #[test]
594    fn apply_to_none_keeps_original() {
595        let base = TerminalCapabilities::modern();
596        let over = CapabilityOverride::new(); // All None
597
598        let result = over.apply_to(base);
599        assert_eq!(result.true_color, base.true_color);
600        assert_eq!(result.mouse_sgr, base.mouse_sgr);
601    }
602
603    #[test]
604    fn push_pop_override() {
605        clear_all_overrides();
606        assert!(!has_active_overrides());
607        assert_eq!(override_depth(), 0);
608
609        {
610            let _guard = push_override(CapabilityOverride::dumb());
611            assert!(has_active_overrides());
612            assert_eq!(override_depth(), 1);
613        }
614
615        assert!(!has_active_overrides());
616        assert_eq!(override_depth(), 0);
617    }
618
619    #[test]
620    fn nested_overrides() {
621        clear_all_overrides();
622
623        {
624            let _outer = push_override(
625                CapabilityOverride::new()
626                    .true_color(Some(true))
627                    .mouse_sgr(Some(true)),
628            );
629            assert_eq!(override_depth(), 1);
630
631            {
632                let _inner = push_override(CapabilityOverride::new().true_color(Some(false)));
633                assert_eq!(override_depth(), 2);
634
635                // Inner override takes precedence
636                let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
637                assert!(!caps.true_color); // Inner: false
638                assert!(caps.mouse_sgr); // Outer: true
639            }
640
641            // Inner dropped, outer still active
642            assert_eq!(override_depth(), 1);
643            let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
644            assert!(caps.true_color); // Outer: true
645        }
646
647        assert_eq!(override_depth(), 0);
648    }
649
650    #[test]
651    fn with_capability_override_scope() {
652        clear_all_overrides();
653
654        let result = with_capability_override(CapabilityOverride::modern(), || {
655            assert!(has_active_overrides());
656            let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
657            caps.true_color
658        });
659
660        assert!(result);
661        assert!(!has_active_overrides());
662    }
663
664    #[test]
665    fn with_capability_override_nested() {
666        clear_all_overrides();
667
668        with_capability_override(CapabilityOverride::new().true_color(Some(true)), || {
669            with_capability_override(CapabilityOverride::new().mouse_sgr(Some(false)), || {
670                let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
671                assert!(caps.true_color);
672                assert!(!caps.mouse_sgr);
673            });
674        });
675    }
676
677    #[test]
678    fn with_overrides_method() {
679        clear_all_overrides();
680
681        with_capability_override(CapabilityOverride::dumb(), || {
682            let caps = TerminalCapabilities::with_overrides();
683            assert!(!caps.true_color);
684            assert!(!caps.colors_256);
685            assert!(!caps.unicode_box_drawing);
686            assert!(!caps.unicode_emoji);
687            assert!(!caps.double_width);
688        });
689    }
690
691    #[test]
692    fn clear_all_overrides_works() {
693        let _g1 = push_override(CapabilityOverride::dumb());
694        let _g2 = push_override(CapabilityOverride::modern());
695        assert_eq!(override_depth(), 2);
696
697        clear_all_overrides();
698        assert_eq!(override_depth(), 0);
699    }
700
701    #[test]
702    fn default_override_is_empty() {
703        let over = CapabilityOverride::default();
704        assert!(over.is_empty());
705    }
706
707    #[test]
708    fn is_empty_false_for_single_override() {
709        let over = CapabilityOverride::new().true_color(Some(true));
710        assert!(!over.is_empty());
711    }
712
713    #[test]
714    fn dumb_disables_all_fields() {
715        let over = CapabilityOverride::dumb();
716        assert_eq!(over.unicode_box_drawing, Some(false));
717        assert_eq!(over.unicode_emoji, Some(false));
718        assert_eq!(over.double_width, Some(false));
719        assert_eq!(over.osc8_hyperlinks, Some(false));
720        assert_eq!(over.scroll_region, Some(false));
721        assert_eq!(over.kitty_keyboard, Some(false));
722        assert_eq!(over.focus_events, Some(false));
723        assert_eq!(over.bracketed_paste, Some(false));
724        assert_eq!(over.osc52_clipboard, Some(false));
725        assert_eq!(over.in_tmux, Some(false));
726        assert_eq!(over.in_screen, Some(false));
727        assert_eq!(over.in_zellij, Some(false));
728    }
729
730    #[test]
731    fn modern_enables_features_disables_mux() {
732        let over = CapabilityOverride::modern();
733        assert_eq!(over.unicode_box_drawing, Some(true));
734        assert_eq!(over.unicode_emoji, Some(true));
735        assert_eq!(over.double_width, Some(true));
736        assert_eq!(over.osc8_hyperlinks, Some(true));
737        assert_eq!(over.scroll_region, Some(true));
738        assert_eq!(over.focus_events, Some(true));
739        assert_eq!(over.bracketed_paste, Some(true));
740        assert_eq!(over.osc52_clipboard, Some(true));
741        assert_eq!(over.in_screen, Some(false));
742        assert_eq!(over.in_zellij, Some(false));
743    }
744
745    #[test]
746    fn tmux_sets_bracketed_paste_and_colors() {
747        let over = CapabilityOverride::tmux();
748        assert_eq!(over.colors_256, Some(true));
749        assert_eq!(over.bracketed_paste, Some(true));
750        assert_eq!(over.mouse_sgr, Some(true));
751        assert_eq!(over.scroll_region, Some(true));
752        assert_eq!(over.kitty_keyboard, Some(false));
753    }
754
755    #[test]
756    fn builder_all_optional_features() {
757        let over = CapabilityOverride::new()
758            .unicode_emoji(Some(true))
759            .double_width(Some(false))
760            .in_screen(Some(true))
761            .in_zellij(Some(true))
762            .osc8_hyperlinks(Some(true))
763            .osc52_clipboard(Some(false))
764            .scroll_region(Some(true))
765            .focus_events(Some(true))
766            .bracketed_paste(Some(false))
767            .kitty_keyboard(Some(true));
768
769        assert_eq!(over.unicode_emoji, Some(true));
770        assert_eq!(over.double_width, Some(false));
771        assert_eq!(over.in_screen, Some(true));
772        assert_eq!(over.in_zellij, Some(true));
773        assert_eq!(over.osc8_hyperlinks, Some(true));
774        assert_eq!(over.osc52_clipboard, Some(false));
775        assert_eq!(over.scroll_region, Some(true));
776        assert_eq!(over.focus_events, Some(true));
777        assert_eq!(over.bracketed_paste, Some(false));
778        assert_eq!(over.kitty_keyboard, Some(true));
779    }
780
781    #[test]
782    fn apply_to_covers_all_mux_flags() {
783        let base = TerminalCapabilities::dumb();
784        let over = CapabilityOverride::new()
785            .in_tmux(Some(true))
786            .in_screen(Some(true))
787            .in_zellij(Some(true));
788        let result = over.apply_to(base);
789        assert!(result.in_tmux);
790        assert!(result.in_screen);
791        assert!(result.in_zellij);
792    }
793
794    #[test]
795    fn apply_to_covers_input_features() {
796        let base = TerminalCapabilities::dumb();
797        let over = CapabilityOverride::new()
798            .kitty_keyboard(Some(true))
799            .focus_events(Some(true))
800            .bracketed_paste(Some(true))
801            .osc52_clipboard(Some(true));
802        let result = over.apply_to(base);
803        assert!(result.kitty_keyboard);
804        assert!(result.focus_events);
805        assert!(result.bracketed_paste);
806        assert!(result.osc52_clipboard);
807    }
808
809    #[test]
810    fn current_capabilities_with_base_composes_stack() {
811        clear_all_overrides();
812        let base = TerminalCapabilities::dumb();
813
814        let _g1 = push_override(CapabilityOverride::new().true_color(Some(true)));
815        let _g2 = push_override(CapabilityOverride::new().mouse_sgr(Some(true)));
816
817        let caps = current_capabilities_with_base(base);
818        assert!(caps.true_color);
819        assert!(caps.mouse_sgr);
820        assert!(!caps.colors_256); // Not overridden, remains dumb
821
822        clear_all_overrides();
823    }
824
825    #[test]
826    fn override_clone() {
827        let over = CapabilityOverride::new()
828            .true_color(Some(true))
829            .in_tmux(Some(false));
830        let cloned = over.clone();
831        assert_eq!(over.true_color, cloned.true_color);
832        assert_eq!(over.in_tmux, cloned.in_tmux);
833    }
834
835    // ── is_empty per-field ────────────────────────────────────────────
836
837    #[test]
838    fn is_empty_false_for_colors_256() {
839        assert!(!CapabilityOverride::new().colors_256(Some(true)).is_empty());
840    }
841
842    #[test]
843    fn is_empty_false_for_unicode_box_drawing() {
844        assert!(
845            !CapabilityOverride::new()
846                .unicode_box_drawing(Some(false))
847                .is_empty()
848        );
849    }
850
851    #[test]
852    fn is_empty_false_for_unicode_emoji() {
853        assert!(
854            !CapabilityOverride::new()
855                .unicode_emoji(Some(true))
856                .is_empty()
857        );
858    }
859
860    #[test]
861    fn is_empty_false_for_double_width() {
862        assert!(
863            !CapabilityOverride::new()
864                .double_width(Some(true))
865                .is_empty()
866        );
867    }
868
869    #[test]
870    fn is_empty_false_for_sync_output() {
871        assert!(
872            !CapabilityOverride::new()
873                .sync_output(Some(false))
874                .is_empty()
875        );
876    }
877
878    #[test]
879    fn is_empty_false_for_osc8_hyperlinks() {
880        assert!(
881            !CapabilityOverride::new()
882                .osc8_hyperlinks(Some(true))
883                .is_empty()
884        );
885    }
886
887    #[test]
888    fn is_empty_false_for_scroll_region() {
889        assert!(
890            !CapabilityOverride::new()
891                .scroll_region(Some(true))
892                .is_empty()
893        );
894    }
895
896    #[test]
897    fn is_empty_false_for_in_tmux() {
898        assert!(!CapabilityOverride::new().in_tmux(Some(true)).is_empty());
899    }
900
901    #[test]
902    fn is_empty_false_for_in_screen() {
903        assert!(!CapabilityOverride::new().in_screen(Some(true)).is_empty());
904    }
905
906    #[test]
907    fn is_empty_false_for_in_zellij() {
908        assert!(!CapabilityOverride::new().in_zellij(Some(true)).is_empty());
909    }
910
911    #[test]
912    fn is_empty_false_for_kitty_keyboard() {
913        assert!(
914            !CapabilityOverride::new()
915                .kitty_keyboard(Some(true))
916                .is_empty()
917        );
918    }
919
920    #[test]
921    fn is_empty_false_for_focus_events() {
922        assert!(
923            !CapabilityOverride::new()
924                .focus_events(Some(false))
925                .is_empty()
926        );
927    }
928
929    #[test]
930    fn is_empty_false_for_bracketed_paste() {
931        assert!(
932            !CapabilityOverride::new()
933                .bracketed_paste(Some(true))
934                .is_empty()
935        );
936    }
937
938    #[test]
939    fn is_empty_false_for_mouse_sgr() {
940        assert!(!CapabilityOverride::new().mouse_sgr(Some(true)).is_empty());
941    }
942
943    #[test]
944    fn is_empty_false_for_osc52_clipboard() {
945        assert!(
946            !CapabilityOverride::new()
947                .osc52_clipboard(Some(false))
948                .is_empty()
949        );
950    }
951
952    // ── apply_to remaining fields ─────────────────────────────────────
953
954    #[test]
955    fn apply_to_covers_unicode_emoji() {
956        let base = TerminalCapabilities::dumb();
957        let result = CapabilityOverride::new()
958            .unicode_emoji(Some(true))
959            .apply_to(base);
960        assert!(result.unicode_emoji);
961    }
962
963    #[test]
964    fn apply_to_covers_double_width() {
965        let base = TerminalCapabilities::dumb();
966        let result = CapabilityOverride::new()
967            .double_width(Some(true))
968            .apply_to(base);
969        assert!(result.double_width);
970    }
971
972    #[test]
973    fn apply_to_covers_sync_output() {
974        let base = TerminalCapabilities::dumb();
975        let result = CapabilityOverride::new()
976            .sync_output(Some(true))
977            .apply_to(base);
978        assert!(result.sync_output);
979    }
980
981    #[test]
982    fn apply_to_covers_osc8_hyperlinks() {
983        let base = TerminalCapabilities::dumb();
984        let result = CapabilityOverride::new()
985            .osc8_hyperlinks(Some(true))
986            .apply_to(base);
987        assert!(result.osc8_hyperlinks);
988    }
989
990    #[test]
991    fn apply_to_covers_scroll_region() {
992        let base = TerminalCapabilities::dumb();
993        let result = CapabilityOverride::new()
994            .scroll_region(Some(true))
995            .apply_to(base);
996        assert!(result.scroll_region);
997    }
998
999    #[test]
1000    fn apply_to_covers_mouse_sgr() {
1001        let base = TerminalCapabilities::dumb();
1002        let result = CapabilityOverride::new()
1003            .mouse_sgr(Some(true))
1004            .apply_to(base);
1005        assert!(result.mouse_sgr);
1006    }
1007
1008    // ── apply_to with presets ─────────────────────────────────────────
1009
1010    #[test]
1011    fn dumb_override_disables_all_on_modern_base() {
1012        let base = TerminalCapabilities::modern();
1013        let result = CapabilityOverride::dumb().apply_to(base);
1014        assert!(!result.true_color);
1015        assert!(!result.colors_256);
1016        assert!(!result.unicode_box_drawing);
1017        assert!(!result.unicode_emoji);
1018        assert!(!result.double_width);
1019        assert!(!result.sync_output);
1020        assert!(!result.osc8_hyperlinks);
1021        assert!(!result.scroll_region);
1022        assert!(!result.in_tmux);
1023        assert!(!result.in_screen);
1024        assert!(!result.in_zellij);
1025        assert!(!result.kitty_keyboard);
1026        assert!(!result.focus_events);
1027        assert!(!result.bracketed_paste);
1028        assert!(!result.mouse_sgr);
1029        assert!(!result.osc52_clipboard);
1030    }
1031
1032    #[test]
1033    fn modern_override_enables_features_on_dumb_base() {
1034        let base = TerminalCapabilities::dumb();
1035        let result = CapabilityOverride::modern().apply_to(base);
1036        assert!(result.true_color);
1037        assert!(result.colors_256);
1038        assert!(result.unicode_box_drawing);
1039        assert!(result.unicode_emoji);
1040        assert!(result.double_width);
1041        assert!(result.sync_output);
1042        assert!(result.osc8_hyperlinks);
1043        assert!(result.scroll_region);
1044        // mux flags disabled by modern preset
1045        assert!(!result.in_tmux);
1046        assert!(!result.in_screen);
1047        assert!(!result.in_zellij);
1048        assert!(result.kitty_keyboard);
1049        assert!(result.focus_events);
1050        assert!(result.bracketed_paste);
1051        assert!(result.mouse_sgr);
1052        assert!(result.osc52_clipboard);
1053    }
1054
1055    // ── tmux None fields ──────────────────────────────────────────────
1056
1057    #[test]
1058    fn tmux_none_fields_passthrough() {
1059        let over = CapabilityOverride::tmux();
1060        assert!(over.true_color.is_none());
1061        assert!(over.unicode_box_drawing.is_none());
1062        assert!(over.unicode_emoji.is_none());
1063        assert!(over.double_width.is_none());
1064    }
1065
1066    // ── builder remaining methods ─────────────────────────────────────
1067
1068    #[test]
1069    fn builder_in_tmux_individually() {
1070        let over = CapabilityOverride::new().in_tmux(Some(true));
1071        assert_eq!(over.in_tmux, Some(true));
1072        assert!(over.true_color.is_none()); // other fields unchanged
1073    }
1074
1075    #[test]
1076    fn builder_sync_output_individually() {
1077        let over = CapabilityOverride::new().sync_output(Some(false));
1078        assert_eq!(over.sync_output, Some(false));
1079        assert!(over.colors_256.is_none());
1080    }
1081
1082    // ── builder overwrite to None ─────────────────────────────────────
1083
1084    #[test]
1085    fn builder_overwrite_field_to_none() {
1086        let over = CapabilityOverride::new()
1087            .true_color(Some(true))
1088            .true_color(None);
1089        assert!(over.true_color.is_none());
1090        assert!(over.is_empty());
1091    }
1092
1093    #[test]
1094    fn builder_overwrite_dumb_field_to_none() {
1095        let over = CapabilityOverride::dumb().true_color(None);
1096        assert!(over.true_color.is_none());
1097        assert!(!over.is_empty()); // other fields still set
1098    }
1099
1100    // ── guard drop after clear_all is safe ────────────────────────────
1101
1102    #[test]
1103    fn guard_drop_after_clear_all_is_noop() {
1104        clear_all_overrides();
1105
1106        let guard = push_override(CapabilityOverride::dumb());
1107        assert_eq!(override_depth(), 1);
1108
1109        clear_all_overrides();
1110        assert_eq!(override_depth(), 0);
1111
1112        // Drop guard after clear - should be silent noop (pop on empty)
1113        drop(guard);
1114        assert_eq!(override_depth(), 0);
1115    }
1116
1117    #[test]
1118    fn multiple_guards_drop_after_clear_all() {
1119        clear_all_overrides();
1120
1121        let g1 = push_override(CapabilityOverride::dumb());
1122        let g2 = push_override(CapabilityOverride::modern());
1123        assert_eq!(override_depth(), 2);
1124
1125        clear_all_overrides();
1126        assert_eq!(override_depth(), 0);
1127
1128        // Both guards drop on empty stack
1129        drop(g2);
1130        drop(g1);
1131        assert_eq!(override_depth(), 0);
1132    }
1133
1134    // ── 3-level deep nesting ──────────────────────────────────────────
1135
1136    #[test]
1137    fn three_level_nesting_innermost_wins() {
1138        clear_all_overrides();
1139
1140        let _l1 = push_override(CapabilityOverride::new().true_color(Some(true)));
1141        let _l2 = push_override(CapabilityOverride::new().true_color(Some(false)));
1142        let _l3 = push_override(CapabilityOverride::new().true_color(Some(true)));
1143
1144        assert_eq!(override_depth(), 3);
1145        let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1146        assert!(caps.true_color); // l3 wins
1147
1148        clear_all_overrides();
1149    }
1150
1151    #[test]
1152    fn three_level_nesting_partial_overrides() {
1153        clear_all_overrides();
1154
1155        let _l1 = push_override(CapabilityOverride::new().true_color(Some(true)));
1156        let _l2 = push_override(CapabilityOverride::new().mouse_sgr(Some(true)));
1157        let _l3 = push_override(CapabilityOverride::new().colors_256(Some(true)));
1158
1159        let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1160        assert!(caps.true_color); // l1
1161        assert!(caps.mouse_sgr); // l2
1162        assert!(caps.colors_256); // l3
1163        assert!(!caps.sync_output); // base dumb
1164
1165        clear_all_overrides();
1166    }
1167
1168    // ── with_overrides_from method ────────────────────────────────────
1169
1170    #[test]
1171    fn with_overrides_from_applies_stack() {
1172        clear_all_overrides();
1173
1174        let base = TerminalCapabilities::dumb();
1175        let _g = push_override(CapabilityOverride::new().true_color(Some(true)));
1176
1177        // with_overrides_from uses current_capabilities_with_base
1178        let caps = base.with_overrides_from(base);
1179        assert!(caps.true_color);
1180        assert!(!caps.colors_256); // base dumb
1181
1182        clear_all_overrides();
1183    }
1184
1185    #[test]
1186    fn with_overrides_from_without_active_overrides() {
1187        clear_all_overrides();
1188
1189        let base = TerminalCapabilities::modern();
1190        let caps = base.with_overrides_from(base);
1191        // No overrides active, should equal base
1192        assert_eq!(caps.true_color, base.true_color);
1193        assert_eq!(caps.mouse_sgr, base.mouse_sgr);
1194    }
1195
1196    // ── with_capability_override panic cleanup ────────────────────────
1197
1198    #[test]
1199    fn with_capability_override_cleans_up_on_panic() {
1200        clear_all_overrides();
1201
1202        let result = std::panic::catch_unwind(|| {
1203            with_capability_override(CapabilityOverride::dumb(), || {
1204                assert!(has_active_overrides());
1205                panic!("deliberate panic");
1206            });
1207        });
1208
1209        assert!(result.is_err());
1210        // Guard should have been dropped during unwind
1211        assert!(!has_active_overrides());
1212        assert_eq!(override_depth(), 0);
1213    }
1214
1215    // ── Debug formatting ──────────────────────────────────────────────
1216
1217    #[test]
1218    fn debug_format_contains_field_names() {
1219        let over = CapabilityOverride::new().true_color(Some(true));
1220        let dbg = format!("{over:?}");
1221        assert!(dbg.contains("true_color"));
1222        assert!(dbg.contains("Some(true)"));
1223    }
1224
1225    #[test]
1226    fn debug_format_empty_override() {
1227        let over = CapabilityOverride::new();
1228        let dbg = format!("{over:?}");
1229        assert!(dbg.contains("CapabilityOverride"));
1230        assert!(dbg.contains("None"));
1231    }
1232
1233    // ── clear_all then push resumes ───────────────────────────────────
1234
1235    #[test]
1236    fn clear_all_then_push_resumes_normally() {
1237        clear_all_overrides();
1238
1239        let _g1 = push_override(CapabilityOverride::dumb());
1240        clear_all_overrides();
1241        assert_eq!(override_depth(), 0);
1242
1243        // Push again should work normally
1244        let _g2 = push_override(CapabilityOverride::modern());
1245        assert_eq!(override_depth(), 1);
1246        assert!(has_active_overrides());
1247
1248        let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1249        assert!(caps.true_color);
1250
1251        clear_all_overrides();
1252    }
1253
1254    // ── current_capabilities with override ────────────────────────────
1255
1256    #[test]
1257    fn current_capabilities_uses_detect_as_base() {
1258        clear_all_overrides();
1259
1260        // Force a known state via dumb override
1261        let _g = push_override(CapabilityOverride::dumb());
1262        let caps = current_capabilities();
1263        assert!(!caps.true_color);
1264        assert!(!caps.mouse_sgr);
1265
1266        clear_all_overrides();
1267    }
1268
1269    // ── with_overrides method ─────────────────────────────────────────
1270
1271    #[test]
1272    fn with_overrides_integrates_full_stack() {
1273        clear_all_overrides();
1274
1275        let _g = push_override(CapabilityOverride::modern());
1276        let caps = TerminalCapabilities::with_overrides();
1277        assert!(caps.true_color);
1278        assert!(caps.kitty_keyboard);
1279        assert!(!caps.in_tmux); // modern disables mux
1280
1281        clear_all_overrides();
1282    }
1283
1284    // ── multiple guards drop ordering ─────────────────────────────────
1285
1286    #[test]
1287    fn second_guard_dropped_first_still_active() {
1288        clear_all_overrides();
1289
1290        let g1 = push_override(CapabilityOverride::new().true_color(Some(true)));
1291        let g2 = push_override(CapabilityOverride::new().true_color(Some(false)));
1292
1293        // Drop g2 first (LIFO order)
1294        drop(g2);
1295        assert_eq!(override_depth(), 1);
1296        let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1297        assert!(caps.true_color); // g1 still active
1298
1299        drop(g1);
1300        assert_eq!(override_depth(), 0);
1301    }
1302
1303    // ── with_capability_override return value propagation ─────────────
1304
1305    #[test]
1306    fn with_capability_override_returns_string() {
1307        clear_all_overrides();
1308
1309        let val = with_capability_override(CapabilityOverride::dumb(), || {
1310            String::from("computed value")
1311        });
1312        assert_eq!(val, "computed value");
1313    }
1314
1315    #[test]
1316    fn with_capability_override_returns_tuple() {
1317        clear_all_overrides();
1318
1319        let (a, b) = with_capability_override(CapabilityOverride::modern(), || {
1320            let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1321            (caps.true_color, caps.mouse_sgr)
1322        });
1323        assert!(a);
1324        assert!(b);
1325    }
1326
1327    // ── apply_to flips true to false ──────────────────────────────────
1328
1329    #[test]
1330    fn apply_to_disables_on_modern_base() {
1331        let base = TerminalCapabilities::modern();
1332        let result = CapabilityOverride::new()
1333            .true_color(Some(false))
1334            .kitty_keyboard(Some(false))
1335            .apply_to(base);
1336        assert!(!result.true_color);
1337        assert!(!result.kitty_keyboard);
1338        // Others still modern
1339        assert!(result.colors_256);
1340        assert!(result.unicode_box_drawing);
1341    }
1342
1343    // ── empty override stack returns base unchanged ───────────────────
1344
1345    #[test]
1346    fn current_capabilities_with_base_no_overrides_returns_base() {
1347        clear_all_overrides();
1348
1349        let base = TerminalCapabilities::modern();
1350        let caps = current_capabilities_with_base(base);
1351        assert_eq!(caps.true_color, base.true_color);
1352        assert_eq!(caps.colors_256, base.colors_256);
1353        assert_eq!(caps.unicode_box_drawing, base.unicode_box_drawing);
1354        assert_eq!(caps.unicode_emoji, base.unicode_emoji);
1355        assert_eq!(caps.double_width, base.double_width);
1356        assert_eq!(caps.sync_output, base.sync_output);
1357        assert_eq!(caps.osc8_hyperlinks, base.osc8_hyperlinks);
1358        assert_eq!(caps.scroll_region, base.scroll_region);
1359        assert_eq!(caps.in_tmux, base.in_tmux);
1360        assert_eq!(caps.in_screen, base.in_screen);
1361        assert_eq!(caps.in_zellij, base.in_zellij);
1362        assert_eq!(caps.kitty_keyboard, base.kitty_keyboard);
1363        assert_eq!(caps.focus_events, base.focus_events);
1364        assert_eq!(caps.bracketed_paste, base.bracketed_paste);
1365        assert_eq!(caps.mouse_sgr, base.mouse_sgr);
1366        assert_eq!(caps.osc52_clipboard, base.osc52_clipboard);
1367    }
1368}