Skip to main content

fallow_cli/report/
sink.rs

1//! Ambient output sink for the report layer.
2//!
3//! By default the `outln!` / `out!` macros write report CONTENT to stdout, so
4//! the CLI 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!` / `out!` lands in the file
7//! instead of stdout. The sink is process-global and ambient, so no command
8//! `Options` struct needs to thread the path through, and the programmatic /
9//! NAPI 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 report content without a trailing newline. Backs the `out!` macro.
102pub fn write_fmt_str(args: fmt::Arguments<'_>) {
103    let mut inner = lock();
104    if inner.error.is_some() {
105        return;
106    }
107    if inner.file.is_some() {
108        inner.wrote = true;
109    }
110    let result = match inner.file.as_mut() {
111        Some(writer) => write!(writer, "{args}"),
112        None => {
113            let _ = write!(io::stdout(), "{args}");
114            Ok(())
115        }
116    };
117    if let Err(error) = result {
118        inner.error = Some(error);
119    }
120}
121
122/// Write a line of report content to the sink. Drop-in replacement for
123/// `println!` on report CONTENT (not progress / errors / interactive chrome).
124macro_rules! outln {
125    () => {
126        $crate::report::sink::write_fmt_line(::std::format_args!(""))
127    };
128    ($($arg:tt)*) => {
129        $crate::report::sink::write_fmt_line(::std::format_args!($($arg)*))
130    };
131}
132
133/// Write report content without a trailing newline. Drop-in replacement for
134/// `print!` on report content.
135macro_rules! out {
136    ($($arg:tt)*) => {
137        $crate::report::sink::write_fmt_str(::std::format_args!($($arg)*))
138    };
139}
140
141pub(crate) use {out, outln};
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::io::Read;
147
148    // The sink is process-global; these tests mutate it and must not run
149    // concurrently with each other. They run serially within this module via a
150    // shared guard.
151    static TEST_GUARD: Mutex<()> = Mutex::new(());
152
153    fn reset() {
154        let mut inner = lock();
155        inner.file = None;
156        inner.error = None;
157    }
158
159    #[test]
160    fn redirects_content_to_file_and_reports_flush_state() {
161        let _g = TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
162        reset();
163        assert!(!is_redirected());
164
165        let dir = tempfile::tempdir().expect("tempdir");
166        let path = dir.path().join("out.txt");
167        let file = std::fs::File::create(&path).expect("create");
168        set_file_sink(file);
169        assert!(is_redirected());
170
171        outln!("line one");
172        out!("partial ");
173        outln!("end");
174        flush().expect("flush ok");
175
176        let mut contents = String::new();
177        std::fs::File::open(&path)
178            .expect("open")
179            .read_to_string(&mut contents)
180            .expect("read");
181        assert_eq!(contents, "line one\npartial end\n");
182        assert!(!contents.contains('\u{1b}'), "no ANSI escapes in file");
183
184        reset();
185        assert!(!is_redirected());
186    }
187
188    #[test]
189    fn flush_is_ok_when_writing_to_stdout() {
190        let _g = TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner());
191        reset();
192        assert!(flush().is_ok());
193    }
194}