Skip to main content

fallow_cli/report/
sink.rs

1//! Ambient output sink for the report layer.
2//!
3//! By default the `outln!` macro writes report CONTENT to stdout, so the CLI
4//! behaves exactly as it always has. When the user passes
5//! `--output-file <PATH>`, `main` opens the file and calls [`set_file_sink`]
6//! once before dispatch; from then on every `outln!` lands in the file instead
7//! of stdout. The sink is process-global and ambient, so no command `Options`
8//! struct needs to thread the path through, and the programmatic / NAPI
9//! consumers (which call the `build_*` helpers and never the `print_*`
10//! dispatch) are unaffected because they never set the sink.
11//!
12//! Progress, errors, and the "Report written to `<path>`" confirmation stay on
13//! stderr (plain `eprintln!`); interactive terminal chrome (the `--explain`
14//! tip, the combined orientation header) is gated on [`is_redirected`] so it
15//! never pollutes the file.
16
17use std::fmt;
18use std::io::{self, BufWriter, Write};
19use std::sync::Mutex;
20
21struct SinkInner {
22    /// `Some` once `--output-file` redirected output. `None` means stdout.
23    file: Option<BufWriter<std::fs::File>>,
24    /// First write error seen against the file sink, surfaced by [`flush`] so a
25    /// truncated / failed write does not masquerade as a successful report.
26    error: Option<io::Error>,
27    /// Whether any report content was written to the file sink. Lets the caller
28    /// suppress the "Report written" confirmation when a command errored out
29    /// before rendering anything (the error went to stdout, the file is empty).
30    wrote: bool,
31}
32
33static SINK: Mutex<SinkInner> = Mutex::new(SinkInner {
34    file: None,
35    error: None,
36    wrote: false,
37});
38
39fn lock() -> std::sync::MutexGuard<'static, SinkInner> {
40    SINK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
41}
42
43/// Redirect all subsequent report content to `file` (truncating it). Call once,
44/// before any rendering. Also resets any prior sticky write error.
45pub fn set_file_sink(file: std::fs::File) {
46    let mut inner = lock();
47    inner.file = Some(BufWriter::new(file));
48    inner.error = None;
49    inner.wrote = false;
50}
51
52/// Whether report content is currently being redirected to a file. Used to gate
53/// interactive terminal chrome that must not land in the file.
54pub fn is_redirected() -> bool {
55    lock().file.is_some()
56}
57
58/// Whether any report content was written to the file sink. False when stdout
59/// was the target, or when a command errored before rendering anything.
60pub fn wrote() -> bool {
61    lock().wrote
62}
63
64/// Flush the file sink and surface the first write error, if any. No-op (Ok)
65/// when writing to stdout. Call after rendering, before the confirmation.
66pub fn flush() -> io::Result<()> {
67    let mut inner = lock();
68    if let Some(error) = inner.error.take() {
69        return Err(error);
70    }
71    match inner.file.as_mut() {
72        Some(writer) => writer.flush(),
73        None => Ok(()),
74    }
75}
76
77/// Write a line of report content (a trailing newline is added). Routed to the
78/// file sink when redirected, else stdout. Backs the `outln!` macro.
79pub fn write_fmt_line(args: fmt::Arguments<'_>) {
80    let mut inner = lock();
81    if inner.error.is_some() {
82        return;
83    }
84    if inner.file.is_some() {
85        inner.wrote = true;
86    }
87    let result = match inner.file.as_mut() {
88        Some(writer) => writeln!(writer, "{args}"),
89        None => {
90            // Ignore stdout write errors (e.g. a closed pipe) rather than
91            // panicking the way `println!` would.
92            let _ = writeln!(io::stdout(), "{args}");
93            Ok(())
94        }
95    };
96    if let Err(error) = result {
97        inner.error = Some(error);
98    }
99}
100
101/// Write a line of report content to the sink. Drop-in replacement for
102/// `println!` on report CONTENT (not progress / errors / interactive chrome).
103macro_rules! outln {
104    () => {
105        $crate::report::sink::write_fmt_line(::std::format_args!(""))
106    };
107    ($($arg:tt)*) => {
108        $crate::report::sink::write_fmt_line(::std::format_args!($($arg)*))
109    };
110}
111
112pub(crate) use outln;
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::io::Read;
118
119    // The sink is process-global; these tests mutate it and must not run
120    // concurrently with each other. They run serially within this module via a
121    // shared guard.
122    static TEST_GUARD: Mutex<()> = Mutex::new(());
123
124    fn reset() {
125        let mut inner = lock();
126        inner.file = None;
127        inner.error = None;
128    }
129
130    #[test]
131    fn redirects_content_to_file_and_reports_flush_state() {
132        let _g = TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
133        reset();
134        assert!(!is_redirected());
135
136        let dir = tempfile::tempdir().expect("tempdir");
137        let path = dir.path().join("out.txt");
138        let file = std::fs::File::create(&path).expect("create");
139        set_file_sink(file);
140        assert!(is_redirected());
141
142        outln!("line one");
143        outln!("end");
144        flush().expect("flush ok");
145
146        let mut contents = String::new();
147        std::fs::File::open(&path)
148            .expect("open")
149            .read_to_string(&mut contents)
150            .expect("read");
151        assert_eq!(contents, "line one\nend\n");
152        assert!(!contents.contains('\u{1b}'), "no ANSI escapes in file");
153
154        reset();
155        assert!(!is_redirected());
156    }
157
158    #[test]
159    fn flush_is_ok_when_writing_to_stdout() {
160        let _g = TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
161        reset();
162        assert!(flush().is_ok());
163    }
164}