ktstr/cli/
util.rs

1//! Terminal-output utilities: color detection, table builders,
2//! status / success / warn helpers, and the `Spinner` progress bar.
3//!
4//! Holds the cross-binary helpers that the rest of the CLI surface
5//! delegates to for visible output. Lives in its own submodule
6//! because the Spinner machinery (panic-hook stash, nesting guard,
7//! termios save/restore) is its own contained subsystem unrelated
8//! to kernel build / list / resolve dispatch.
9
10use std::time::Duration;
11
12/// Whether stderr supports color (cached per process).
13pub fn stderr_color() -> bool {
14    use std::io::IsTerminal;
15    static COLOR: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
16    *COLOR.get_or_init(|| std::io::stderr().is_terminal())
17}
18
19/// Whether stdout supports color (cached per process). Distinct from
20/// [`stderr_color`] because `cargo ktstr perf-delta > report.txt`
21/// pipes stdout to a file while leaving stderr on the TTY — gating
22/// stdout tables on the stderr TTY state would leave ANSI escapes
23/// in the file. Table-rendering code paths gate on this reading;
24/// diagnostic/status prints use [`stderr_color`].
25pub fn stdout_color() -> bool {
26    use std::io::IsTerminal;
27    static COLOR: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
28    *COLOR.get_or_init(|| std::io::stdout().is_terminal())
29}
30
31/// Build a borderless comfy-table with styling gated on
32/// [`stdout_color`]. When stdout is not a TTY (CI, piped-to-file),
33/// `force_no_tty` suppresses cell color escapes so a log or grep
34/// capture does not land raw `\x1b[...` sequences. The NOTHING preset
35/// skips box-drawing characters and keeps whitespace-padded columns,
36/// matching the previous hand-rolled `format!("{:<30}…")` look while
37/// auto-measuring each column from actual cell contents.
38///
39/// `ContentArrangement::Disabled` is the default arrangement: columns
40/// expand to whatever each cell needs, even when the result spills
41/// past the terminal edge. Callers that want terminal-width-aware
42/// cell wrapping use [`new_wrapped_table`] (ctprof compare/show
43/// reaches it via `--wrap`).
44pub fn new_table() -> comfy_table::Table {
45    use comfy_table::{ContentArrangement, Table, presets::NOTHING};
46    let mut t = Table::new();
47    t.load_preset(NOTHING);
48    t.set_content_arrangement(ContentArrangement::Disabled);
49    if !stdout_color() {
50        t.force_no_tty();
51    }
52    t
53}
54
55/// Variant of [`new_table`] that opts into comfy-table's
56/// terminal-width-aware [`comfy_table::ContentArrangement::Dynamic`]
57/// layout. Cells too wide for the available terminal width wrap
58/// inside the cell rather than pushing later columns past the edge,
59/// at the cost of taller rows. Used by `ctprof compare` /
60/// `ctprof show` under the `--wrap` flag; the existing
61/// fixed-column [`new_table`] stays the default for every other
62/// caller (locks, verifier, stats) so their output stays
63/// byte-stable for shell-pipeline consumers.
64///
65/// When stdout is not a TTY, comfy-table's terminal-width probe
66/// returns `None`. The `Dynamic` arrangement is documented to
67/// degrade to `Disabled` in that case; we additionally call
68/// [`comfy_table::Table::force_no_tty`] under the same
69/// `!stdout_color()` gate as [`new_table`], so a piped stdout that
70/// requested `--wrap` still suppresses ANSI escapes. The end-state
71/// behaviour under a non-TTY stdout is therefore equivalent to
72/// [`new_table`]'s — the wrap request is silently dropped rather
73/// than producing unbounded-wrap output without a width.
74pub fn new_wrapped_table() -> comfy_table::Table {
75    use comfy_table::{ContentArrangement, Table, presets::NOTHING};
76    let mut t = Table::new();
77    t.load_preset(NOTHING);
78    t.set_content_arrangement(ContentArrangement::Dynamic);
79    if !stdout_color() {
80        t.force_no_tty();
81    }
82    t
83}
84
85/// Restore SIGPIPE to its default action (terminate the process)
86/// so piping a ktstr binary's output to a reader that closes
87/// early (e.g. `... | head`) does not panic inside `print!` /
88/// `println!`. Rust's startup code sets SIGPIPE to `SIG_IGN`,
89/// which turns the broken-pipe write into an `io::Error` that
90/// `print!` escalates to a panic. Setting `SIG_DFL` restores the
91/// POSIX "process terminates on SIGPIPE" convention that Unix
92/// CLI tools rely on.
93///
94/// Call this at the TOP of each of the three user-facing CLIs'
95/// `main` — `ktstr`, `cargo-ktstr`, and `ktstr-jemalloc-probe` —
96/// before the tracing subscriber installs its stderr handler and
97/// before any stdout write. Shared across `src/bin/ktstr.rs`,
98/// `src/bin/cargo-ktstr.rs`, and `src/bin/jemalloc_probe.rs` so
99/// the three CLIs behave identically under `|` pipelines and a
100/// future reword of the SAFETY rationale lands in one place. The
101/// `ktstr-jemalloc-alloc-worker` binary does NOT call this — it
102/// is a test-fixture target spawned by the probe's closed-loop
103/// integration tests, never piped by a human operator, and its
104/// stdout emission path prints a single "ready" breadcrumb that
105/// the test body ignores, so SIGPIPE restoration there would
106/// add noise without benefit.
107///
108/// No return value; the call is effectively infallible (libc's
109/// `signal(2)` can't fail for a standard signal + SIG_DFL
110/// handler on a live process).
111///
112/// # Safety (FFI)
113///
114/// `libc::signal` is an FFI call with no memory effects (no
115/// pointer dereferences, no mutation of Rust state). `SIG_DFL`
116/// is a well-known constant handler. Call must run before any
117/// stdout writes so the handler is in place by the time
118/// `print!` fires.
119pub fn restore_sigpipe_default() {
120    // SAFETY: see fn-level doc comment.
121    unsafe {
122        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
123    }
124}
125
126/// Print a styled status message to stderr.
127pub(crate) fn status(msg: &str) {
128    if stderr_color() {
129        eprintln!("\x1b[1m{msg}\x1b[0m");
130    } else {
131        eprintln!("{msg}");
132    }
133}
134
135/// Print a green success message to stderr.
136pub(crate) fn success(msg: &str) {
137    if stderr_color() {
138        eprintln!("\x1b[32m{msg}\x1b[0m");
139    } else {
140        eprintln!("{msg}");
141    }
142}
143
144/// Print a blue warning to stderr.
145pub(crate) fn warn(msg: &str) {
146    if stderr_color() {
147        eprintln!("\x1b[34m{msg}\x1b[0m");
148    } else {
149        eprintln!("{msg}");
150    }
151}
152
153/// Stash of the pre-spinner termios for the panic hook's restore
154/// path. Populated by [`Spinner::disable_echo`] before the ECHO flag
155/// is cleared, and cleared by [`Spinner::teardown`] on normal exit.
156/// The panic hook reads this mutex — when populated, it replays the
157/// stashed termios to the terminal BEFORE the default panic handler
158/// emits its message. Under `panic = "abort"`, `Spinner::Drop` never
159/// runs, so without the hook the terminal stays in echo-disabled /
160/// non-canonical mode and the multi-line panic message staircases
161/// (LF without CR) before SIGABRT kills the process.
162static SPINNER_SAVED_TERMIOS: std::sync::Mutex<Option<libc::termios>> = std::sync::Mutex::new(None);
163
164/// Tracks whether a [`Spinner`] is currently alive. `Spinner::start`
165/// flips this from `false` to `true`; `Drop` flips it back. A
166/// `debug_assert!` at start-time fires when the previous value was
167/// already `true`, catching nested `Spinner::start()` calls that
168/// would clobber [`SPINNER_SAVED_TERMIOS`]: the second `start` saves
169/// the outer spinner's ALREADY-ECHO-disabled termios, and the outer
170/// teardown then restores to the disabled state instead of the
171/// original. Release builds skip the check (the assertion compiles
172/// away) rather than panic in production; the flag is still
173/// maintained so a future `debug_assert` → `assert` upgrade would
174/// not need a second seam.
175static SPINNER_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
176
177/// Install a panic hook that restores stdin termios from
178/// [`SPINNER_SAVED_TERMIOS`] before the default panic handler prints.
179/// Called via [`std::sync::Once`] from [`Spinner::disable_echo`], so
180/// every Spinner that actually mutates termios triggers the install
181/// exactly once per process. Idempotent — subsequent calls hit the
182/// `Once` guard and no-op.
183///
184/// The hook delegates to the default `take_hook()` output after
185/// restoring, preserving the full panic-message contract (message,
186/// location, backtrace under `RUST_BACKTRACE`).
187///
188/// # Panic-hook stacking convention
189///
190/// ktstr installs hooks in two places: this spinner-termios restorer
191/// and the vCPU classifier (`crate::vmm::vcpu_panic::install_once`).
192/// `std::panic::set_hook` is process-wide — whichever site installs
193/// LAST wins, and earlier hooks are reached only via the previous-
194/// hook chain each site captures at install time. Every ktstr-side
195/// installer MUST follow the stacking pattern used here: call
196/// `std::panic::take_hook()` to capture the current hook, then
197/// `set_hook` a closure that runs its own work AND calls the
198/// captured `prev(info)` at the end. Skipping the delegation
199/// breaks the chain and silently drops every earlier-installed
200/// hook. See the module-level doc on `src/vmm/vcpu_panic.rs` for
201/// the full rationale (limitations section) and an alternative
202/// `make_hook(prev)` factoring; the pattern is identical, just
203/// packaged differently.
204fn install_spinner_termios_panic_hook() {
205    static INSTALLED: std::sync::Once = std::sync::Once::new();
206    INSTALLED.call_once(|| {
207        let default = std::panic::take_hook();
208        std::panic::set_hook(Box::new(move |info| {
209            // try_lock, not lock: if the panicking thread is the
210            // one mid-mutation inside Spinner::disable_echo (holds
211            // the mutex across its own libc::tcsetattr call), a
212            // blocking lock would deadlock the hook. try_lock
213            // failure ≈ "mutex held by someone mid-mutation" — the
214            // terminal state is indeterminate and the hook
215            // cannot safely restore, so we fall through to the
216            // default handler unchanged.
217            if let Ok(guard) = SPINNER_SAVED_TERMIOS.try_lock()
218                && let Some(termios) = *guard
219            {
220                unsafe {
221                    libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
222                }
223            }
224            default(info);
225        }));
226    });
227}
228
229/// Progress spinner for long-running CLI operations.
230///
231/// When stderr is a TTY, draws an animated spinner via indicatif,
232/// ticks in the background, and disables stdin echo to prevent
233/// keypress jank. When stderr is not a TTY, skips all indicatif
234/// machinery and falls back to plain stderr writes.
235/// Call `finish` with a completion message to replace it with a
236/// final line, or let it drop to remove it silently; [`Drop`] also
237/// restores echo and clears the bar so a panic or early `?`
238/// propagation leaves the terminal in a usable state. Under
239/// `panic = "abort"`, Drop does NOT run on a panic — the panic hook
240/// installed by `install_spinner_termios_panic_hook` restores
241/// termios instead, so the panic message renders cleanly before
242/// SIGABRT kills the process. Note: Drop also does NOT run on
243/// SIGINT/SIGTERM kill; if the spinner is interrupted mid-operation,
244/// run `stty sane` to restore echo.
245pub struct Spinner {
246    /// None when stderr is not a TTY — no indicatif overhead.
247    pb: Option<indicatif::ProgressBar>,
248    /// Saved termios for echo restore. None when stdin is not a tty
249    /// or when the spinner is inactive (non-TTY stderr). Owned directly
250    /// (not `Arc<Mutex>`) because Spinner is not Clone.
251    saved_termios: Option<libc::termios>,
252}
253
254impl Spinner {
255    /// Start a spinner with the given message (e.g. "Building kernel...").
256    ///
257    /// When stderr is not a TTY, no ProgressBar or ticker thread is
258    /// created — all output methods fall back to plain `eprintln!`.
259    pub fn start(msg: impl Into<std::borrow::Cow<'static, str>>) -> Self {
260        // Nesting rejection: a second `Spinner::start()` while
261        // another Spinner is still live would overwrite
262        // SPINNER_SAVED_TERMIOS with the ALREADY-ECHO-disabled
263        // termios that the outer spinner installed; the outer's
264        // Drop / teardown would then restore the disabled state
265        // instead of the pre-spinner state, leaving the terminal
266        // broken after both exit. `debug_assert!` catches the
267        // misuse under `cargo test` / `cargo nextest` without
268        // paying a release-mode cost. Release builds allow the
269        // nesting and accept the terminal-leakage risk (the
270        // alternative — panicking release binaries — would be
271        // worse than a terminal that needs `reset` after a crash
272        // path that was never exercised in testing). If nesting
273        // is genuinely needed in the future, flip this guard and
274        // add depth-aware save/restore logic to `teardown()`.
275        //
276        // The flag is swapped unconditionally at start (before the
277        // TTY-absence short-circuit) AND cleared in both Drop and
278        // the `is_hidden()` early-return below, so the invariant
279        // `SPINNER_ACTIVE == true iff a Spinner exists` holds
280        // across every exit path.
281        debug_assert!(
282            !SPINNER_ACTIVE.swap(true, std::sync::atomic::Ordering::SeqCst),
283            "Spinner::start called while another Spinner is already \
284             active. Nested spinners clobber SPINNER_SAVED_TERMIOS — \
285             the outer spinner's restore path would reset to the \
286             already-modified termios state instead of the original. \
287             If nesting is genuinely needed, refactor the save/restore \
288             path to depth-count before lifting this assertion.",
289        );
290
291        if !stderr_color() {
292            return Spinner {
293                pb: None,
294                saved_termios: None,
295            };
296        }
297
298        let pb = indicatif::ProgressBar::new_spinner();
299        pb.set_style(
300            indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}")
301                .expect("valid template"),
302        );
303        pb.set_message(msg);
304        pb.enable_steady_tick(Duration::from_millis(80));
305
306        // indicatif hides the bar when NO_COLOR is set or TERM is
307        // dumb, even on a real TTY. Downgrade to the non-TTY path
308        // so println/finish output is not silently dropped.
309        if pb.is_hidden() {
310            return Spinner {
311                pb: None,
312                saved_termios: None,
313            };
314        }
315
316        let saved_termios = Self::disable_echo();
317
318        Spinner {
319            pb: Some(pb),
320            saved_termios,
321        }
322    }
323
324    fn disable_echo() -> Option<libc::termios> {
325        use std::io::IsTerminal;
326        if !std::io::stdin().is_terminal() {
327            return None;
328        }
329        unsafe {
330            let fd = libc::STDIN_FILENO;
331            let mut termios: libc::termios = std::mem::zeroed();
332            if libc::tcgetattr(fd, &mut termios) != 0 {
333                return None;
334            }
335            let saved = termios;
336            // Stash the pre-mutation termios for the panic hook's
337            // restore path. Under `panic=abort` the Spinner's Drop
338            // never runs, so if a panic fires while the spinner is
339            // active the terminal stays in echo-disabled mode and
340            // the panic message renders with a "staircase" effect
341            // (LF without CR). The hook replays the saved termios
342            // before the default panic handler prints, producing a
343            // readable diagnostic on the way to SIGABRT.
344            install_spinner_termios_panic_hook();
345            *SPINNER_SAVED_TERMIOS.lock().unwrap() = Some(saved);
346            termios.c_lflag &= !libc::ECHO;
347            libc::tcsetattr(fd, libc::TCSANOW, &termios);
348            Some(saved)
349        }
350    }
351
352    /// Restore stdin echo if we disabled it, consuming `saved_termios`
353    /// via [`Option::take`]. Idempotent — `finish` and the `Drop`
354    /// impl both call this; only the first call has any effect. The
355    /// old standalone `clear` method was consolidated into `Drop`
356    /// (calling `drop(spinner)` produces the same effect).
357    fn teardown(&mut self) {
358        if let Some(termios) = self.saved_termios.take() {
359            unsafe {
360                libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
361            }
362            // Clear the panic-hook stash — further panics without a
363            // live Spinner should NOT try to restore a termios we
364            // already restored via the normal path.
365            *SPINNER_SAVED_TERMIOS.lock().unwrap() = None;
366        }
367    }
368
369    /// Update the spinner message.
370    pub fn set_message(&self, msg: impl Into<std::borrow::Cow<'static, str>>) {
371        if let Some(ref pb) = self.pb {
372            pb.set_message(msg);
373        }
374    }
375
376    /// Finish the spinner, replacing it with a completion message.
377    ///
378    /// In non-TTY mode, prints the message to stderr directly.
379    pub fn finish(mut self, msg: impl Into<std::borrow::Cow<'static, str>>) {
380        self.teardown();
381        match self.pb.take() {
382            Some(pb) => pb.finish_with_message(msg),
383            None => eprintln!("{}", msg.into()),
384        }
385    }
386
387    /// Print a line above the spinner. The spinner redraws below.
388    ///
389    /// In non-TTY mode, prints directly to stderr.
390    pub fn println(&self, msg: impl AsRef<str>) {
391        match self.pb {
392            Some(ref pb) => pb.println(msg),
393            None => eprintln!("{}", msg.as_ref()),
394        }
395    }
396
397    /// Suspend the spinner tick, execute a closure, then resume.
398    /// Use for terminal output that must not race with the spinner.
399    ///
400    /// In non-TTY mode, calls `f` directly (no spinner to suspend).
401    pub fn suspend<F: FnOnce() -> R, R>(&self, f: F) -> R {
402        match self.pb {
403            Some(ref pb) => pb.suspend(f),
404            None => f(),
405        }
406    }
407
408    /// Run `f` under a spinner that starts with `start_msg`, replaces
409    /// itself with `success_msg` on `Ok`, and drops silently on `Err`
410    /// so the error propagates without a stale progress bar obscuring
411    /// the caller's diagnostics. The closure receives the live
412    /// `&Spinner` so it can call [`Self::println`] / [`Self::suspend`]
413    /// / [`Self::set_message`] during the operation.
414    pub fn with_progress<T, E, F>(
415        start_msg: impl Into<std::borrow::Cow<'static, str>>,
416        success_msg: impl Into<std::borrow::Cow<'static, str>>,
417        f: F,
418    ) -> Result<T, E>
419    where
420        F: FnOnce(&Spinner) -> Result<T, E>,
421    {
422        let sp = Spinner::start(start_msg);
423        let result = f(&sp);
424        match result {
425            Ok(v) => {
426                sp.finish(success_msg);
427                Ok(v)
428            }
429            Err(e) => {
430                drop(sp);
431                Err(e)
432            }
433        }
434    }
435}
436
437impl Drop for Spinner {
438    /// Restore terminal echo and clear any live progress bar on drop.
439    ///
440    /// [`finish`](Self::finish) calls `Self::teardown` and takes
441    /// `self.pb` via [`Option::take`], so this impl is a no-op after
442    /// an explicit end. When the spinner is dropped implicitly
443    /// (panic, `?` propagation, `drop(sp)`, or scope exit), this
444    /// restores the termios saved in `Self::disable_echo` and
445    /// clears the live bar so stdin is usable afterwards.
446    fn drop(&mut self) {
447        self.teardown();
448        if let Some(pb) = self.pb.take() {
449            pb.finish_and_clear();
450        }
451        // Release the nesting guard. Paired with the `swap(true)` in
452        // `Spinner::start`: Drop fires exactly once per Spinner
453        // (owned value), so the flag returns to `false` and the
454        // next call to `start` can succeed. Unconditional store
455        // rather than a swap — a nested misuse already panicked
456        // under `debug_assert`, so the ordering of the counter
457        // value on the first observer side is less important than
458        // releasing the guard for the next legitimate caller.
459        SPINNER_ACTIVE.store(false, std::sync::atomic::Ordering::SeqCst);
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn spinner_drop_without_finish_does_not_panic_in_non_tty() {
469        // Regression: Spinner previously had no Drop impl so early return
470        // or panic leaked the disabled-ECHO termios. The added Drop must
471        // run cleanly even on the non-TTY path (pb is None, saved_termios
472        // is None) that nextest exercises under stderr capture.
473        let sp = Spinner::start("test");
474        drop(sp);
475    }
476
477    #[test]
478    fn spinner_finish_then_drop_is_idempotent() {
479        // finish() takes pb via Option::take so Drop's pb.take() sees None
480        // and is a no-op on the progress bar side. teardown() is
481        // idempotent because it consumes saved_termios via Option::take;
482        // the second call finds None and does nothing. This test
483        // exercises that lifecycle end-to-end.
484        let sp = Spinner::start("test");
485        sp.finish("done");
486    }
487
488    /// Nesting guard pin: starting a second Spinner while another is
489    /// live must panic under `debug_assert!`. Exercises the
490    /// SPINNER_ACTIVE swap — without the guard, the inner spinner
491    /// would stash the outer's already-ECHO-disabled termios into
492    /// SPINNER_SAVED_TERMIOS, and the outer's teardown would restore
493    /// to that broken state instead of the pre-spinner original.
494    ///
495    /// `#[should_panic]` is gated on `debug_assertions` because the
496    /// assertion compiles away in release builds; running the test
497    /// without the debug gate under a release harness would make
498    /// the test fail when the expected panic doesn't fire. The
499    /// sibling `spinner_start_releases_guard_on_drop` test covers
500    /// the happy path (non-nested sequential spinners) and runs
501    /// under both profiles.
502    #[test]
503    #[cfg(debug_assertions)]
504    #[should_panic(expected = "Spinner::start called while another Spinner is already active")]
505    fn spinner_nested_start_panics_under_debug_assertions() {
506        let _outer = Spinner::start("outer");
507        // This call must fire the debug_assert! — the outer is
508        // still live in scope. The test framework captures the
509        // panic via `#[should_panic]`.
510        let _inner = Spinner::start("inner");
511    }
512
513    /// Happy path paired with the nesting-panic test: starting two
514    /// spinners SEQUENTIALLY (with the first dropped before the
515    /// second starts) must succeed. Guards against a regression that
516    /// forgot to clear SPINNER_ACTIVE in Drop and would one-shot the
517    /// guard after a single use.
518    #[test]
519    fn spinner_start_releases_guard_on_drop() {
520        {
521            let _sp = Spinner::start("first");
522            // Drop at end of block.
523        }
524        // After the first Spinner is dropped, the guard must be
525        // cleared so a fresh start succeeds without panicking.
526        let _sp = Spinner::start("second");
527    }
528
529    // ---------------------------------------------------------------
530    // Spinner public-API surface — testable on both TTY and non-TTY
531    // paths without requiring a real terminal.
532    // ---------------------------------------------------------------
533    //
534    // `Spinner::set_message`, `Spinner::println`, and
535    // `Spinner::suspend` all branch on `self.pb.is_some()` —
536    // `Some(pb)` on the TTY-active path delegates to indicatif,
537    // `None` on the non-TTY path falls back to a plain `eprintln!`
538    // or direct closure call. The tests pin the non-TTY arms
539    // (which is what runs under nextest's stderr capture) but
540    // also exercise the TTY arms when the test happens to run
541    // on a TTY-attached host (the assertions are shape-only —
542    // no stderr capture, just no-panic + correct return shape).
543
544    /// `Spinner::set_message` must not panic on either path. Pin
545    /// the non-TTY arm directly: `pb.is_none()` makes the call a
546    /// no-op. A regression that changed the gate (e.g. removed
547    /// the if-let guard) would surface as a panic on `None`.
548    #[test]
549    fn spinner_set_message_no_panic_on_non_tty() {
550        let sp = Spinner::start("initial");
551        sp.set_message("updated");
552        // Drop runs the teardown path — whether TTY or non-TTY,
553        // no panic must escape.
554        drop(sp);
555    }
556
557    /// `Spinner::println` writes through `eprintln!` on the non-TTY
558    /// path. Pin no-panic and the return-type contract (unit) — the
559    /// helper is fire-and-forget by design.
560    #[test]
561    fn spinner_println_no_panic_on_non_tty() {
562        let sp = Spinner::start("operation");
563        sp.println("interleaved log line");
564        drop(sp);
565    }
566
567    /// `Spinner::suspend` invokes the closure synchronously on
568    /// the non-TTY path and returns the closure's result
569    /// verbatim. Pins the value passthrough — a regression that
570    /// returned `Default` or dropped the value would surface
571    /// here.
572    #[test]
573    fn spinner_suspend_returns_closure_value_on_non_tty() {
574        let sp = Spinner::start("operation");
575        let v: u32 = sp.suspend(|| 42);
576        assert_eq!(v, 42);
577        let s: String = sp.suspend(|| "hello".to_string());
578        assert_eq!(s, "hello");
579        drop(sp);
580    }
581
582    /// `Spinner::with_progress` runs the closure under a fresh
583    /// spinner and returns `Ok(value)` on success. Pins the happy
584    /// path: the closure receives a `&Spinner` (testable via
585    /// `set_message`), and the success return propagates the
586    /// closure's `Ok` value verbatim.
587    #[test]
588    fn spinner_with_progress_returns_ok_value_on_success() {
589        let result: Result<u32, String> = Spinner::with_progress("starting", "done", |sp| {
590            sp.set_message("midway");
591            Ok(123)
592        });
593        assert_eq!(result, Ok(123));
594    }
595
596    /// `Spinner::with_progress` propagates `Err` from the
597    /// closure unchanged and drops the spinner silently (no
598    /// success message — the `Err` arm calls `drop(sp)` rather
599    /// than `sp.finish(success_msg)`). Pins the failure-path
600    /// contract: the spinner does not pollute stderr with a
601    /// "success" line when the underlying op failed.
602    #[test]
603    fn spinner_with_progress_propagates_err_without_finish_message() {
604        let result: Result<(), String> = Spinner::with_progress("starting", "done", |_sp| {
605            Err("synthetic failure".to_string())
606        });
607        assert_eq!(result, Err("synthetic failure".to_string()));
608    }
609
610    /// Nested `with_progress` calls are sequential by construction
611    /// — the inner spinner only starts AFTER the outer's closure
612    /// returns. Pins the SPINNER_ACTIVE guard release path under
613    /// the convenience helper: a regression that leaked the
614    /// guard from `with_progress` would break sequential pairs.
615    #[test]
616    fn spinner_with_progress_sequential_pair_succeeds() {
617        let r1: Result<u8, String> = Spinner::with_progress("first", "first done", |_| Ok(1));
618        assert_eq!(r1, Ok(1));
619        // Second invocation must not panic under the nesting guard
620        // because the first's Drop already released it.
621        let r2: Result<u8, String> = Spinner::with_progress("second", "second done", |_| Ok(2));
622        assert_eq!(r2, Ok(2));
623    }
624
625    // ---------------------------------------------------------------
626    // Color helpers — caching contract
627    // ---------------------------------------------------------------
628
629    /// `stderr_color()` is cached via `OnceLock` — repeated calls
630    /// return the same value. Pins the cache invariant: a future
631    /// regression that re-probed `is_terminal()` on every call
632    /// would lose the per-process consistency contract that
633    /// downstream renderers depend on.
634    #[test]
635    fn stderr_color_returns_consistent_value_across_calls() {
636        let a = stderr_color();
637        let b = stderr_color();
638        assert_eq!(a, b, "stderr_color must be cached and stable per process",);
639    }
640
641    /// `stdout_color()` carries the same cached-per-process
642    /// contract as `stderr_color`. Pins the sibling cache.
643    #[test]
644    fn stdout_color_returns_consistent_value_across_calls() {
645        let a = stdout_color();
646        let b = stdout_color();
647        assert_eq!(a, b, "stdout_color must be cached and stable per process",);
648    }
649
650    // ---------------------------------------------------------------
651    // restore_sigpipe_default — FFI shim no-panic
652    // ---------------------------------------------------------------
653
654    /// `restore_sigpipe_default` is an FFI call wrapping
655    /// `libc::signal(SIGPIPE, SIG_DFL)` — infallible by libc's
656    /// own contract for a standard signal + standard handler.
657    /// Pins the no-panic contract: every ktstr CLI binary calls
658    /// this once at top-of-main, and any panic here would
659    /// terminate before the operator's actual subcommand ran.
660    #[test]
661    fn restore_sigpipe_default_does_not_panic() {
662        // Idempotent: calling twice is also infallible (the
663        // second call sets the handler that's already in place).
664        // Pins the per-binary "call at top-of-main" pattern —
665        // future code that re-armed SIG_IGN between calls would
666        // surface a behavior change here.
667        restore_sigpipe_default();
668        restore_sigpipe_default();
669    }
670
671    // ---------------------------------------------------------------
672    // new_table / new_wrapped_table — comfy-table preset
673    // ---------------------------------------------------------------
674
675    /// `new_table()` returns a comfy-table with NO box-drawing
676    /// preset (whitespace-padded columns). Pin the rendering
677    /// shape via a single-cell roundtrip: the rendered table
678    /// must NOT contain box-drawing chars (`│`, `─`, `┼`, etc.)
679    /// — those characters are the canonical preset signature
680    /// that would surface if a regression swapped NOTHING for
681    /// UTF8_FULL.
682    #[test]
683    fn new_table_uses_borderless_preset() {
684        let mut t = new_table();
685        t.set_header(["A", "B"]);
686        t.add_row(["1", "2"]);
687        let rendered = t.to_string();
688        for ch in ['│', '─', '┼', '┴', '┬', '├', '┤'] {
689            assert!(
690                !rendered.contains(ch),
691                "borderless table must not contain box-drawing char `{ch}`: {rendered}",
692            );
693        }
694        // Cell content still rendered.
695        assert!(rendered.contains("A"), "header A must render: {rendered}");
696        assert!(rendered.contains("1"), "row cell 1 must render: {rendered}");
697    }
698
699    /// `new_wrapped_table()` follows the same NOTHING preset (no
700    /// box-drawing) but uses Dynamic content arrangement. Pins
701    /// the borderless-preset contract for the wrapped-table
702    /// variant — the only difference between `new_table` and
703    /// `new_wrapped_table` is the `ContentArrangement`, NOT the
704    /// preset. A regression that drifted the wrapped table to
705    /// UTF8_FULL would surface here.
706    #[test]
707    fn new_wrapped_table_uses_borderless_preset() {
708        let mut t = new_wrapped_table();
709        t.set_header(["A"]);
710        t.add_row(["x"]);
711        let rendered = t.to_string();
712        for ch in ['│', '─', '┼'] {
713            assert!(
714                !rendered.contains(ch),
715                "borderless wrapped table must not contain box-drawing char `{ch}`: {rendered}",
716            );
717        }
718    }
719
720    // ---------------------------------------------------------------
721    // Panic hook installer — idempotency
722    // ---------------------------------------------------------------
723
724    /// `install_spinner_termios_panic_hook` is gated by `Once`,
725    /// so calling it many times must be a no-op after the first
726    /// call lands. Pins the install path's idempotency: a
727    /// regression that omitted the `Once` guard would re-take
728    /// the previous hook into a Box-recursion chain and
729    /// eventually panic on stack overflow.
730    #[test]
731    fn install_spinner_termios_panic_hook_is_idempotent() {
732        // Direct invocation is safe — `set_hook` is process-wide
733        // but the helper is gated by `INSTALLED: Once`. Multiple
734        // calls coalesce into a single hook install. The test
735        // does not unwind a panic; it pins the no-panic contract
736        // on repeated install, regardless of whether other tests
737        // earlier in the run already triggered the install.
738        install_spinner_termios_panic_hook();
739        install_spinner_termios_panic_hook();
740        install_spinner_termios_panic_hook();
741        // No assertion needed beyond no-panic — the failure mode
742        // is recursion / overflow, which would surface as a test
743        // failure, not as a missing assertion.
744    }
745}