Skip to main content

ralph_core/
cli_capture.rs

1//! CLI output capture for recording terminal sessions.
2//!
3//! `CliCapture` wraps a `Write` implementation to capture all bytes written
4//! to stdout/stderr while forwarding them to the underlying writer. This
5//! enables transparent recording without changing calling code.
6
7use ralph_proto::{FrameCapture, TerminalWrite, UxEvent};
8use std::io::{self, Write};
9use std::time::Instant;
10
11/// A writer that captures all output while forwarding to an inner writer.
12///
13/// This wrapper implements `std::io::Write` and records every write operation
14/// as a `UxEvent::TerminalWrite`. The captured events can be retrieved via
15/// the `FrameCapture` trait for session recording.
16///
17/// # Example
18///
19/// ```
20/// use ralph_core::CliCapture;
21/// use ralph_proto::FrameCapture;
22/// use std::io::Write;
23///
24/// let mut output = Vec::new();
25/// let mut capture = CliCapture::new(&mut output, true);
26///
27/// writeln!(capture, "Hello, World!").unwrap();
28///
29/// // And captured as UX events
30/// let events = capture.take_captures();
31/// assert_eq!(events.len(), 1);
32///
33/// // Output was forwarded to the inner writer (checked after capture drops borrow)
34/// drop(capture);
35/// assert!(String::from_utf8_lossy(&output).contains("Hello"));
36/// ```
37pub struct CliCapture<W> {
38    /// The underlying writer to forward output to.
39    inner: W,
40
41    /// Captured UX events.
42    captures: Vec<UxEvent>,
43
44    /// Time when capture started, for calculating offsets.
45    start_time: Instant,
46
47    /// Whether this captures stdout (true) or stderr (false).
48    is_stdout: bool,
49}
50
51impl<W> CliCapture<W> {
52    /// Creates a new capture wrapper around the given writer.
53    ///
54    /// # Arguments
55    ///
56    /// * `inner` - The writer to forward output to
57    /// * `is_stdout` - `true` if capturing stdout, `false` for stderr
58    pub fn new(inner: W, is_stdout: bool) -> Self {
59        Self {
60            inner,
61            captures: Vec::new(),
62            start_time: Instant::now(),
63            is_stdout,
64        }
65    }
66
67    /// Creates a capture wrapper with a custom start time.
68    ///
69    /// This is useful when coordinating multiple captures (stdout + stderr)
70    /// that should share the same timing baseline.
71    pub fn with_start_time(inner: W, is_stdout: bool, start_time: Instant) -> Self {
72        Self {
73            inner,
74            captures: Vec::new(),
75            start_time,
76            is_stdout,
77        }
78    }
79
80    /// Returns the current offset in milliseconds since capture started.
81    #[allow(clippy::cast_possible_truncation)]
82    fn offset_ms(&self) -> u64 {
83        // Safe: milliseconds since start won't exceed u64 in practice
84        self.start_time.elapsed().as_millis() as u64
85    }
86
87    /// Returns a reference to the inner writer.
88    pub fn inner(&self) -> &W {
89        &self.inner
90    }
91
92    /// Returns a mutable reference to the inner writer.
93    pub fn inner_mut(&mut self) -> &mut W {
94        &mut self.inner
95    }
96
97    /// Consumes the capture and returns the inner writer.
98    pub fn into_inner(self) -> W {
99        self.inner
100    }
101}
102
103impl<W: Write> Write for CliCapture<W> {
104    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
105        // Forward to inner writer first
106        let n = self.inner.write(buf)?;
107
108        // Only capture the bytes that were actually written
109        if n > 0 {
110            self.captures
111                .push(UxEvent::TerminalWrite(TerminalWrite::new(
112                    &buf[..n],
113                    self.is_stdout,
114                    self.offset_ms(),
115                )));
116        }
117
118        Ok(n)
119    }
120
121    fn flush(&mut self) -> io::Result<()> {
122        self.inner.flush()
123    }
124}
125
126impl<W: Send + Sync> FrameCapture for CliCapture<W> {
127    fn take_captures(&mut self) -> Vec<UxEvent> {
128        std::mem::take(&mut self.captures)
129    }
130
131    fn has_captures(&self) -> bool {
132        !self.captures.is_empty()
133    }
134}
135
136/// A pair of capture wrappers for stdout and stderr.
137///
138/// This struct coordinates captures for both streams with a shared start time,
139/// ensuring timing offsets are consistent across stdout and stderr events.
140pub struct CliCapturePair<Stdout, Stderr> {
141    /// Capture wrapper for stdout.
142    pub stdout: CliCapture<Stdout>,
143
144    /// Capture wrapper for stderr.
145    pub stderr: CliCapture<Stderr>,
146}
147
148impl<Stdout, Stderr> CliCapturePair<Stdout, Stderr> {
149    /// Creates a new capture pair with a shared start time.
150    pub fn new(stdout: Stdout, stderr: Stderr) -> Self {
151        let start_time = Instant::now();
152        Self {
153            stdout: CliCapture::with_start_time(stdout, true, start_time),
154            stderr: CliCapture::with_start_time(stderr, false, start_time),
155        }
156    }
157}
158
159impl<Stdout: Send + Sync, Stderr: Send + Sync> CliCapturePair<Stdout, Stderr> {
160    /// Takes all captured events from both streams, merged in chronological order.
161    pub fn take_all_captures(&mut self) -> Vec<UxEvent> {
162        let mut stdout_events = self.stdout.take_captures();
163        let mut stderr_events = self.stderr.take_captures();
164
165        // Merge and sort by offset_ms
166        stdout_events.append(&mut stderr_events);
167        stdout_events.sort_by_key(|event| match event {
168            UxEvent::TerminalWrite(tw) => tw.offset_ms,
169            UxEvent::TerminalResize(tr) => tr.offset_ms,
170            UxEvent::TerminalColorMode(cm) => cm.offset_ms,
171            UxEvent::TuiFrame(tf) => tf.offset_ms,
172        });
173
174        stdout_events
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_capture_write() {
184        let mut output = Vec::new();
185
186        // Scope the capture so we can check output afterward
187        let events = {
188            let mut capture = CliCapture::new(&mut output, true);
189            write!(capture, "Hello").unwrap();
190            capture.take_captures()
191        };
192
193        // Check output was forwarded
194        assert_eq!(output, b"Hello");
195
196        // Check event was captured
197        assert_eq!(events.len(), 1);
198
199        if let UxEvent::TerminalWrite(tw) = &events[0] {
200            assert!(tw.stdout);
201            let decoded = tw.decode_bytes().unwrap();
202            assert_eq!(decoded, b"Hello");
203        } else {
204            panic!("Expected TerminalWrite event");
205        }
206    }
207
208    #[test]
209    fn test_capture_multiple_writes() {
210        let mut output = Vec::new();
211        let mut capture = CliCapture::new(&mut output, true);
212
213        writeln!(capture, "Line 1").unwrap();
214        writeln!(capture, "Line 2").unwrap();
215
216        let events = capture.take_captures();
217        assert_eq!(events.len(), 2);
218
219        // Check offsets are monotonic
220        if let (UxEvent::TerminalWrite(tw1), UxEvent::TerminalWrite(tw2)) = (&events[0], &events[1])
221        {
222            assert!(tw2.offset_ms >= tw1.offset_ms);
223        }
224    }
225
226    #[test]
227    fn test_capture_stderr() {
228        let mut output = Vec::new();
229        let mut capture = CliCapture::new(&mut output, false);
230
231        write!(capture, "Error!").unwrap();
232
233        let events = capture.take_captures();
234        if let UxEvent::TerminalWrite(tw) = &events[0] {
235            assert!(!tw.stdout); // stderr
236        }
237    }
238
239    #[test]
240    fn test_capture_pair() {
241        let stdout_buf = Vec::new();
242        let stderr_buf = Vec::new();
243        let mut pair = CliCapturePair::new(stdout_buf, stderr_buf);
244
245        write!(pair.stdout, "out").unwrap();
246        write!(pair.stderr, "err").unwrap();
247
248        let events = pair.take_all_captures();
249        assert_eq!(events.len(), 2);
250    }
251
252    #[test]
253    fn test_take_captures_clears_buffer() {
254        let mut output = Vec::new();
255        let mut capture = CliCapture::new(&mut output, true);
256
257        write!(capture, "test").unwrap();
258        assert!(capture.has_captures());
259
260        let events = capture.take_captures();
261        assert_eq!(events.len(), 1);
262
263        // Buffer should be cleared
264        assert!(!capture.has_captures());
265        assert!(capture.take_captures().is_empty());
266    }
267
268    #[test]
269    fn test_ansi_preservation() {
270        let mut output = Vec::new();
271        let mut capture = CliCapture::new(&mut output, true);
272
273        // Write ANSI escape sequence for green text
274        let ansi_text = b"\x1b[32mGreen\x1b[0m";
275        capture.write_all(ansi_text).unwrap();
276
277        let events = capture.take_captures();
278        if let UxEvent::TerminalWrite(tw) = &events[0] {
279            let decoded = tw.decode_bytes().unwrap();
280            assert_eq!(decoded, ansi_text);
281        }
282    }
283}