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}