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
12use std::fmt;
13use std::io::{self, Write};
14use std::sync::{Arc, Mutex, RwLock};
15
16/// A shared writer that receives diagnostics while a redirect is active.
17pub type Sink = Arc<Mutex<dyn Write + Send>>;
18
19/// The active redirect. `None` means the process stderr.
20static SINK: RwLock<Option<Sink>> = RwLock::new(None);
21
22/// Writes one diagnostic line to the active sink.
23///
24/// A failed write is ignored: diagnostics are best-effort, and the library
25/// call that produced the warning must not fail because stderr is closed.
26pub fn notice(args: fmt::Arguments<'_>) {
27    let _ = Stderr.write_fmt(format_args!("{}\n", args));
28}
29
30/// Sends diagnostics to `sink` until the returned guard is dropped.
31///
32/// Nested redirects are allowed. Dropping a guard restores the sink that
33/// was active when that guard was created.
34pub fn redirect(sink: Sink) -> RedirectGuard {
35    let mut slot = SINK.write().unwrap_or_else(|p| p.into_inner());
36    let previous = slot.replace(sink);
37    RedirectGuard { previous }
38}
39
40/// Restores the previous diagnostics sink when dropped.
41#[must_use = "diagnostics return to stderr as soon as the guard is dropped"]
42pub struct RedirectGuard {
43    previous: Option<Sink>,
44}
45
46impl Drop for RedirectGuard {
47    fn drop(&mut self) {
48        let mut slot = SINK.write().unwrap_or_else(|p| p.into_inner());
49        *slot = self.previous.take();
50    }
51}
52
53/// A writer that forwards to the active diagnostics sink.
54///
55/// Use it where an API wants a writer for diagnostics, for example
56/// `tracing_subscriber::fmt().with_writer(|| scope::diag::Stderr)`.
57#[derive(Debug, Clone, Copy, Default)]
58pub struct Stderr;
59
60impl Write for Stderr {
61    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
62        let slot = SINK.read().unwrap_or_else(|p| p.into_inner());
63        match slot.as_ref() {
64            Some(sink) => sink.lock().unwrap_or_else(|p| p.into_inner()).write(buf),
65            None => io::stderr().write(buf),
66        }
67    }
68
69    fn flush(&mut self) -> io::Result<()> {
70        let slot = SINK.read().unwrap_or_else(|p| p.into_inner());
71        match slot.as_ref() {
72            Some(sink) => sink.lock().unwrap_or_else(|p| p.into_inner()).flush(),
73            None => io::stderr().flush(),
74        }
75    }
76}
77
78/// Writes a formatted diagnostic line. See [`notice`].
79#[macro_export]
80macro_rules! notice {
81    ($($arg:tt)*) => {
82        $crate::diag::notice(format_args!($($arg)*))
83    };
84}
85
86// ============================================================================
87// Unit Tests
88// ============================================================================
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    /// Serializes the tests that redirect the process-global sink. Without
95    /// it, one test's redirect captures the other's notices (34 of 60 runs
96    /// failed with 8 test threads).
97    static REDIRECT_LOCK: Mutex<()> = Mutex::new(());
98
99    #[test]
100    fn test_stderr_writer_flushes_with_and_without_redirect() {
101        let _lock = REDIRECT_LOCK.lock().unwrap_or_else(|p| p.into_inner());
102        // tracing calls flush on its writer; both routes must accept it.
103        let mut w = Stderr;
104        assert!(w.flush().is_ok());
105        let buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
106        let _g = redirect(buf.clone());
107        assert!(w.flush().is_ok());
108    }
109
110    #[test]
111    fn test_redirect_captures_and_restores() {
112        let _lock = REDIRECT_LOCK.lock().unwrap_or_else(|p| p.into_inner());
113        // While the TUI owns the screen, library warnings must land in its
114        // pane; after it exits they must reach stderr again.
115        let outer: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
116        let inner: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
117        {
118            let _g1 = redirect(outer.clone());
119            notice(format_args!("one"));
120            {
121                let _g2 = redirect(inner.clone());
122                crate::notice!("two {}", 2);
123            }
124            notice(format_args!("three"));
125        }
126        // Other tests may emit notices concurrently into the same global
127        // sink, so check for our lines and their order, not for equality.
128        let outer = String::from_utf8_lossy(&outer.lock().unwrap()).into_owned();
129        let inner = String::from_utf8_lossy(&inner.lock().unwrap()).into_owned();
130        let (one, three) = (outer.find("one\n").unwrap(), outer.find("three\n").unwrap());
131        assert!(one < three);
132        assert!(!outer.contains("two 2"));
133        assert!(inner.contains("two 2\n"));
134        assert!(!inner.contains("one") && !inner.contains("three"));
135    }
136}