Skip to main content

sqry_cli/output/
stream.rs

1//! Output stream abstraction for stdout/stderr separation
2
3use super::pager::{BufferedOutput, PagerConfig, PagerExitStatus};
4use std::io::{self, Write};
5
6/// Internal stdout backend for `OutputStreams`
7enum StdoutBackend {
8    /// Direct stdout (no paging)
9    Direct(Box<dyn Write + Send>),
10    /// Pager-backed output (may auto-page)
11    Pager(BufferedOutput),
12}
13
14/// Manages stdout and stderr streams
15///
16/// Supports optional pager integration for large output. When created with
17/// `with_pager()`, stdout writes are buffered and may be piped through
18/// a pager (like `less`) if output exceeds terminal height.
19pub struct OutputStreams {
20    stdout: StdoutBackend,
21    stderr: Box<dyn Write + Send>,
22}
23
24impl OutputStreams {
25    /// Create streams using actual stdout/stderr (no paging)
26    #[must_use]
27    pub fn new() -> Self {
28        Self {
29            stdout: StdoutBackend::Direct(Box::new(io::stdout())),
30            stderr: Box::new(io::stderr()),
31        }
32    }
33
34    /// Create streams with pager support
35    ///
36    /// When pager is enabled, stdout output is buffered and may be
37    /// piped through a pager (like `less`) if output exceeds terminal height.
38    ///
39    /// Call `finish()` at the end to properly handle pager lifecycle.
40    #[must_use]
41    pub fn with_pager(config: PagerConfig) -> Self {
42        Self {
43            stdout: StdoutBackend::Pager(BufferedOutput::new(config)),
44            stderr: Box::new(io::stderr()),
45        }
46    }
47
48    /// Create streams with custom writers (for testing)
49    #[cfg(test)]
50    #[allow(dead_code)] // API for future tests
51    pub fn with_writers<W1, W2>(stdout: W1, stderr: W2) -> Self
52    where
53        W1: Write + Send + 'static,
54        W2: Write + Send + 'static,
55    {
56        Self {
57            stdout: StdoutBackend::Direct(Box::new(stdout)),
58            stderr: Box::new(stderr),
59        }
60    }
61
62    /// Write results to stdout (data stream)
63    ///
64    /// # Errors
65    /// Returns an error if writing to stdout fails.
66    pub fn write_result(&mut self, content: &str) -> io::Result<()> {
67        match &mut self.stdout {
68            StdoutBackend::Direct(writer) => writeln!(writer, "{content}"),
69            StdoutBackend::Pager(buffer) => {
70                buffer.write(content)?;
71                buffer.write("\n")
72            }
73        }
74    }
75
76    /// Write diagnostic to stderr (diagnostic stream)
77    ///
78    /// # Errors
79    /// Returns an error if writing to stderr fails.
80    pub fn write_diagnostic(&mut self, content: &str) -> io::Result<()> {
81        writeln!(self.stderr, "{content}")
82    }
83
84    /// Flush stderr (for --explain to avoid interleaving)
85    #[allow(dead_code)]
86    ///
87    /// # Errors
88    /// Returns an error if flushing stderr fails.
89    pub fn flush_stderr(&mut self) -> io::Result<()> {
90        self.stderr.flush()
91    }
92
93    /// Finalize output, flushing buffer and waiting for pager if applicable
94    ///
95    /// Returns the pager exit status. For non-pager streams, returns `Success`.
96    /// Call this at the end of command execution to properly handle pager lifecycle.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if flushing or waiting for pager fails.
101    pub fn finish(self) -> io::Result<PagerExitStatus> {
102        match self.stdout {
103            StdoutBackend::Direct(_) => Ok(PagerExitStatus::Success),
104            StdoutBackend::Pager(buffer) => buffer.finish(),
105        }
106    }
107
108    /// Finalize output and check for pager exit status
109    ///
110    /// This is a convenience method that combines `finish()` with pager exit code
111    /// checking. If the pager exited with a non-zero code, returns a `CliError::PagerExit`.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if:
116    /// - Flushing or waiting for pager fails (IO error)
117    /// - Pager exited with non-zero code (`CliError::PagerExit`)
118    pub fn finish_checked(self) -> anyhow::Result<()> {
119        let status = self.finish()?;
120        if let Some(code) = status.exit_code() {
121            return Err(crate::error::CliError::pager_exit(code).into());
122        }
123        Ok(())
124    }
125}
126
127impl Default for OutputStreams {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133/// Test-friendly streams that capture output to strings
134#[cfg(test)]
135pub struct TestOutputStreams {
136    pub stdout: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
137    pub stderr: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
138}
139
140#[cfg(test)]
141impl TestOutputStreams {
142    #[must_use]
143    pub fn new() -> (Self, OutputStreams) {
144        let stdout = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
145        let stderr = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
146
147        let test = Self {
148            stdout: std::sync::Arc::clone(&stdout),
149            stderr: std::sync::Arc::clone(&stderr),
150        };
151
152        let streams = OutputStreams {
153            stdout: StdoutBackend::Direct(Box::new(SharedWriter(stdout))),
154            stderr: Box::new(SharedWriter(stderr)),
155        };
156
157        (test, streams)
158    }
159
160    /// Returns the captured stdout as a string.
161    ///
162    /// # Panics
163    ///
164    /// Panics if the internal stdout mutex has been poisoned.
165    #[must_use]
166    pub fn stdout_string(&self) -> String {
167        let guard = self.stdout.lock().unwrap();
168        String::from_utf8_lossy(&guard).to_string()
169    }
170
171    /// Returns the captured stderr as a string.
172    ///
173    /// # Panics
174    ///
175    /// Panics if the internal stderr mutex has been poisoned.
176    #[must_use]
177    pub fn stderr_string(&self) -> String {
178        let guard = self.stderr.lock().unwrap();
179        String::from_utf8_lossy(&guard).to_string()
180    }
181}
182
183#[cfg(test)]
184struct SharedWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
185
186#[cfg(test)]
187impl Write for SharedWriter {
188    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
189        let mut guard = self.0.lock().unwrap();
190        guard.extend_from_slice(buf);
191        Ok(buf.len())
192    }
193
194    fn flush(&mut self) -> io::Result<()> {
195        Ok(())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_output_streams_creation() {
205        let _streams = OutputStreams::new();
206        // Just verify it can be created
207    }
208
209    #[test]
210    fn test_default() {
211        let _streams = OutputStreams::default();
212    }
213
214    #[test]
215    fn test_output_streams_capture() {
216        let (test, mut streams) = TestOutputStreams::new();
217
218        streams.write_result("hello").unwrap();
219        streams.write_diagnostic("world").unwrap();
220
221        assert_eq!(test.stdout_string(), "hello\n");
222        assert_eq!(test.stderr_string(), "world\n");
223    }
224
225    #[test]
226    fn test_finish_non_pager_returns_success() {
227        let streams = OutputStreams::new();
228        let status = streams.finish().unwrap();
229        assert!(status.is_success());
230    }
231}