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
508/// Parse an operator policy switch: `1`/`true`/`on`/`yes` enable,
509/// `0`/`false`/`off`/`no` disable, anything else (including unset) is `None`.
510fn policy_switch(value: Option<String>) -> Option<bool> {
511    let value = value?;
512    match value.trim().to_ascii_lowercase().as_str() {
513        "1" | "true" | "on" | "yes" => Some(true),
514        "0" | "false" | "off" | "no" => Some(false),
515        _ => None,
516    }
517}
518
519/// Which operator policy switches were present and what they forced.
520///
521/// Returned by [`apply_env_policy_overrides`] so the runtime can record the
522/// override in its capability decision ledger (`capability_decision`
523/// evidence rows) instead of silently mutating the detected capabilities.
524#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
525pub struct PolicyOverrides {
526    /// `FTUI_SYNC_OUTPUT` forced synchronized output on (`Some(true)`) or
527    /// off (`Some(false)`); `None` when the switch was absent or unparsable.
528    pub sync_output: Option<bool>,
529    /// `FTUI_SCROLL_REGION`, same encoding.
530    pub scroll_region: Option<bool>,
531    /// `FTUI_OSC52_CLIPBOARD`, same encoding; preserves multiplexer wrapping.
532    pub osc52_clipboard: Option<bool>,
533}
534
535impl PolicyOverrides {
536    /// `true` when no switch was applied.
537    #[must_use]
538    pub const fn is_empty(&self) -> bool {
539        self.sync_output.is_none() && self.scroll_region.is_none() && self.osc52_clipboard.is_none()
540    }
541}
542
543/// Apply the operator environment switches that override detection policy.
544///
545/// - `FTUI_SYNC_OUTPUT=1|0` forces synchronized-output (DEC 2026) brackets on
546///   or off. `1` also lifts the conservative WezTerm-identity gate, because an
547///   explicit operator statement outranks an identity heuristic; real
548///   multiplexer evidence (tmux/screen/zellij) is never overridden.
549/// - `FTUI_SCROLL_REGION=1|0` does the same for the inline scroll-region
550///   (DECSTBM) strategy.
551/// - `FTUI_OSC52_CLIPBOARD=1|0` explicitly enables or disables clipboard
552///   requests. Enabling it preserves multiplexer identity and OSC 52 wrapping;
553///   the operator must configure the multiplexer to permit passthrough.
554///
555/// These exist so a user on a terminal the allowlist does not know (or one
556/// where a probe cannot run) can opt in without rebuilding, and so a flaky
557/// terminal can be opted out without editing code.
558///
559/// Returns which switches were applied so callers can log the decision.
560pub fn apply_env_policy_overrides_with<F>(
561    caps: &mut TerminalCapabilities,
562    get_env: F,
563) -> PolicyOverrides
564where
565    F: Fn(&str) -> Option<String>,
566{
567    let overrides = PolicyOverrides {
568        sync_output: policy_switch(get_env("FTUI_SYNC_OUTPUT")),
569        scroll_region: policy_switch(get_env("FTUI_SCROLL_REGION")),
570        osc52_clipboard: policy_switch(get_env("FTUI_OSC52_CLIPBOARD")),
571    };
572    if let Some(enabled) = overrides.sync_output {
573        caps.sync_output = enabled;
574        if enabled {
575            caps.in_wezterm_mux = false;
576        }
577    }
578    if let Some(enabled) = overrides.scroll_region {
579        caps.scroll_region = enabled;
580        if enabled {
581            caps.in_wezterm_mux = false;
582        }
583    }
584    if let Some(enabled) = overrides.osc52_clipboard {
585        caps.osc52_clipboard = enabled;
586    }
587    overrides
588}
589
590/// Apply the operator environment switches from the process environment.
591///
592/// Returns which switches were applied so callers can log the decision.
593pub fn apply_env_policy_overrides(caps: &mut TerminalCapabilities) -> PolicyOverrides {
594    apply_env_policy_overrides_with(caps, |key| std::env::var(key).ok())
595}
596
597impl TerminalCapabilities {
598    /// Detect capabilities and apply any active thread-local overrides,
599    /// then the operator environment switches (`FTUI_SYNC_OUTPUT`,
600    /// `FTUI_SCROLL_REGION`; see [`apply_env_policy_overrides`]).
601    ///
602    /// This is the recommended way to get capabilities in code that may
603    /// be running under test with overrides.
604    #[must_use]
605    pub fn with_overrides() -> Self {
606        let mut caps = current_capabilities();
607        let _applied = apply_env_policy_overrides(&mut caps);
608        caps
609    }
610
611    /// Apply overrides to these capabilities.
612    #[must_use]
613    pub fn with_overrides_from(self, base: Self) -> Self {
614        current_capabilities_with_base(base)
615    }
616}
617
618// ============================================================================
619// Tests
620// ============================================================================
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    fn env_from<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
627        move |key| {
628            pairs
629                .iter()
630                .find(|(k, _)| *k == key)
631                .map(|(_, v)| (*v).to_string())
632        }
633    }
634
635    #[test]
636    fn env_policy_switch_parses_common_spellings() {
637        for on in ["1", "true", "ON", " yes "] {
638            assert_eq!(policy_switch(Some(on.to_string())), Some(true), "{on:?}");
639        }
640        for off in ["0", "false", "Off", "no"] {
641            assert_eq!(policy_switch(Some(off.to_string())), Some(false), "{off:?}");
642        }
643        assert_eq!(policy_switch(Some("maybe".to_string())), None);
644        assert_eq!(policy_switch(None), None);
645    }
646
647    #[test]
648    fn env_policy_override_forces_sync_output_on_and_lifts_wezterm_gate() {
649        let mut caps = TerminalCapabilities::xterm_256color();
650        caps.in_wezterm_mux = true;
651        assert!(!caps.use_sync_output());
652        apply_env_policy_overrides_with(&mut caps, env_from(&[("FTUI_SYNC_OUTPUT", "1")]));
653        assert!(caps.sync_output);
654        assert!(
655            !caps.in_wezterm_mux,
656            "explicit opt-in outranks the identity gate"
657        );
658        assert!(caps.use_sync_output());
659    }
660
661    #[test]
662    fn env_policy_override_never_lifts_real_multiplexer_evidence() {
663        let mut caps = TerminalCapabilities::modern();
664        caps.in_tmux = true;
665        apply_env_policy_overrides_with(&mut caps, env_from(&[("FTUI_SYNC_OUTPUT", "1")]));
666        assert!(caps.sync_output);
667        assert!(caps.in_tmux);
668        assert!(!caps.use_sync_output(), "tmux policy still wins");
669    }
670
671    #[test]
672    fn env_policy_override_forces_off_and_ignores_unset_or_junk() {
673        let mut caps = TerminalCapabilities::modern();
674        apply_env_policy_overrides_with(&mut caps, env_from(&[("FTUI_SYNC_OUTPUT", "0")]));
675        assert!(!caps.sync_output);
676        assert!(caps.scroll_region, "unrelated switch untouched");
677
678        let mut caps = TerminalCapabilities::modern();
679        apply_env_policy_overrides_with(
680            &mut caps,
681            env_from(&[("FTUI_SYNC_OUTPUT", "banana"), ("FTUI_SCROLL_REGION", "0")]),
682        );
683        assert!(caps.sync_output, "junk value ignored");
684        assert!(!caps.scroll_region);
685        assert!(!caps.use_scroll_region());
686    }
687
688    #[test]
689    fn env_policy_override_reports_which_switches_applied() {
690        let mut caps = TerminalCapabilities::modern();
691        let none = apply_env_policy_overrides_with(&mut caps, env_from(&[]));
692        assert!(none.is_empty());
693        assert_eq!(none, PolicyOverrides::default());
694
695        let mut caps = TerminalCapabilities::modern();
696        let applied = apply_env_policy_overrides_with(
697            &mut caps,
698            env_from(&[("FTUI_SYNC_OUTPUT", "off"), ("FTUI_SCROLL_REGION", "yes")]),
699        );
700        assert_eq!(applied.sync_output, Some(false));
701        assert_eq!(applied.scroll_region, Some(true));
702        assert!(!applied.is_empty());
703        assert!(!caps.sync_output);
704        assert!(caps.scroll_region);
705
706        let mut caps = TerminalCapabilities::modern();
707        let junk =
708            apply_env_policy_overrides_with(&mut caps, env_from(&[("FTUI_SYNC_OUTPUT", "maybe")]));
709        assert!(junk.is_empty(), "unparsable switch reports nothing applied");
710    }
711
712    #[test]
713    fn clipboard_policy_requires_explicit_opt_in_and_preserves_mux_identity() {
714        for value in [None, Some("banana"), Some("0"), Some("1")] {
715            let mut caps = TerminalCapabilities::tmux();
716            let applied = apply_env_policy_overrides_with(&mut caps, |key| {
717                (key == "FTUI_OSC52_CLIPBOARD")
718                    .then_some(value)
719                    .flatten()
720                    .map(str::to_owned)
721            });
722            assert_eq!(caps.osc52_clipboard, value == Some("1"));
723            assert!(caps.in_tmux);
724            assert!(!caps.use_sync_output());
725            assert_eq!(
726                applied.is_empty(),
727                value.is_none() || value == Some("banana")
728            );
729        }
730        let mut caps = TerminalCapabilities::modern();
731        let applied = apply_env_policy_overrides_with(
732            &mut caps,
733            env_from(&[("FTUI_OSC52_CLIPBOARD", "off")]),
734        );
735        assert_eq!(applied.osc52_clipboard, Some(false));
736        assert!(!caps.osc52_clipboard);
737        assert!(caps.sync_output);
738    }
739
740    #[test]
741    fn override_new_is_empty() {
742        let over = CapabilityOverride::new();
743        assert!(over.is_empty());
744    }
745
746    #[test]
747    fn override_dumb_disables_all() {
748        let over = CapabilityOverride::dumb();
749        assert!(!over.is_empty());
750        assert_eq!(over.color_depth, Some(ColorDepth::Mono));
751        assert_eq!(over.sync_output, Some(false));
752        assert_eq!(over.mouse_sgr, Some(false));
753    }
754
755    #[test]
756    fn override_modern_enables_all() {
757        let over = CapabilityOverride::modern();
758        assert_eq!(over.color_depth, Some(ColorDepth::TrueColor));
759        assert_eq!(over.sync_output, Some(true));
760        assert_eq!(over.kitty_keyboard, Some(true));
761        // But mux flags are false
762        assert_eq!(over.in_tmux, Some(false));
763    }
764
765    #[test]
766    fn override_tmux_sets_mux() {
767        let over = CapabilityOverride::tmux();
768        assert_eq!(over.in_tmux, Some(true));
769        assert_eq!(over.sync_output, Some(false));
770        assert_eq!(over.osc52_clipboard, Some(false));
771    }
772
773    #[test]
774    fn override_builder_chain() {
775        let over = CapabilityOverride::new()
776            .color_depth(Some(ColorDepth::TrueColor))
777            .unicode_box_drawing(Some(false))
778            .mouse_sgr(Some(false));
779
780        assert_eq!(over.color_depth, Some(ColorDepth::TrueColor));
781        assert_eq!(over.unicode_box_drawing, Some(false));
782        assert_eq!(over.mouse_sgr, Some(false));
783        assert!(over.sync_output.is_none());
784    }
785
786    #[test]
787    fn apply_to_overrides_caps() {
788        let base = TerminalCapabilities::dumb();
789        let over = CapabilityOverride::new()
790            .color_depth(Some(ColorDepth::TrueColor))
791            .unicode_box_drawing(Some(true));
792
793        let result = over.apply_to(base);
794        assert_eq!(result.color_depth, ColorDepth::TrueColor);
795        assert!(result.unicode_box_drawing);
796        // Unchanged fields remain from base
797        assert!(!result.mouse_sgr);
798    }
799
800    #[test]
801    fn apply_to_none_keeps_original() {
802        let base = TerminalCapabilities::modern();
803        let over = CapabilityOverride::new(); // All None
804
805        let result = over.apply_to(base);
806        assert_eq!(result.color_depth, base.color_depth);
807        assert_eq!(result.mouse_sgr, base.mouse_sgr);
808    }
809
810    #[test]
811    fn push_pop_override() {
812        clear_all_overrides();
813        assert!(!has_active_overrides());
814        assert_eq!(override_depth(), 0);
815
816        {
817            let _guard = push_override(CapabilityOverride::dumb());
818            assert!(has_active_overrides());
819            assert_eq!(override_depth(), 1);
820        }
821
822        assert!(!has_active_overrides());
823        assert_eq!(override_depth(), 0);
824    }
825
826    #[test]
827    fn nested_overrides() {
828        clear_all_overrides();
829
830        {
831            let _outer = push_override(
832                CapabilityOverride::new()
833                    .color_depth(Some(ColorDepth::TrueColor))
834                    .mouse_sgr(Some(true)),
835            );
836            assert_eq!(override_depth(), 1);
837
838            {
839                let _inner =
840                    push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::Mono)));
841                assert_eq!(override_depth(), 2);
842
843                // Inner override takes precedence
844                let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
845                assert_eq!(caps.color_depth, ColorDepth::Mono);
846                assert!(caps.mouse_sgr); // Outer: true
847            }
848
849            // Inner dropped, outer still active
850            assert_eq!(override_depth(), 1);
851            let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
852            assert_eq!(caps.color_depth, ColorDepth::TrueColor);
853        }
854
855        assert_eq!(override_depth(), 0);
856    }
857
858    #[test]
859    fn with_capability_override_scope() {
860        clear_all_overrides();
861
862        let result = with_capability_override(CapabilityOverride::modern(), || {
863            assert!(has_active_overrides());
864            let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
865            caps.supports_true_color()
866        });
867
868        assert!(result);
869        assert!(!has_active_overrides());
870    }
871
872    #[test]
873    fn with_capability_override_nested() {
874        clear_all_overrides();
875
876        with_capability_override(
877            CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor)),
878            || {
879                with_capability_override(CapabilityOverride::new().mouse_sgr(Some(false)), || {
880                    let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
881                    assert_eq!(caps.color_depth, ColorDepth::TrueColor);
882                    assert!(!caps.mouse_sgr);
883                });
884            },
885        );
886    }
887
888    #[test]
889    fn with_overrides_method() {
890        clear_all_overrides();
891
892        with_capability_override(CapabilityOverride::dumb(), || {
893            let caps = TerminalCapabilities::with_overrides();
894            assert_eq!(caps.color_depth, ColorDepth::Mono);
895            assert!(!caps.unicode_box_drawing);
896            assert!(!caps.unicode_emoji);
897            assert!(!caps.double_width);
898        });
899    }
900
901    #[test]
902    fn clear_all_overrides_works() {
903        let _g1 = push_override(CapabilityOverride::dumb());
904        let _g2 = push_override(CapabilityOverride::modern());
905        assert_eq!(override_depth(), 2);
906
907        clear_all_overrides();
908        assert_eq!(override_depth(), 0);
909    }
910
911    #[test]
912    fn default_override_is_empty() {
913        let over = CapabilityOverride::default();
914        assert!(over.is_empty());
915    }
916
917    #[test]
918    fn is_empty_false_for_single_override() {
919        let over = CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor));
920        assert!(!over.is_empty());
921    }
922
923    #[test]
924    fn dumb_disables_all_fields() {
925        let over = CapabilityOverride::dumb();
926        assert_eq!(over.unicode_box_drawing, Some(false));
927        assert_eq!(over.unicode_emoji, Some(false));
928        assert_eq!(over.double_width, Some(false));
929        assert_eq!(over.osc8_hyperlinks, Some(false));
930        assert_eq!(over.scroll_region, Some(false));
931        assert_eq!(over.kitty_keyboard, Some(false));
932        assert_eq!(over.focus_events, Some(false));
933        assert_eq!(over.bracketed_paste, Some(false));
934        assert_eq!(over.osc52_clipboard, Some(false));
935        assert_eq!(over.in_tmux, Some(false));
936        assert_eq!(over.in_screen, Some(false));
937        assert_eq!(over.in_zellij, Some(false));
938    }
939
940    #[test]
941    fn modern_enables_features_disables_mux() {
942        let over = CapabilityOverride::modern();
943        assert_eq!(over.unicode_box_drawing, Some(true));
944        assert_eq!(over.unicode_emoji, Some(true));
945        assert_eq!(over.double_width, Some(true));
946        assert_eq!(over.osc8_hyperlinks, Some(true));
947        assert_eq!(over.scroll_region, Some(true));
948        assert_eq!(over.focus_events, Some(true));
949        assert_eq!(over.bracketed_paste, Some(true));
950        assert_eq!(over.osc52_clipboard, Some(true));
951        assert_eq!(over.in_screen, Some(false));
952        assert_eq!(over.in_zellij, Some(false));
953    }
954
955    #[test]
956    fn tmux_preserves_color_depth_and_sets_bracketed_paste() {
957        let over = CapabilityOverride::tmux();
958        assert_eq!(over.color_depth, None);
959        assert_eq!(over.bracketed_paste, Some(true));
960        assert_eq!(over.mouse_sgr, Some(true));
961        assert_eq!(over.scroll_region, Some(true));
962        assert_eq!(over.kitty_keyboard, Some(false));
963    }
964
965    #[test]
966    fn builder_all_optional_features() {
967        let over = CapabilityOverride::new()
968            .unicode_emoji(Some(true))
969            .double_width(Some(false))
970            .in_screen(Some(true))
971            .in_zellij(Some(true))
972            .osc8_hyperlinks(Some(true))
973            .osc52_clipboard(Some(false))
974            .scroll_region(Some(true))
975            .focus_events(Some(true))
976            .bracketed_paste(Some(false))
977            .kitty_keyboard(Some(true));
978
979        assert_eq!(over.unicode_emoji, Some(true));
980        assert_eq!(over.double_width, Some(false));
981        assert_eq!(over.in_screen, Some(true));
982        assert_eq!(over.in_zellij, Some(true));
983        assert_eq!(over.osc8_hyperlinks, Some(true));
984        assert_eq!(over.osc52_clipboard, Some(false));
985        assert_eq!(over.scroll_region, Some(true));
986        assert_eq!(over.focus_events, Some(true));
987        assert_eq!(over.bracketed_paste, Some(false));
988        assert_eq!(over.kitty_keyboard, Some(true));
989    }
990
991    #[test]
992    fn apply_to_covers_all_mux_flags() {
993        let base = TerminalCapabilities::dumb();
994        let over = CapabilityOverride::new()
995            .in_tmux(Some(true))
996            .in_screen(Some(true))
997            .in_zellij(Some(true))
998            .in_wezterm_mux(Some(true));
999        let result = over.apply_to(base);
1000        assert!(result.in_tmux);
1001        assert!(result.in_screen);
1002        assert!(result.in_zellij);
1003        assert!(result.in_wezterm_mux);
1004    }
1005
1006    #[test]
1007    fn modern_override_simulates_non_mux_even_under_wezterm() {
1008        // Regression: CapabilityOverride had no in_wezterm_mux field, so
1009        // modern() could not neutralize a WezTerm host — in_any_mux() stayed
1010        // true and use_sync_output()/use_scroll_region()/use_clipboard()
1011        // returned false, making override-based tests host-dependent.
1012        let mut base = TerminalCapabilities::modern();
1013        base.in_wezterm_mux = true; // simulate detection on a WezTerm host
1014
1015        let result = CapabilityOverride::modern().apply_to(base);
1016        assert!(!result.in_wezterm_mux);
1017        assert!(!result.in_any_mux());
1018        assert!(result.use_sync_output());
1019        assert!(result.use_scroll_region());
1020        assert!(result.use_hyperlinks());
1021        assert!(result.use_clipboard());
1022    }
1023
1024    #[test]
1025    fn dumb_override_clears_wezterm_mux() {
1026        let mut base = TerminalCapabilities::modern();
1027        base.in_wezterm_mux = true;
1028        let result = CapabilityOverride::dumb().apply_to(base);
1029        assert!(!result.in_wezterm_mux);
1030    }
1031
1032    #[test]
1033    fn is_empty_false_for_in_wezterm_mux() {
1034        assert!(
1035            !CapabilityOverride::new()
1036                .in_wezterm_mux(Some(true))
1037                .is_empty()
1038        );
1039    }
1040
1041    #[test]
1042    fn apply_to_covers_input_features() {
1043        let base = TerminalCapabilities::dumb();
1044        let over = CapabilityOverride::new()
1045            .kitty_keyboard(Some(true))
1046            .focus_events(Some(true))
1047            .bracketed_paste(Some(true))
1048            .osc52_clipboard(Some(true));
1049        let result = over.apply_to(base);
1050        assert!(result.kitty_keyboard);
1051        assert!(result.focus_events);
1052        assert!(result.bracketed_paste);
1053        assert!(result.osc52_clipboard);
1054    }
1055
1056    #[test]
1057    fn current_capabilities_with_base_composes_stack() {
1058        clear_all_overrides();
1059        let base = TerminalCapabilities::dumb();
1060
1061        let _g1 = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor)));
1062        let _g2 = push_override(CapabilityOverride::new().mouse_sgr(Some(true)));
1063
1064        let caps = current_capabilities_with_base(base);
1065        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1066        assert!(caps.mouse_sgr);
1067
1068        clear_all_overrides();
1069    }
1070
1071    #[test]
1072    fn override_clone() {
1073        let over = CapabilityOverride::new()
1074            .color_depth(Some(ColorDepth::TrueColor))
1075            .in_tmux(Some(false));
1076        let cloned = over.clone();
1077        assert_eq!(over.color_depth, cloned.color_depth);
1078        assert_eq!(over.in_tmux, cloned.in_tmux);
1079    }
1080
1081    // ── is_empty per-field ────────────────────────────────────────────
1082
1083    #[test]
1084    fn is_empty_false_for_color_depth() {
1085        assert!(
1086            !CapabilityOverride::new()
1087                .color_depth(Some(ColorDepth::Ansi256))
1088                .is_empty()
1089        );
1090    }
1091
1092    #[test]
1093    fn is_empty_false_for_unicode_box_drawing() {
1094        assert!(
1095            !CapabilityOverride::new()
1096                .unicode_box_drawing(Some(false))
1097                .is_empty()
1098        );
1099    }
1100
1101    #[test]
1102    fn is_empty_false_for_unicode_emoji() {
1103        assert!(
1104            !CapabilityOverride::new()
1105                .unicode_emoji(Some(true))
1106                .is_empty()
1107        );
1108    }
1109
1110    #[test]
1111    fn is_empty_false_for_double_width() {
1112        assert!(
1113            !CapabilityOverride::new()
1114                .double_width(Some(true))
1115                .is_empty()
1116        );
1117    }
1118
1119    #[test]
1120    fn is_empty_false_for_sync_output() {
1121        assert!(
1122            !CapabilityOverride::new()
1123                .sync_output(Some(false))
1124                .is_empty()
1125        );
1126    }
1127
1128    #[test]
1129    fn is_empty_false_for_osc8_hyperlinks() {
1130        assert!(
1131            !CapabilityOverride::new()
1132                .osc8_hyperlinks(Some(true))
1133                .is_empty()
1134        );
1135    }
1136
1137    #[test]
1138    fn is_empty_false_for_scroll_region() {
1139        assert!(
1140            !CapabilityOverride::new()
1141                .scroll_region(Some(true))
1142                .is_empty()
1143        );
1144    }
1145
1146    #[test]
1147    fn is_empty_false_for_in_tmux() {
1148        assert!(!CapabilityOverride::new().in_tmux(Some(true)).is_empty());
1149    }
1150
1151    #[test]
1152    fn is_empty_false_for_in_screen() {
1153        assert!(!CapabilityOverride::new().in_screen(Some(true)).is_empty());
1154    }
1155
1156    #[test]
1157    fn is_empty_false_for_in_zellij() {
1158        assert!(!CapabilityOverride::new().in_zellij(Some(true)).is_empty());
1159    }
1160
1161    #[test]
1162    fn is_empty_false_for_kitty_keyboard() {
1163        assert!(
1164            !CapabilityOverride::new()
1165                .kitty_keyboard(Some(true))
1166                .is_empty()
1167        );
1168    }
1169
1170    #[test]
1171    fn is_empty_false_for_focus_events() {
1172        assert!(
1173            !CapabilityOverride::new()
1174                .focus_events(Some(false))
1175                .is_empty()
1176        );
1177    }
1178
1179    #[test]
1180    fn is_empty_false_for_bracketed_paste() {
1181        assert!(
1182            !CapabilityOverride::new()
1183                .bracketed_paste(Some(true))
1184                .is_empty()
1185        );
1186    }
1187
1188    #[test]
1189    fn is_empty_false_for_mouse_sgr() {
1190        assert!(!CapabilityOverride::new().mouse_sgr(Some(true)).is_empty());
1191    }
1192
1193    #[test]
1194    fn is_empty_false_for_osc52_clipboard() {
1195        assert!(
1196            !CapabilityOverride::new()
1197                .osc52_clipboard(Some(false))
1198                .is_empty()
1199        );
1200    }
1201
1202    // ── apply_to remaining fields ─────────────────────────────────────
1203
1204    #[test]
1205    fn apply_to_covers_unicode_emoji() {
1206        let base = TerminalCapabilities::dumb();
1207        let result = CapabilityOverride::new()
1208            .unicode_emoji(Some(true))
1209            .apply_to(base);
1210        assert!(result.unicode_emoji);
1211    }
1212
1213    #[test]
1214    fn apply_to_covers_double_width() {
1215        let base = TerminalCapabilities::dumb();
1216        let result = CapabilityOverride::new()
1217            .double_width(Some(true))
1218            .apply_to(base);
1219        assert!(result.double_width);
1220    }
1221
1222    #[test]
1223    fn apply_to_covers_sync_output() {
1224        let base = TerminalCapabilities::dumb();
1225        let result = CapabilityOverride::new()
1226            .sync_output(Some(true))
1227            .apply_to(base);
1228        assert!(result.sync_output);
1229    }
1230
1231    #[test]
1232    fn apply_to_covers_osc8_hyperlinks() {
1233        let base = TerminalCapabilities::dumb();
1234        let result = CapabilityOverride::new()
1235            .osc8_hyperlinks(Some(true))
1236            .apply_to(base);
1237        assert!(result.osc8_hyperlinks);
1238    }
1239
1240    #[test]
1241    fn apply_to_covers_scroll_region() {
1242        let base = TerminalCapabilities::dumb();
1243        let result = CapabilityOverride::new()
1244            .scroll_region(Some(true))
1245            .apply_to(base);
1246        assert!(result.scroll_region);
1247    }
1248
1249    #[test]
1250    fn apply_to_covers_mouse_sgr() {
1251        let base = TerminalCapabilities::dumb();
1252        let result = CapabilityOverride::new()
1253            .mouse_sgr(Some(true))
1254            .apply_to(base);
1255        assert!(result.mouse_sgr);
1256    }
1257
1258    // ── apply_to with presets ─────────────────────────────────────────
1259
1260    #[test]
1261    fn dumb_override_disables_all_on_modern_base() {
1262        let base = TerminalCapabilities::modern();
1263        let result = CapabilityOverride::dumb().apply_to(base);
1264        assert_eq!(result.color_depth, ColorDepth::Mono);
1265        assert!(!result.unicode_box_drawing);
1266        assert!(!result.unicode_emoji);
1267        assert!(!result.double_width);
1268        assert!(!result.sync_output);
1269        assert!(!result.osc8_hyperlinks);
1270        assert!(!result.scroll_region);
1271        assert!(!result.in_tmux);
1272        assert!(!result.in_screen);
1273        assert!(!result.in_zellij);
1274        assert!(!result.kitty_keyboard);
1275        assert!(!result.focus_events);
1276        assert!(!result.bracketed_paste);
1277        assert!(!result.mouse_sgr);
1278        assert!(!result.osc52_clipboard);
1279    }
1280
1281    #[test]
1282    fn modern_override_enables_features_on_dumb_base() {
1283        let base = TerminalCapabilities::dumb();
1284        let result = CapabilityOverride::modern().apply_to(base);
1285        assert_eq!(result.color_depth, ColorDepth::TrueColor);
1286        assert!(result.unicode_box_drawing);
1287        assert!(result.unicode_emoji);
1288        assert!(result.double_width);
1289        assert!(result.sync_output);
1290        assert!(result.osc8_hyperlinks);
1291        assert!(result.scroll_region);
1292        // mux flags disabled by modern preset
1293        assert!(!result.in_tmux);
1294        assert!(!result.in_screen);
1295        assert!(!result.in_zellij);
1296        assert!(result.kitty_keyboard);
1297        assert!(result.focus_events);
1298        assert!(result.bracketed_paste);
1299        assert!(result.mouse_sgr);
1300        assert!(result.osc52_clipboard);
1301    }
1302
1303    // ── tmux None fields ──────────────────────────────────────────────
1304
1305    #[test]
1306    fn tmux_leaves_depth_and_unrelated_fields_unset() {
1307        let over = CapabilityOverride::tmux();
1308        assert_eq!(over.color_depth, None);
1309        assert!(over.unicode_box_drawing.is_none());
1310        assert!(over.unicode_emoji.is_none());
1311        assert!(over.double_width.is_none());
1312    }
1313
1314    // ── builder remaining methods ─────────────────────────────────────
1315
1316    #[test]
1317    fn builder_in_tmux_individually() {
1318        let over = CapabilityOverride::new().in_tmux(Some(true));
1319        assert_eq!(over.in_tmux, Some(true));
1320        assert!(over.color_depth.is_none()); // other fields unchanged
1321    }
1322
1323    #[test]
1324    fn builder_sync_output_individually() {
1325        let over = CapabilityOverride::new().sync_output(Some(false));
1326        assert_eq!(over.sync_output, Some(false));
1327        assert!(over.color_depth.is_none());
1328    }
1329
1330    // ── builder overwrite to None ─────────────────────────────────────
1331
1332    #[test]
1333    fn builder_overwrite_field_to_none() {
1334        let over = CapabilityOverride::new()
1335            .color_depth(Some(ColorDepth::TrueColor))
1336            .color_depth(None);
1337        assert!(over.color_depth.is_none());
1338        assert!(over.is_empty());
1339    }
1340
1341    #[test]
1342    fn builder_overwrite_dumb_field_to_none() {
1343        let over = CapabilityOverride::dumb().color_depth(None);
1344        assert!(over.color_depth.is_none());
1345        assert!(!over.is_empty()); // other fields still set
1346    }
1347
1348    // ── guard drop after clear_all is safe ────────────────────────────
1349
1350    #[test]
1351    fn guard_drop_after_clear_all_is_noop() {
1352        clear_all_overrides();
1353
1354        let guard = push_override(CapabilityOverride::dumb());
1355        assert_eq!(override_depth(), 1);
1356
1357        clear_all_overrides();
1358        assert_eq!(override_depth(), 0);
1359
1360        // Drop guard after clear - should be silent noop (pop on empty)
1361        drop(guard);
1362        assert_eq!(override_depth(), 0);
1363    }
1364
1365    #[test]
1366    fn multiple_guards_drop_after_clear_all() {
1367        clear_all_overrides();
1368
1369        let g1 = push_override(CapabilityOverride::dumb());
1370        let g2 = push_override(CapabilityOverride::modern());
1371        assert_eq!(override_depth(), 2);
1372
1373        clear_all_overrides();
1374        assert_eq!(override_depth(), 0);
1375
1376        // Both guards drop on empty stack
1377        drop(g2);
1378        drop(g1);
1379        assert_eq!(override_depth(), 0);
1380    }
1381
1382    // ── 3-level deep nesting ──────────────────────────────────────────
1383
1384    #[test]
1385    fn three_level_nesting_innermost_wins() {
1386        clear_all_overrides();
1387
1388        let _l1 = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor)));
1389        let _l2 = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::Mono)));
1390        let _l3 = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor)));
1391
1392        assert_eq!(override_depth(), 3);
1393        let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1394        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1395
1396        clear_all_overrides();
1397    }
1398
1399    #[test]
1400    fn three_level_nesting_partial_overrides() {
1401        clear_all_overrides();
1402
1403        let _l1 = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor)));
1404        let _l2 = push_override(CapabilityOverride::new().mouse_sgr(Some(true)));
1405        let _l3 = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::Ansi256)));
1406
1407        let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1408        assert_eq!(caps.color_depth, ColorDepth::Ansi256); // l3 wins
1409        assert!(caps.mouse_sgr); // l2
1410        assert!(!caps.sync_output); // base dumb
1411
1412        clear_all_overrides();
1413    }
1414
1415    // ── with_overrides_from method ────────────────────────────────────
1416
1417    #[test]
1418    fn with_overrides_from_applies_stack() {
1419        clear_all_overrides();
1420
1421        let base = TerminalCapabilities::dumb();
1422        let _g = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor)));
1423
1424        // with_overrides_from uses current_capabilities_with_base
1425        let caps = base.with_overrides_from(base);
1426        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1427
1428        clear_all_overrides();
1429    }
1430
1431    #[test]
1432    fn with_overrides_from_without_active_overrides() {
1433        clear_all_overrides();
1434
1435        let base = TerminalCapabilities::modern();
1436        let caps = base.with_overrides_from(base);
1437        // No overrides active, should equal base
1438        assert_eq!(caps.color_depth, base.color_depth);
1439        assert_eq!(caps.mouse_sgr, base.mouse_sgr);
1440    }
1441
1442    // ── with_capability_override panic cleanup ────────────────────────
1443
1444    #[test]
1445    fn with_capability_override_cleans_up_on_panic() {
1446        clear_all_overrides();
1447
1448        let result = std::panic::catch_unwind(|| {
1449            with_capability_override(CapabilityOverride::dumb(), || {
1450                assert!(has_active_overrides());
1451                panic!("deliberate panic");
1452            });
1453        });
1454
1455        assert!(result.is_err());
1456        // Guard should have been dropped during unwind
1457        assert!(!has_active_overrides());
1458        assert_eq!(override_depth(), 0);
1459    }
1460
1461    // ── Debug formatting ──────────────────────────────────────────────
1462
1463    #[test]
1464    fn debug_format_contains_field_names() {
1465        let over = CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor));
1466        let dbg = format!("{over:?}");
1467        assert!(dbg.contains("color_depth"));
1468        assert!(dbg.contains("TrueColor"));
1469    }
1470
1471    #[test]
1472    fn debug_format_empty_override() {
1473        let over = CapabilityOverride::new();
1474        let dbg = format!("{over:?}");
1475        assert!(dbg.contains("CapabilityOverride"));
1476        assert!(dbg.contains("None"));
1477    }
1478
1479    // ── clear_all then push resumes ───────────────────────────────────
1480
1481    #[test]
1482    fn clear_all_then_push_resumes_normally() {
1483        clear_all_overrides();
1484
1485        let _g1 = push_override(CapabilityOverride::dumb());
1486        clear_all_overrides();
1487        assert_eq!(override_depth(), 0);
1488
1489        // Push again should work normally
1490        let _g2 = push_override(CapabilityOverride::modern());
1491        assert_eq!(override_depth(), 1);
1492        assert!(has_active_overrides());
1493
1494        let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1495        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1496
1497        clear_all_overrides();
1498    }
1499
1500    // ── current_capabilities with override ────────────────────────────
1501
1502    #[test]
1503    fn current_capabilities_uses_detect_as_base() {
1504        clear_all_overrides();
1505
1506        // Force a known state via dumb override
1507        let _g = push_override(CapabilityOverride::dumb());
1508        let caps = current_capabilities();
1509        assert_eq!(caps.color_depth, ColorDepth::Mono);
1510        assert!(!caps.mouse_sgr);
1511
1512        clear_all_overrides();
1513    }
1514
1515    // ── with_overrides method ─────────────────────────────────────────
1516
1517    #[test]
1518    fn with_overrides_integrates_full_stack() {
1519        clear_all_overrides();
1520
1521        let _g = push_override(CapabilityOverride::modern());
1522        let caps = TerminalCapabilities::with_overrides();
1523        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1524        assert!(caps.kitty_keyboard);
1525        assert!(!caps.in_tmux); // modern disables mux
1526
1527        clear_all_overrides();
1528    }
1529
1530    // ── multiple guards drop ordering ─────────────────────────────────
1531
1532    #[test]
1533    fn second_guard_dropped_first_still_active() {
1534        clear_all_overrides();
1535
1536        let g1 = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::TrueColor)));
1537        let g2 = push_override(CapabilityOverride::new().color_depth(Some(ColorDepth::Mono)));
1538
1539        // Drop g2 first (LIFO order)
1540        drop(g2);
1541        assert_eq!(override_depth(), 1);
1542        let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1543        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1544
1545        drop(g1);
1546        assert_eq!(override_depth(), 0);
1547    }
1548
1549    // ── with_capability_override return value propagation ─────────────
1550
1551    #[test]
1552    fn with_capability_override_returns_string() {
1553        clear_all_overrides();
1554
1555        let val = with_capability_override(CapabilityOverride::dumb(), || {
1556            String::from("computed value")
1557        });
1558        assert_eq!(val, "computed value");
1559    }
1560
1561    #[test]
1562    fn with_capability_override_returns_tuple() {
1563        clear_all_overrides();
1564
1565        let (a, b) = with_capability_override(CapabilityOverride::modern(), || {
1566            let caps = current_capabilities_with_base(TerminalCapabilities::dumb());
1567            (caps.supports_true_color(), caps.mouse_sgr)
1568        });
1569        assert!(a);
1570        assert!(b);
1571    }
1572
1573    // ── apply_to flips true to false ──────────────────────────────────
1574
1575    #[test]
1576    fn apply_to_disables_on_modern_base() {
1577        let base = TerminalCapabilities::modern();
1578        let result = CapabilityOverride::new()
1579            .color_depth(Some(ColorDepth::Ansi256))
1580            .kitty_keyboard(Some(false))
1581            .apply_to(base);
1582        assert_eq!(result.color_depth, ColorDepth::Ansi256);
1583        assert!(!result.kitty_keyboard);
1584        // Others still modern
1585        assert!(result.supports_256_colors());
1586        assert!(result.unicode_box_drawing);
1587    }
1588
1589    // ── empty override stack returns base unchanged ───────────────────
1590
1591    #[test]
1592    fn current_capabilities_with_base_no_overrides_returns_base() {
1593        clear_all_overrides();
1594
1595        let base = TerminalCapabilities::modern();
1596        let caps = current_capabilities_with_base(base);
1597        assert_eq!(caps.color_depth, base.color_depth);
1598        assert_eq!(caps.unicode_box_drawing, base.unicode_box_drawing);
1599        assert_eq!(caps.unicode_emoji, base.unicode_emoji);
1600        assert_eq!(caps.double_width, base.double_width);
1601        assert_eq!(caps.sync_output, base.sync_output);
1602        assert_eq!(caps.osc8_hyperlinks, base.osc8_hyperlinks);
1603        assert_eq!(caps.scroll_region, base.scroll_region);
1604        assert_eq!(caps.in_tmux, base.in_tmux);
1605        assert_eq!(caps.in_screen, base.in_screen);
1606        assert_eq!(caps.in_zellij, base.in_zellij);
1607        assert_eq!(caps.kitty_keyboard, base.kitty_keyboard);
1608        assert_eq!(caps.focus_events, base.focus_events);
1609        assert_eq!(caps.bracketed_paste, base.bracketed_paste);
1610        assert_eq!(caps.mouse_sgr, base.mouse_sgr);
1611        assert_eq!(caps.osc52_clipboard, base.osc52_clipboard);
1612    }
1613}