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