Skip to main content

scope/
diag.rs

1//! # Diagnostics Sink
2//!
3//! Library code has no injected writer of its own, but it still
4//! writes warnings for the user (for example "Holder data requires a Pro
5//! API key"). Those warnings go through this module instead of `eprintln!`.
6//!
7//! By default they go to stderr. A full-screen TUI calls [`redirect`](crate::diag::redirect) so
8//! they appear in its output pane instead of over the screen. The binary
9//! also gives [`Stderr`](crate::diag::Stderr) to its tracing subscriber, so log lines follow the
10//! same route.
11//!
12//! [`scoped`](crate::diag::scoped) gives one future its own sink, over the redirect. The TUI
13//! polls a pane command and a watchlist run in one task; each keeps its own
14//! warnings (#47).
15
16use std::fmt;
17use std::future::Future;
18use std::io::{self, Write};
19use std::sync::{Arc, Mutex, RwLock};
20
21/// A shared writer that receives diagnostics while a redirect is active.
22pub type Sink = Arc<Mutex<dyn Write + Send>>;
23
24/// The active redirect. `None` means the process stderr.
25static SINK: RwLock<Option<Sink>> = RwLock::new(None);
26
27/// Writes one diagnostic line to the active sink.
28///
29/// A failed write is ignored: diagnostics are best-effort, and the library
30/// call that produced the warning must not fail because stderr is closed.
31pub fn notice(args: fmt::Arguments<'_>) {
32    let _ = Stderr.write_fmt(format_args!("{}\n", args));
33}
34
35tokio::task_local! {
36    /// The sink of the future [`scoped`] runs, while that future is polled.
37    static SCOPED: Sink;
38}
39
40/// Runs `fut` with its diagnostics in `sink`, whatever the redirect is.
41///
42/// The sink holds only while `fut` is polled. So two futures that one task
43/// polls in turn (a TUI pane command and a watchlist run) keep their
44/// warnings apart: the other one's go to the redirect.
45pub fn scoped<F: Future>(sink: Sink, fut: F) -> impl Future<Output = F::Output> {
46    SCOPED.scope(sink, fut)
47}
48
49/// Sends diagnostics to `sink` until the returned guard is dropped.
50///
51/// Nested redirects are allowed. Dropping a guard restores the sink that
52/// was active when that guard was created.
53pub fn redirect(sink: Sink) -> RedirectGuard {
54    let mut slot = SINK.write().unwrap_or_else(|p| p.into_inner());
55    let previous = slot.replace(sink);
56    RedirectGuard { previous }
57}
58
59/// Restores the previous diagnostics sink when dropped.
60#[must_use = "diagnostics return to stderr as soon as the guard is dropped"]
61pub struct RedirectGuard {
62    previous: Option<Sink>,
63}
64
65impl Drop for RedirectGuard {
66    fn drop(&mut self) {
67        let mut slot = SINK.write().unwrap_or_else(|p| p.into_inner());
68        *slot = self.previous.take();
69    }
70}
71
72/// A writer that forwards to the active diagnostics sink.
73///
74/// Use it where an API wants a writer for diagnostics, for example
75/// `tracing_subscriber::fmt().with_writer(|| scope::diag::Stderr)`.
76#[derive(Debug, Clone, Copy, Default)]
77pub struct Stderr;
78
79impl Write for Stderr {
80    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
81        if let Ok(written) =
82            SCOPED.try_with(|sink| sink.lock().unwrap_or_else(|p| p.into_inner()).write(buf))
83        {
84            return written;
85        }
86        let slot = SINK.read().unwrap_or_else(|p| p.into_inner());
87        match slot.as_ref() {
88            Some(sink) => sink.lock().unwrap_or_else(|p| p.into_inner()).write(buf),
89            None => io::stderr().write(buf),
90        }
91    }
92
93    fn flush(&mut self) -> io::Result<()> {
94        if let Ok(flushed) =
95            SCOPED.try_with(|sink| sink.lock().unwrap_or_else(|p| p.into_inner()).flush())
96        {
97            return flushed;
98        }
99        let slot = SINK.read().unwrap_or_else(|p| p.into_inner());
100        match slot.as_ref() {
101            Some(sink) => sink.lock().unwrap_or_else(|p| p.into_inner()).flush(),
102            None => io::stderr().flush(),
103        }
104    }
105}
106
107/// Writes a formatted diagnostic line. See [`notice`].
108#[macro_export]
109macro_rules! notice {
110    ($($arg:tt)*) => {
111        $crate::diag::notice(format_args!($($arg)*))
112    };
113}
114
115// ============================================================================
116// Unit Tests
117// ============================================================================
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    /// Serializes the tests that redirect the process-global sink. Without
124    /// it, one test's redirect captures the other's notices (34 of 60 runs
125    /// failed with 8 test threads).
126    static REDIRECT_LOCK: Mutex<()> = Mutex::new(());
127
128    #[test]
129    fn test_stderr_writer_flushes_with_and_without_redirect() {
130        let _lock = REDIRECT_LOCK.lock().unwrap_or_else(|p| p.into_inner());
131        // tracing calls flush on its writer; both routes must accept it.
132        let mut w = Stderr;
133        assert!(w.flush().is_ok());
134        let buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
135        let _g = redirect(buf.clone());
136        assert!(w.flush().is_ok());
137    }
138
139    #[test]
140    fn test_a_scoped_future_keeps_its_notices_from_the_redirect() {
141        let _lock = REDIRECT_LOCK.lock().unwrap_or_else(|p| p.into_inner());
142        // The lock is held outside the runtime, never across an await.
143        let rt = tokio::runtime::Builder::new_current_thread()
144            .build()
145            .unwrap();
146        // One task polls both futures in turn, as the TUI polls a pane
147        // command and a watchlist run.
148        let pane: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
149        let watch: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
150        let _g = redirect(pane.clone());
151        let watching = scoped(watch.clone(), async {
152            for i in 0..3 {
153                notice(format_args!("watch {i}"));
154                tokio::task::yield_now().await;
155            }
156        });
157        let command = async {
158            for i in 0..3 {
159                notice(format_args!("pane {i}"));
160                tokio::task::yield_now().await;
161            }
162        };
163        rt.block_on(async { tokio::join!(watching, command) });
164        let pane = String::from_utf8_lossy(&pane.lock().unwrap()).into_owned();
165        let watch = String::from_utf8_lossy(&watch.lock().unwrap()).into_owned();
166        assert!(
167            watch.contains(
168                "watch 0
169watch 1
170watch 2
171"
172            ),
173            "{watch}"
174        );
175        assert!(!watch.contains("pane"), "{watch}");
176        assert!(
177            pane.contains(
178                "pane 0
179"
180            ) && pane.contains(
181                "pane 2
182"
183            ),
184            "{pane}"
185        );
186        assert!(!pane.contains("watch"), "{pane}");
187    }
188
189    #[test]
190    fn test_redirect_captures_and_restores() {
191        let _lock = REDIRECT_LOCK.lock().unwrap_or_else(|p| p.into_inner());
192        // While the TUI owns the screen, library warnings must land in its
193        // pane; after it exits they must reach stderr again.
194        let outer: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
195        let inner: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
196        {
197            let _g1 = redirect(outer.clone());
198            notice(format_args!("one"));
199            {
200                let _g2 = redirect(inner.clone());
201                crate::notice!("two {}", 2);
202            }
203            notice(format_args!("three"));
204        }
205        // Other tests may emit notices concurrently into the same global
206        // sink, so check for our lines and their order, not for equality.
207        let outer = String::from_utf8_lossy(&outer.lock().unwrap()).into_owned();
208        let inner = String::from_utf8_lossy(&inner.lock().unwrap()).into_owned();
209        let (one, three) = (outer.find("one\n").unwrap(), outer.find("three\n").unwrap());
210        assert!(one < three);
211        assert!(!outer.contains("two 2"));
212        assert!(inner.contains("two 2\n"));
213        assert!(!inner.contains("one") && !inner.contains("three"));
214    }
215}