Skip to main content

recursive/
logging.rs

1//! Tracing writer that suppresses output while the TUI is active.
2//!
3//! ## Why this exists
4//!
5//! `main.rs` initialises its `tracing-subscriber` *before* the TUI
6//! takes over the terminal. After the TUI enters its alternate
7//! screen, every `tracing::info!` / `tracing::warn!` is still being
8//! written to the stderr file descriptor — which is the same
9//! descriptor the alternate screen is rendered onto, so the log
10//! lines land *inside* the TUI surface (typically right next to
11//! the input box, since that's where the cursor was last parked by
12//! `terminal.draw`). The user sees stray log lines like
13//! `2026-06-03T… INFO agent.turn: finished steps=2 …` rendered
14//! inside their input box.
15//!
16//! ## How it works
17//!
18//! The `TUI_QUIET` atomic flag flips to `true` while the TUI is
19//! running. The `StderrOrNull` writer short-circuits to a no-op
20//! while the flag is set, and falls back to `std::io::stderr`
21//! otherwise. The TUI's `run()` function takes an RAII guard via
22//! [`suppress_tracing_for_tui`] so the flag is restored on every
23//! exit path — including panics — letting the user still see the
24//! panic message on stderr after `LeaveAlternateScreen`.
25//!
26//! ## What about user-visible logs during a TUI session?
27//!
28//! Things the user actually wants to see during a session (hook
29//! progress, the spinner verb, etc.) flow through the TUI's own
30//! event sink, not the global subscriber. If you need a brand-new
31//! `tracing::info!` line to be visible during a TUI session, push
32//! it onto the sink as a `UiEvent` instead.
33
34use std::io;
35use std::sync::atomic::{AtomicBool, Ordering};
36
37#[cfg(feature = "cli")]
38use tracing_subscriber::fmt::MakeWriter;
39
40static TUI_QUIET: AtomicBool = AtomicBool::new(false);
41
42/// Mark the global tracing writer as suppressed (TUI active) or
43/// normal. Called by `tui::run()` via the RAII guard below; exposed
44/// publicly in case other long-running surfaces (e.g. an HTTP
45/// server's interactive console) want the same behaviour.
46pub fn set_tui_quiet(quiet: bool) {
47    TUI_QUIET.store(quiet, Ordering::Relaxed);
48}
49
50/// Returns the current TUI-quiet state. Exposed for tests.
51pub fn is_tui_quiet() -> bool {
52    TUI_QUIET.load(Ordering::Relaxed)
53}
54
55/// RAII guard that flips [`set_tui_quiet(false)`] on drop, ensuring
56/// the global tracing writer is restored even if the TUI panics
57/// partway through.
58pub struct TuiQuietGuard;
59
60impl Drop for TuiQuietGuard {
61    fn drop(&mut self) {
62        set_tui_quiet(false);
63    }
64}
65
66/// Acquire a guard that holds the tracing writer in the suppressed
67/// state. When the guard is dropped the writer is restored. Use
68/// this at the very top of `tui::run()` (before `enable_raw_mode`).
69pub fn suppress_tracing_for_tui() -> TuiQuietGuard {
70    set_tui_quiet(true);
71    TuiQuietGuard
72}
73
74/// `io::Write` adapter that drops every byte while the TUI is
75/// active and otherwise mirrors `std::io::stderr`.
76#[derive(Debug)]
77pub struct StderrOrNull;
78
79impl io::Write for StderrOrNull {
80    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
81        if TUI_QUIET.load(Ordering::Relaxed) {
82            // Pretend the write succeeded so the caller's formatting
83            // does not error out. We do not count the suppressed
84            // bytes because tracing does not care.
85            return Ok(buf.len());
86        }
87        let mut handle = std::io::stderr().lock();
88        handle.write(buf)
89    }
90
91    fn flush(&mut self) -> io::Result<()> {
92        if TUI_QUIET.load(Ordering::Relaxed) {
93            return Ok(());
94        }
95        std::io::stderr().flush()
96    }
97}
98
99/// `MakeWriter` factory that hands out `StderrOrNull` writers. The
100/// `tracing_subscriber::fmt` layer accepts this in `with_writer`.
101#[derive(Clone, Debug)]
102pub struct StderrOrNullMaker;
103
104#[cfg(feature = "cli")]
105impl<'a> MakeWriter<'a> for StderrOrNullMaker {
106    type Writer = StderrOrNull;
107    fn make_writer(&'a self) -> Self::Writer {
108        StderrOrNull
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::io::Write;
116    use std::sync::{Mutex, MutexGuard};
117
118    // `TUI_QUIET` is a process-global `AtomicBool`. Every test in this module
119    // mutates it (`set_tui_quiet` / `suppress_tracing_for_tui`) and several
120    // assert its value. Run in parallel (cargo test's default) they race: one
121    // test's `set_tui_quiet(true)` flips the flag while another is in the
122    // middle of `assert!(!is_tui_quiet())`, producing intermittent failures
123    // (observed blocking the g331 land-preserve run on 2026-07-24). Acquire
124    // this lock at the top of every test that touches the global so the whole
125    // module runs serially. (Goal 345 hardening)
126    static QUIET_TEST_LOCK: Mutex<()> = Mutex::new(());
127    fn quiet_test_lock() -> MutexGuard<'static, ()> {
128        QUIET_TEST_LOCK.lock().expect("quiet test lock poisoned")
129    }
130
131    #[test]
132    fn stderr_or_null_drops_bytes_when_quiet() {
133        let _l = quiet_test_lock();
134        set_tui_quiet(true);
135        let mut w = StderrOrNull;
136        let n = w.write(b"hello").unwrap();
137        assert_eq!(n, 5);
138        w.flush().unwrap();
139        set_tui_quiet(false);
140    }
141
142    #[test]
143    fn guard_restores_quiet_state_on_drop() {
144        let _l = quiet_test_lock();
145        assert!(!is_tui_quiet());
146        {
147            let _g = suppress_tracing_for_tui();
148            assert!(is_tui_quiet());
149        }
150        assert!(!is_tui_quiet());
151    }
152
153    #[test]
154    fn set_tui_quiet_stores_given_value() {
155        let _l = quiet_test_lock();
156        // kills `store(!quiet, ...)` or `store(true, ...)` mutations in set_tui_quiet
157        set_tui_quiet(true);
158        assert!(is_tui_quiet(), "must be quiet after set_tui_quiet(true)");
159        set_tui_quiet(false);
160        assert!(
161            !is_tui_quiet(),
162            "must not be quiet after set_tui_quiet(false)"
163        );
164    }
165
166    #[test]
167    fn guard_restores_even_if_another_guard_already_dropped() {
168        let _l = quiet_test_lock();
169        // Two nested guards; the inner drop will flip the flag to
170        // false even though the outer is still alive. The guard is
171        // single-shot, not reference-counted. The pragmatic
172        // consequence is that *the outermost* guard's drop is the
173        // only one that matters, and nested calls should be
174        // avoided. We document the behaviour here so the test
175        // catches any future refactor that tries to make this
176        // re-entrant.
177        let _outer = suppress_tracing_for_tui();
178        assert!(is_tui_quiet());
179        {
180            let _inner = suppress_tracing_for_tui();
181            assert!(is_tui_quiet());
182        }
183        assert!(
184            !is_tui_quiet(),
185            "inner guard's drop already cleared the flag (documented)"
186        );
187    }
188
189    #[test]
190    fn stderr_or_null_write_not_quiet_returns_byte_count() {
191        let _l = quiet_test_lock();
192        // kills branch mutation: write path when TUI_QUIET is false must call
193        // the real stderr, not the short-circuit return.
194        set_tui_quiet(false);
195        let mut w = StderrOrNull;
196        // Write empty slice to avoid polluting test output; byte count must still match.
197        let n = w.write(b"").unwrap();
198        assert_eq!(n, 0, "writing 0 bytes while not quiet must return 0");
199    }
200
201    #[test]
202    fn stderr_or_null_flush_not_quiet_succeeds() {
203        let _l = quiet_test_lock();
204        // kills `if TUI_QUIET.load(...)` guard removal mutation in flush():
205        // when not quiet, flush must succeed (delegates to real stderr).
206        set_tui_quiet(false);
207        let mut w = StderrOrNull;
208        assert!(w.flush().is_ok(), "flush() when not quiet must succeed");
209    }
210
211    #[test]
212    fn suppress_tracing_sets_quiet_immediately() {
213        let _l = quiet_test_lock();
214        // kills `set_tui_quiet(true)` → `set_tui_quiet(false)` mutation
215        // in suppress_tracing_for_tui before the guard is dropped.
216        set_tui_quiet(false);
217        let _g = suppress_tracing_for_tui();
218        assert!(
219            is_tui_quiet(),
220            "suppress_tracing_for_tui must set quiet=true before returning"
221        );
222        drop(_g);
223        assert!(
224            !is_tui_quiet(),
225            "dropping the guard must restore quiet=false"
226        );
227    }
228}