chainview 0.1.2

Terminal UI for option chains, Greeks and volatility - real-time market data and backtest replay in your terminal.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
//! Terminal lifecycle: the RAII [`TerminalGuard`] and the panic-hook restore.
//!
//! The terminal is a shared resource the process borrows and must return on
//! **every** exit path — a clean return, an early `?`, or a panic
//! (`docs/02-tui-architecture.md` §6, ADR-0001). Setup enables raw mode, enters
//! the alternate screen, and hides the cursor; teardown runs the exact inverse
//! and is driven by [`Drop`], so the shell is restored even when the caller
//! forgets. [`install_panic_hook`] adds a hook that restores the terminal
//! **before** the chained previous hook prints, so a backtrace never lands on a
//! raw-mode screen and is never swallowed.
//!
//! Restore is best-effort and never panics: a partially-initialized or an
//! already-restored guard tears down cleanly (idempotent and tolerant). The
//! low-level operations are abstracted over the crate-internal `TerminalOps`
//! trait so the restore ordering and idempotency are unit-testable **without a
//! real TTY** — the deterministic sequencing proofs live in this module's
//! `#[cfg(test)]` block, and the real end-to-end panic path is exercised by the
//! subprocess harness in `tests/terminal_restore.rs` (`docs/TESTING.md` §7).

use std::io::{self, Stdout};
use std::panic;
use std::sync::atomic::{AtomicBool, Ordering};

use crossterm::cursor::{Hide, Show};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};

use crate::error::ChainViewError;

/// The low-level terminal operations the guard drives, in setup order:
/// enable raw mode, enter the alternate screen, hide the cursor — and their
/// inverses for teardown.
///
/// Abstracting these behind a trait lets the guard's restore ordering,
/// idempotency, and partial-setup tolerance be asserted deterministically
/// against a recording fake, with no real terminal attached. Production uses
/// [`CrosstermOps`]; tests use an in-memory recorder.
pub(crate) trait TerminalOps {
    /// Put the terminal into raw mode.
    fn enable_raw_mode(&mut self) -> io::Result<()>;
    /// Return the terminal from raw mode to cooked mode.
    fn disable_raw_mode(&mut self) -> io::Result<()>;
    /// Switch to the alternate screen buffer.
    fn enter_alternate_screen(&mut self) -> io::Result<()>;
    /// Return to the primary screen buffer.
    fn leave_alternate_screen(&mut self) -> io::Result<()>;
    /// Hide the cursor.
    fn hide_cursor(&mut self) -> io::Result<()>;
    /// Show the cursor.
    fn show_cursor(&mut self) -> io::Result<()>;
}

/// The production [`TerminalOps`], driving `crossterm` over the process stdout.
///
/// A fresh [`Stdout`] handle is acquired per call (each is a cheap clone of the
/// global handle) so teardown holds no long-lived borrow and stays allocation-
/// light for the panic path.
pub(crate) struct CrosstermOps;

impl TerminalOps for CrosstermOps {
    #[inline]
    fn enable_raw_mode(&mut self) -> io::Result<()> {
        enable_raw_mode()
    }

    #[inline]
    fn disable_raw_mode(&mut self) -> io::Result<()> {
        disable_raw_mode()
    }

    #[inline]
    fn enter_alternate_screen(&mut self) -> io::Result<()> {
        let mut out: Stdout = io::stdout();
        execute!(out, EnterAlternateScreen)
    }

    #[inline]
    fn leave_alternate_screen(&mut self) -> io::Result<()> {
        let mut out: Stdout = io::stdout();
        execute!(out, LeaveAlternateScreen)
    }

    #[inline]
    fn hide_cursor(&mut self) -> io::Result<()> {
        let mut out: Stdout = io::stdout();
        execute!(out, Hide)
    }

    #[inline]
    fn show_cursor(&mut self) -> io::Result<()> {
        let mut out: Stdout = io::stdout();
        execute!(out, Show)
    }
}

/// Map a terminal-backend I/O failure into the shared boundary error.
///
/// The message is a non-secret `crossterm`/`io` string — terminal operations
/// never touch a credential (`docs/01-domain-model.md` §11).
#[cold]
#[inline(never)]
fn terminal_error(err: io::Error) -> ChainViewError {
    ChainViewError::Terminal(err.to_string())
}

/// The generic guard core: owns the backend and the record of which setup steps
/// are currently applied, so teardown undoes exactly those, in inverse order,
/// at most once.
///
/// Generic over [`TerminalOps`] purely for testability; the public surface is
/// the concrete [`TerminalGuard`] below.
pub(crate) struct Guard<O: TerminalOps> {
    ops: O,
    raw_enabled: bool,
    alt_screen: bool,
    cursor_hidden: bool,
    restored: bool,
}

impl<O: TerminalOps> Guard<O> {
    /// Run the setup sequence, rolling back any partial progress on failure so a
    /// rejected setup leaves the terminal clean and returns the failure.
    fn new(ops: O) -> Result<Self, ChainViewError> {
        let mut guard = Self {
            ops,
            raw_enabled: false,
            alt_screen: false,
            cursor_hidden: false,
            restored: false,
        };
        if let Err(err) = guard.enter() {
            // Undo whatever succeeded before the failing step, then surface the
            // error. The subsequent `Drop` sees `restored == true` and is a
            // no-op, so setup failure never double-tears-down.
            guard.restore();
            return Err(err);
        }
        Ok(guard)
    }

    /// Apply the setup steps in order, recording each success so a mid-sequence
    /// failure rolls back precisely the applied prefix.
    fn enter(&mut self) -> Result<(), ChainViewError> {
        self.ops.enable_raw_mode().map_err(terminal_error)?;
        self.raw_enabled = true;
        self.ops.enter_alternate_screen().map_err(terminal_error)?;
        self.alt_screen = true;
        self.ops.hide_cursor().map_err(terminal_error)?;
        self.cursor_hidden = true;
        Ok(())
    }

    /// Restore the terminal: undo the applied setup steps in inverse order, at
    /// most once.
    ///
    /// Best-effort and infallible — a backend error on one step is ignored so
    /// the remaining steps still run and `Drop` never panics. (`Drop` cannot
    /// propagate an error, and stdout must not carry a failure while the TUI
    /// owns it; the tracing WARN sink lands with the supervisor in #11.)
    fn restore(&mut self) {
        if self.restored {
            return;
        }
        // Continue through every step even if one fails, but clear a state flag
        // ONLY when its inverse actually returned `Ok` — a failed step leaves its
        // flag set so the recorded state stays truthful (never "forgets" that raw
        // mode / the alternate screen is still applied). `restored` is latched
        // LAST, after the work, so the flags drive the teardown, not a flag set
        // before the ops ran.
        if self.cursor_hidden && self.ops.show_cursor().is_ok() {
            self.cursor_hidden = false;
        }
        if self.alt_screen && self.ops.leave_alternate_screen().is_ok() {
            self.alt_screen = false;
        }
        if self.raw_enabled && self.ops.disable_raw_mode().is_ok() {
            self.raw_enabled = false;
        }
        self.restored = true;
    }
}

impl<O: TerminalOps> Drop for Guard<O> {
    fn drop(&mut self) {
        self.restore();
    }
}

/// An RAII guard for the terminal: on construction it enables raw mode, enters
/// the alternate screen, and hides the cursor; on [`Drop`] it runs the exact
/// inverse.
///
/// Because teardown is driven by `Drop`, the terminal is restored on **every**
/// exit path — a normal return, an early `?`, or a panic (paired with
/// [`install_panic_hook`], which restores before the backtrace prints). Hold the
/// guard for the whole lifetime of the TUI; dropping it early restores the
/// terminal immediately.
///
/// Restore is idempotent and tolerant: a partially-initialized guard (setup
/// failed midway) and a double teardown both restore cleanly without panicking.
#[must_use = "hold the guard for the terminal's lifetime; dropping it restores the terminal"]
pub struct TerminalGuard {
    inner: Guard<CrosstermOps>,
}

impl TerminalGuard {
    /// Enter raw mode and the alternate screen and hide the cursor, returning a
    /// guard whose [`Drop`] restores the terminal.
    ///
    /// # Errors
    ///
    /// Returns [`ChainViewError::Terminal`] if the terminal backend rejects a
    /// setup step (for example, stdout is not a TTY). Any partially-applied
    /// setup is rolled back before returning, so the terminal is left clean.
    pub fn new() -> Result<Self, ChainViewError> {
        Ok(Self {
            inner: Guard::new(CrosstermOps)?,
        })
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        // Restore explicitly here (and again when `inner` drops — restore is
        // idempotent) so the guard's whole point, returning the terminal, is
        // driven by its own `Drop`.
        self.inner.restore();
    }
}

/// While a supervisor drives the ordered teardown it is the **single** owner of
/// the terminal restore (`docs/02-tui-architecture.md` §12): it cancels + joins
/// the render task, then restores the terminal **last**. In that window the panic
/// hook must NOT restore the terminal itself — a restore racing a still-live
/// render draw is the double-owner bug. This process-global flag lets the hook
/// defer to the active supervisor and own the restore only **outside** supervised
/// operation (a panic during startup or after teardown). The invariant "the
/// terminal is always restored on panic" holds either way: the supervisor
/// restores in order, or — if its future is unwound by a main-thread panic — the
/// [`TerminalGuard`] it owns restores on `Drop`.
static SUPERVISOR_OWNS_RESTORE: AtomicBool = AtomicBool::new(false);

/// Mark whether the active supervisor owns the terminal restore. Set by the
/// app-layer task supervisor around its ordered teardown
/// (`docs/02-tui-architecture.md` §12); read by the panic hook to decide whether
/// to defer. This is an app -> terminal call (the supervisor depends on this
/// leaf module), never the reverse — the restore-owner state lives with the
/// restore code, so the layering stays one-directional.
pub(crate) fn set_supervisor_owns_restore(owned: bool) {
    SUPERVISOR_OWNS_RESTORE.store(owned, Ordering::SeqCst);
}

/// Whether a supervisor currently owns the ordered terminal restore.
fn supervisor_owns_restore() -> bool {
    SUPERVISOR_OWNS_RESTORE.load(Ordering::SeqCst)
}

/// Whether the panic hook should perform its own terminal restore: `true` only
/// when no supervisor owns the ordered restore. Pure so the single-owner decision
/// is unit-testable without touching the process-global flag.
fn should_hook_restore(supervisor_owns_restore: bool) -> bool {
    !supervisor_owns_restore
}

thread_local! {
    /// Depth of CONTAINED-panic boundaries active on THIS thread. While non-zero,
    /// the panic hook is fully silent (no restore, no chained print): the caller
    /// is about to `catch_unwind` the panic and map it to a typed error (the #53
    /// replay decode boundary), so the process-global side effects — restoring
    /// raw mode under a live TUI, or printing into the alternate screen — would
    /// be exactly the damage the boundary exists to prevent. Thread-local (the
    /// hook runs ON the panicking thread), so an unrelated panic on another
    /// thread is never suppressed.
    static CONTAINED_PANIC_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

/// RAII marker for a contained-panic boundary: while alive, a panic on this
/// thread runs NO hook side effects (the `catch_unwind` caller owns the outcome
/// as a typed error). Nesting is counted; `Drop` restores the prior depth.
pub(crate) struct ContainedPanicGuard;

impl ContainedPanicGuard {
    /// Enter a contained-panic boundary on this thread.
    pub(crate) fn new() -> Self {
        CONTAINED_PANIC_DEPTH.with(|d| {
            // Checked, not saturating (a banned method); the lint-safe shape is
            // the explicit if-let (nesting depth cannot realistically overflow).
            if let Some(next) = d.get().checked_add(1) {
                d.set(next);
            }
        });
        Self
    }
}

impl Drop for ContainedPanicGuard {
    fn drop(&mut self) {
        CONTAINED_PANIC_DEPTH.with(|d| {
            let next = match d.get() {
                0 => 0,
                n => n - 1,
            };
            d.set(next);
        });
    }
}

/// Whether a contained-panic boundary is active on this thread.
fn contained_panic_active() -> bool {
    CONTAINED_PANIC_DEPTH.with(|d| d.get() > 0)
}

/// Run `op` inside a contained-panic boundary: `Some(value)` when it returns
/// normally, `None` when it **panics**.
///
/// This is the single implementation of the containment policy every caller
/// shares. Two seams need it, both for the same reason — an upstream crate
/// `panic!`s on an input a `Result` cannot express, and a panic escaping into the
/// render loop would take the whole terminal down:
///
/// * the Parquet/Arrow decode of a malformed replay bundle
///   ([`catch_decode_panic`](crate::replay), issue #53), and
/// * the `optionstratlib` pricing math behind a payoff curve
///   (`src/app/payoff_build.rs`, `src/app/replay_payoff_build.rs`, issue #131),
///   where the caller renders an explicit "curve unavailable" state instead.
///
/// Three properties the callers depend on, all of them the reason this lives in
/// one place rather than being written twice:
///
/// * [`std::panic::catch_unwind`] needs **no** `unsafe`, so
///   `#![forbid(unsafe_code)]` holds.
/// * The thread-local [`ContainedPanicGuard`] is held **across** the call, so the
///   process panic hook stays silent on this thread: it neither restores the
///   terminal under a live TUI nor prints into the alternate screen. An
///   uncontained panic on any other thread still runs the full hook.
/// * The panic payload is **dropped**, never returned or interpolated into a
///   message, so a hostile bundle or a pathological quote cannot steer any
///   user-visible string. The caller names the operation itself.
///
/// [`std::panic::AssertUnwindSafe`] is the caller's contract: the value `op`
/// borrows must be abandoned on the panic path, never observed again in its
/// partially-updated state. Both seams satisfy it by returning an error or an
/// empty state and dropping the work in progress.
pub(crate) fn contained<T>(op: impl FnOnce() -> T) -> Option<T> {
    let _contained = ContainedPanicGuard::new();
    panic::catch_unwind(panic::AssertUnwindSafe(op)).ok()
}

/// Install a panic hook that restores the terminal **before** chaining to the
/// previously installed hook.
///
/// The prior hook (captured via [`std::panic::take_hook`]) is invoked *after*
/// the restore, so the panic message and any backtrace print on a normal
/// (non-raw) screen and are never swallowed (`docs/02-tui-architecture.md` §6).
/// Install this once at startup, before the [`TerminalGuard`] enters the
/// alternate screen.
pub fn install_panic_hook() {
    let previous = panic::take_hook();
    panic::set_hook(Box::new(move |info| {
        if contained_panic_active() {
            // A catch_unwind boundary on THIS thread owns the outcome as a typed
            // error (#53): stay fully silent - no restore (a live TUI keeps its
            // raw mode) and no chained print (nothing lands on the alternate
            // screen). An uncontained panic elsewhere still runs the full hook.
            return;
        }
        restore_then_chain(restore_on_panic, |i| previous(i), info);
    }));
}

/// Run `restore` first, then invoke `next` with `payload`.
///
/// Factored out of [`install_panic_hook`] so the ordering guarantee — the
/// restore runs strictly before the chained hook — is unit-testable without
/// installing a process-global hook or constructing a `PanicHookInfo`.
#[inline]
fn restore_then_chain<T>(restore: impl FnOnce(), next: impl FnOnce(&T), payload: &T) {
    restore();
    next(payload);
}

/// Best-effort, synchronous terminal restore for the panic path: show the
/// cursor, leave the alternate screen, and disable raw mode.
///
/// Errors are intentionally ignored — nothing actionable remains inside a panic,
/// and stdout must not carry a second failure. This runs before the chained
/// previous hook prints, so the terminal is already normal when the backtrace
/// lands.
fn restore_on_panic() {
    if !should_hook_restore(supervisor_owns_restore()) {
        // A supervisor is driving the ordered teardown and owns the single
        // restore; deferring here removes the double-owner race with a still-live
        // render draw (`docs/02-tui-architecture.md` §12). The supervisor restores
        // LAST, after joining the render task; if its future is unwound instead,
        // the `TerminalGuard` it owns restores on `Drop`. The panic is still
        // surfaced after restore via the supervisor's `ExitCause` and the log.
        return;
    }
    let mut out: Stdout = io::stdout();
    let _ = execute!(out, Show, LeaveAlternateScreen);
    let _ = disable_raw_mode();
}

#[cfg(test)]
mod tests {
    use std::cell::RefCell;
    use std::rc::Rc;

    use super::*;

    /// A recorded terminal operation, in the exact order it was applied.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    enum Op {
        EnableRaw,
        DisableRaw,
        EnterAlt,
        LeaveAlt,
        HideCursor,
        ShowCursor,
    }

    /// A recording [`TerminalOps`] fake. Records each *successful* op into a
    /// shared log and can be configured to fail exactly one op (to inject a
    /// setup failure or a teardown-step failure). Failed ops are not recorded,
    /// so the log reflects the terminal's actual state changes.
    struct FakeOps {
        log: Rc<RefCell<Vec<Op>>>,
        fail_on: Option<Op>,
    }

    impl FakeOps {
        fn new(log: Rc<RefCell<Vec<Op>>>) -> Self {
            Self { log, fail_on: None }
        }

        fn failing(log: Rc<RefCell<Vec<Op>>>, fail_on: Op) -> Self {
            Self {
                log,
                fail_on: Some(fail_on),
            }
        }

        fn run(&mut self, op: Op) -> io::Result<()> {
            if self.fail_on == Some(op) {
                return Err(io::Error::other("injected terminal failure"));
            }
            self.log.borrow_mut().push(op);
            Ok(())
        }
    }

    impl TerminalOps for FakeOps {
        fn enable_raw_mode(&mut self) -> io::Result<()> {
            self.run(Op::EnableRaw)
        }
        fn disable_raw_mode(&mut self) -> io::Result<()> {
            self.run(Op::DisableRaw)
        }
        fn enter_alternate_screen(&mut self) -> io::Result<()> {
            self.run(Op::EnterAlt)
        }
        fn leave_alternate_screen(&mut self) -> io::Result<()> {
            self.run(Op::LeaveAlt)
        }
        fn hide_cursor(&mut self) -> io::Result<()> {
            self.run(Op::HideCursor)
        }
        fn show_cursor(&mut self) -> io::Result<()> {
            self.run(Op::ShowCursor)
        }
    }

    fn new_log() -> Rc<RefCell<Vec<Op>>> {
        Rc::new(RefCell::new(Vec::new()))
    }

    #[test]
    fn test_guard_new_records_setup_sequence_in_order() {
        let log = new_log();
        let guard = match Guard::new(FakeOps::new(Rc::clone(&log))) {
            Ok(g) => g,
            Err(e) => panic!("expected setup to succeed, got: {e}"),
        };
        // Setup order: raw mode, then alternate screen, then hide cursor.
        assert_eq!(
            *log.borrow(),
            vec![Op::EnableRaw, Op::EnterAlt, Op::HideCursor]
        );
        drop(guard);
    }

    #[test]
    fn test_guard_drop_restores_inverse_sequence() {
        let log = new_log();
        let guard = match Guard::new(FakeOps::new(Rc::clone(&log))) {
            Ok(g) => g,
            Err(e) => panic!("expected setup to succeed, got: {e}"),
        };
        drop(guard);
        // Full lifecycle: setup forwards, teardown the exact inverse.
        assert_eq!(
            *log.borrow(),
            vec![
                Op::EnableRaw,
                Op::EnterAlt,
                Op::HideCursor,
                Op::ShowCursor,
                Op::LeaveAlt,
                Op::DisableRaw,
            ]
        );
    }

    #[test]
    fn test_guard_restore_continues_past_a_failed_step_and_keeps_the_flag_truthful() {
        // Inject a failure on the FIRST teardown step (show_cursor). The remaining
        // steps must still run, and the failed step's flag must stay set so the
        // recorded state never "forgets" that the cursor is still hidden.
        let log = new_log();
        let mut guard = match Guard::new(FakeOps::failing(Rc::clone(&log), Op::ShowCursor)) {
            Ok(g) => g,
            Err(e) => panic!("expected setup to succeed, got: {e}"),
        };
        guard.restore();
        // show_cursor failed (not recorded), but leave_alt + disable_raw still ran.
        assert_eq!(
            *log.borrow(),
            vec![
                Op::EnableRaw,
                Op::EnterAlt,
                Op::HideCursor,
                Op::LeaveAlt,
                Op::DisableRaw,
            ]
        );
        // The failed step's flag stays TRUE (state truthful); the succeeded ones
        // are cleared; restore is latched.
        assert!(
            guard.cursor_hidden,
            "a failed show_cursor must not clear the flag"
        );
        assert!(!guard.alt_screen);
        assert!(!guard.raw_enabled);
        assert!(guard.restored);
    }

    #[test]
    fn test_guard_double_restore_is_idempotent() {
        let log = new_log();
        let mut guard = match Guard::new(FakeOps::new(Rc::clone(&log))) {
            Ok(g) => g,
            Err(e) => panic!("expected setup to succeed, got: {e}"),
        };
        guard.restore();
        guard.restore();
        drop(guard);
        // Teardown appears exactly once despite three restore attempts.
        assert_eq!(
            *log.borrow(),
            vec![
                Op::EnableRaw,
                Op::EnterAlt,
                Op::HideCursor,
                Op::ShowCursor,
                Op::LeaveAlt,
                Op::DisableRaw,
            ]
        );
    }

    #[test]
    fn test_guard_partial_setup_teardown_undoes_only_applied_steps() {
        // A half-set-up guard: only raw mode was applied. Teardown must undo just
        // that step and must not panic.
        let log = new_log();
        let mut guard = Guard {
            ops: FakeOps::new(Rc::clone(&log)),
            raw_enabled: true,
            alt_screen: false,
            cursor_hidden: false,
            restored: false,
        };
        guard.restore();
        drop(guard);
        assert_eq!(*log.borrow(), vec![Op::DisableRaw]);
    }

    #[test]
    fn test_guard_new_setup_failure_rolls_back_applied_prefix() {
        // Failure at "enter alternate screen": raw mode was applied and must be
        // rolled back; the alternate screen and cursor were never touched.
        let log = new_log();
        let err = match Guard::new(FakeOps::failing(Rc::clone(&log), Op::EnterAlt)) {
            Err(e) => e,
            Ok(_) => panic!("expected setup to fail at the alternate screen"),
        };
        assert!(matches!(err, ChainViewError::Terminal(_)));
        assert_eq!(*log.borrow(), vec![Op::EnableRaw, Op::DisableRaw]);
    }

    #[test]
    fn test_guard_restore_tolerates_backend_error_and_continues() {
        // The cursor-show step fails during teardown; restore must swallow it and
        // still leave the alternate screen and disable raw mode.
        let log = new_log();
        let mut guard = Guard {
            ops: FakeOps::failing(Rc::clone(&log), Op::ShowCursor),
            raw_enabled: true,
            alt_screen: true,
            cursor_hidden: true,
            restored: false,
        };
        guard.restore();
        drop(guard);
        // ShowCursor failed (unrecorded), yet the remaining teardown ran.
        assert_eq!(*log.borrow(), vec![Op::LeaveAlt, Op::DisableRaw]);
    }

    #[test]
    fn test_restore_then_chain_runs_restore_before_chained_hook() {
        let order: RefCell<Vec<&'static str>> = RefCell::new(Vec::new());
        restore_then_chain(
            || order.borrow_mut().push("restore"),
            |_payload: &u8| order.borrow_mut().push("chained"),
            &0u8,
        );
        assert_eq!(*order.borrow(), vec!["restore", "chained"]);
    }

    #[test]
    fn test_should_hook_restore_defers_to_an_active_supervisor() {
        // Outside supervised operation the hook owns the restore; while a
        // supervisor owns the ordered restore the hook defers, so the terminal
        // is restored by a single owner and never out from under a live draw
        // (`docs/02-tui-architecture.md` §12).
        assert!(
            should_hook_restore(false),
            "no supervisor active: the panic hook owns the restore"
        );
        assert!(
            !should_hook_restore(true),
            "a supervisor owns the ordered restore: the hook defers"
        );
    }

    // --- The contained-panic boundary (#53 decode, #131 payoff curves) -------
    //
    // These three drive REAL panics through `contained`. The process panic hook is
    // not installed in a unit test, so the libtest default hook prints the caught
    // panic to the captured stderr — expected noise, never a failure.

    #[test]
    fn test_contained_returns_some_when_the_operation_returns() {
        // The happy path is transparent: the value passes straight through.
        assert_eq!(contained(|| 7_u8), Some(7));
        assert_eq!(contained(|| "curve".to_owned()), Some("curve".to_owned()));
    }

    #[test]
    fn test_contained_returns_none_when_the_operation_panics() {
        // A panic is CONTAINED and reported as `None` — the caller maps it to its own
        // typed error / honest empty state, and the payload is dropped (never returned,
        // so no hostile input can steer a user-visible string).
        let caught: Option<u8> = contained(|| panic!("upstream blew up"));
        assert_eq!(caught, None, "a panicking op yields None, not an unwind");
    }

    #[test]
    fn test_contained_nests_and_restores_the_guard_depth() {
        // Nesting is counted, an inner panic does not disarm the outer boundary, and
        // the depth is back to zero afterwards — so a later, UNCONTAINED panic on this
        // thread still runs the full hook (terminal restore + chained print).
        assert!(
            !contained_panic_active(),
            "no boundary is active before the test"
        );
        let outer = contained(|| {
            assert!(contained_panic_active(), "the outer boundary is active");
            let inner: Option<()> = contained(|| panic!("inner"));
            assert_eq!(inner, None, "the inner panic is contained");
            assert!(
                contained_panic_active(),
                "the outer boundary survives the inner panic"
            );
            "done"
        });
        assert_eq!(outer, Some("done"));
        assert!(
            !contained_panic_active(),
            "the guard depth is restored, so an uncontained panic still runs the hook"
        );
    }

    #[test]
    fn test_restore_then_chain_always_invokes_chained_hook() {
        // The chained (previous) hook is never swallowed — proving the panic
        // message still prints after the restore.
        let chained = RefCell::new(false);
        restore_then_chain(|| {}, |_p: &()| *chained.borrow_mut() = true, &());
        assert!(*chained.borrow());
    }
}