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}