Skip to main content

ftui_runtime/
asciicast.rs

1#![forbid(unsafe_code)]
2
3//! Asciicast v2 recorder for capturing terminal sessions.
4//!
5//! This recorder writes newline-delimited JSON (NDJSON) compatible with
6//! asciinema-player. The first line is the header object, followed by event
7//! arrays of the form `[time, "o", "text"]` for output and `[time, "i", "text"]`
8//! for input (optional).
9//!
10//! # Example
11//! ```no_run
12//! use ftui_core::terminal_capabilities::TerminalCapabilities;
13//! use ftui_runtime::asciicast::{AsciicastRecorder, AsciicastWriter};
14//! use ftui_runtime::{ScreenMode, TerminalWriter, UiAnchor};
15//! use std::io::Cursor;
16//!
17//! let recorder = AsciicastRecorder::with_writer(Cursor::new(Vec::new()), 80, 24, 0).unwrap();
18//! let output = Cursor::new(Vec::new());
19//! let recording_output = AsciicastWriter::new(output, recorder);
20//! let caps = TerminalCapabilities::detect();
21//! let mut writer = TerminalWriter::new(recording_output, ScreenMode::Inline { ui_height: 10 }, UiAnchor::Bottom, caps);
22//! writer.write_log("hello\n").unwrap();
23//! ```
24
25use std::fmt::Write as FmtWrite;
26use std::fs::File;
27use std::io::{self, BufWriter, Write};
28use std::path::{Path, PathBuf};
29use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
30
31use tracing::{info, trace};
32
33/// Records terminal output in asciicast v2 format.
34#[derive(Debug)]
35pub struct AsciicastRecorder<W: Write> {
36    output: W,
37    start: Instant,
38    width: u16,
39    height: u16,
40    event_count: u64,
41    path: Option<PathBuf>,
42}
43
44impl AsciicastRecorder<BufWriter<File>> {
45    /// Create a recorder that writes to a file at `path`.
46    pub fn new(path: &Path, width: u16, height: u16) -> io::Result<Self> {
47        let file = File::create(path)?;
48        let writer = BufWriter::new(file);
49        let timestamp = unix_timestamp()?;
50        let recorder =
51            AsciicastRecorder::build(writer, width, height, timestamp, Some(path.to_path_buf()))?;
52        info!(
53            path = ?path,
54            width = width,
55            height = height,
56            "Asciicast recording started"
57        );
58        Ok(recorder)
59    }
60}
61
62impl<W: Write> AsciicastRecorder<W> {
63    /// Create a recorder that writes to the provided writer.
64    ///
65    /// `timestamp` is seconds since UNIX epoch used in the asciicast header.
66    pub fn with_writer(output: W, width: u16, height: u16, timestamp: i64) -> io::Result<Self> {
67        let recorder = Self::build(output, width, height, timestamp, None)?;
68        info!(
69            width = width,
70            height = height,
71            timestamp = timestamp,
72            "Asciicast recording started"
73        );
74        Ok(recorder)
75    }
76
77    /// Record terminal output bytes.
78    pub fn record_output(&mut self, data: &[u8]) -> io::Result<()> {
79        self.record_event("o", data)
80    }
81
82    /// Record terminal input bytes (optional).
83    pub fn record_input(&mut self, data: &[u8]) -> io::Result<()> {
84        self.record_event("i", data)
85    }
86
87    /// Number of events recorded so far.
88    #[must_use]
89    pub const fn event_count(&self) -> u64 {
90        self.event_count
91    }
92
93    /// Elapsed duration since recording started.
94    #[must_use]
95    pub fn duration(&self) -> Duration {
96        self.start.elapsed()
97    }
98
99    /// Returns the terminal width recorded in the asciicast header.
100    #[must_use]
101    pub const fn width(&self) -> u16 {
102        self.width
103    }
104
105    /// Returns the terminal height recorded in the asciicast header.
106    #[must_use]
107    pub const fn height(&self) -> u16 {
108        self.height
109    }
110
111    /// Flush output and return the inner writer.
112    pub fn finish(mut self) -> io::Result<W> {
113        let duration = self.start.elapsed().as_secs_f64();
114        self.output.flush()?;
115        if let Some(path) = &self.path {
116            info!(
117                path = ?path,
118                duration_secs = duration,
119                events = self.event_count,
120                "Asciicast recording complete"
121            );
122        } else {
123            info!(
124                duration_secs = duration,
125                events = self.event_count,
126                "Asciicast recording complete"
127            );
128        }
129        Ok(self.output)
130    }
131
132    fn record_event(&mut self, kind: &str, data: &[u8]) -> io::Result<()> {
133        let time = self.start.elapsed().as_secs_f64();
134        let text = String::from_utf8_lossy(data);
135        let escaped = escape_json(&text);
136        writeln!(self.output, "[{:.6},\"{}\",\"{}\"]", time, kind, escaped)?;
137        self.event_count += 1;
138        trace!(
139            bytes = data.len(),
140            elapsed_secs = time,
141            kind = kind,
142            "Output recorded"
143        );
144        Ok(())
145    }
146
147    fn build(
148        mut output: W,
149        width: u16,
150        height: u16,
151        timestamp: i64,
152        path: Option<PathBuf>,
153    ) -> io::Result<Self> {
154        write_header(&mut output, width, height, timestamp)?;
155        Ok(Self {
156            output,
157            start: Instant::now(),
158            width,
159            height,
160            event_count: 0,
161            path,
162        })
163    }
164}
165
166/// Writer that mirrors terminal output into an asciicast recorder.
167#[derive(Debug)]
168pub struct AsciicastWriter<W: Write, R: Write> {
169    inner: W,
170    recorder: AsciicastRecorder<R>,
171}
172
173impl<W: Write, R: Write> AsciicastWriter<W, R> {
174    /// Create a new recording writer.
175    pub const fn new(inner: W, recorder: AsciicastRecorder<R>) -> Self {
176        Self { inner, recorder }
177    }
178
179    /// Access the underlying recorder (for input recording).
180    pub fn recorder_mut(&mut self) -> &mut AsciicastRecorder<R> {
181        &mut self.recorder
182    }
183
184    /// Record input bytes.
185    pub fn record_input(&mut self, data: &[u8]) -> io::Result<()> {
186        self.recorder.record_input(data)
187    }
188
189    /// Flush and finish recording, returning the inner writer and recorder output.
190    pub fn finish(mut self) -> io::Result<(W, R)> {
191        self.inner.flush()?;
192        let recorder_output = self.recorder.finish()?;
193        Ok((self.inner, recorder_output))
194    }
195}
196
197impl<W: Write, R: Write> Write for AsciicastWriter<W, R> {
198    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
199        let written = self.inner.write(buf)?;
200        if written > 0 {
201            self.recorder.record_output(&buf[..written])?;
202        }
203        Ok(written)
204    }
205
206    fn flush(&mut self) -> io::Result<()> {
207        self.inner.flush()?;
208        self.recorder.output.flush()
209    }
210}
211
212fn write_header<W: Write>(
213    output: &mut W,
214    width: u16,
215    height: u16,
216    timestamp: i64,
217) -> io::Result<()> {
218    writeln!(
219        output,
220        "{{\"version\":2,\"width\":{},\"height\":{},\"timestamp\":{}}}",
221        width, height, timestamp
222    )
223}
224
225fn unix_timestamp() -> io::Result<i64> {
226    let since_epoch = SystemTime::now()
227        .duration_since(UNIX_EPOCH)
228        .map_err(|_| io::Error::other("system time before unix epoch"))?;
229    Ok(since_epoch.as_secs() as i64)
230}
231
232fn escape_json(input: &str) -> String {
233    let mut out = String::with_capacity(input.len() + 8);
234    for ch in input.chars() {
235        match ch {
236            '\"' => out.push_str("\\\""),
237            '\\' => out.push_str("\\\\"),
238            '\n' => out.push_str("\\n"),
239            '\r' => out.push_str("\\r"),
240            '\t' => out.push_str("\\t"),
241            '\u{08}' => out.push_str("\\b"),
242            '\u{0C}' => out.push_str("\\f"),
243            c if c < ' ' => {
244                let _ = write!(out, "\\u{:04x}", c as u32);
245            }
246            _ => out.push(ch),
247        }
248    }
249    out
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::io::Cursor;
256
257    fn make_recorder(width: u16, height: u16) -> AsciicastRecorder<Cursor<Vec<u8>>> {
258        AsciicastRecorder::with_writer(Cursor::new(Vec::new()), width, height, 0).unwrap()
259    }
260
261    fn output_string(recorder: AsciicastRecorder<Cursor<Vec<u8>>>) -> String {
262        let cursor = recorder.finish().unwrap();
263        String::from_utf8(cursor.into_inner()).unwrap()
264    }
265
266    // --- Header tests ---
267
268    #[test]
269    fn header_and_output_are_written() {
270        let cursor = Cursor::new(Vec::new());
271        let mut recorder = AsciicastRecorder::with_writer(cursor, 80, 24, 123).unwrap();
272        recorder.record_output(b"hi\n").unwrap();
273        let cursor = recorder.finish().unwrap();
274        let output = String::from_utf8(cursor.into_inner()).unwrap();
275        let mut lines = output.lines();
276        assert_eq!(
277            lines.next().unwrap(),
278            "{\"version\":2,\"width\":80,\"height\":24,\"timestamp\":123}"
279        );
280        let event = lines.next().unwrap();
281        assert!(event.contains("\"o\""));
282        assert!(event.contains("hi\\n"));
283    }
284
285    #[test]
286    fn header_contains_version_2() {
287        let recorder = make_recorder(40, 10);
288        let output = output_string(recorder);
289        let header = output.lines().next().unwrap();
290        assert!(header.contains("\"version\":2"));
291    }
292
293    #[test]
294    fn header_contains_dimensions() {
295        let recorder = make_recorder(120, 50);
296        let output = output_string(recorder);
297        let header = output.lines().next().unwrap();
298        assert!(header.contains("\"width\":120"));
299        assert!(header.contains("\"height\":50"));
300    }
301
302    // --- Event recording tests ---
303
304    #[test]
305    fn record_output_creates_output_event() {
306        let mut recorder = make_recorder(80, 24);
307        recorder.record_output(b"hello").unwrap();
308        let output = output_string(recorder);
309        let event = output.lines().nth(1).unwrap();
310        assert!(event.starts_with('['));
311        assert!(event.contains("\"o\""));
312        assert!(event.contains("hello"));
313    }
314
315    #[test]
316    fn record_input_creates_input_event() {
317        let mut recorder = make_recorder(80, 24);
318        recorder.record_input(b"key").unwrap();
319        let output = output_string(recorder);
320        let event = output.lines().nth(1).unwrap();
321        assert!(event.contains("\"i\""));
322        assert!(event.contains("key"));
323    }
324
325    #[test]
326    fn multiple_events_are_sequential() {
327        let mut recorder = make_recorder(80, 24);
328        recorder.record_output(b"first").unwrap();
329        recorder.record_output(b"second").unwrap();
330        recorder.record_input(b"third").unwrap();
331        let output = output_string(recorder);
332        let lines: Vec<&str> = output.lines().collect();
333        // header + 3 events
334        assert_eq!(lines.len(), 4);
335        assert!(lines[1].contains("first"));
336        assert!(lines[2].contains("second"));
337        assert!(lines[3].contains("third"));
338    }
339
340    #[test]
341    fn event_count_tracks_events() {
342        let mut recorder = make_recorder(80, 24);
343        assert_eq!(recorder.event_count(), 0);
344        recorder.record_output(b"a").unwrap();
345        assert_eq!(recorder.event_count(), 1);
346        recorder.record_input(b"b").unwrap();
347        assert_eq!(recorder.event_count(), 2);
348    }
349
350    #[test]
351    fn accessor_methods_return_dimensions() {
352        let recorder = make_recorder(132, 43);
353        assert_eq!(recorder.width(), 132);
354        assert_eq!(recorder.height(), 43);
355    }
356
357    #[test]
358    fn duration_is_non_negative() {
359        let recorder = make_recorder(80, 24);
360        assert!(recorder.duration().as_secs_f64() >= 0.0);
361    }
362
363    // --- JSON escaping tests ---
364
365    #[test]
366    fn json_escape_controls() {
367        let cursor = Cursor::new(Vec::new());
368        let mut recorder = AsciicastRecorder::with_writer(cursor, 1, 1, 0).unwrap();
369        recorder.record_output(b"\"\\\\\n").unwrap();
370        let cursor = recorder.finish().unwrap();
371        let output = String::from_utf8(cursor.into_inner()).unwrap();
372        let event = output.lines().nth(1).unwrap();
373        assert!(event.contains("\\\"\\\\\\\\\\n"));
374    }
375
376    #[test]
377    fn escape_json_handles_all_special_chars() {
378        assert_eq!(escape_json("\""), "\\\"");
379        assert_eq!(escape_json("\\"), "\\\\");
380        assert_eq!(escape_json("\n"), "\\n");
381        assert_eq!(escape_json("\r"), "\\r");
382        assert_eq!(escape_json("\t"), "\\t");
383        assert_eq!(escape_json("\u{08}"), "\\b");
384        assert_eq!(escape_json("\u{0C}"), "\\f");
385    }
386
387    #[test]
388    fn escape_json_passes_normal_text() {
389        assert_eq!(escape_json("hello world"), "hello world");
390        assert_eq!(escape_json(""), "");
391    }
392
393    #[test]
394    fn escape_json_handles_low_control_chars() {
395        let result = escape_json("\x01\x02");
396        assert!(result.contains("\\u0001"));
397        assert!(result.contains("\\u0002"));
398    }
399
400    // --- AsciicastWriter tests ---
401
402    #[test]
403    fn writer_mirrors_output_to_recorder() {
404        let output = Cursor::new(Vec::new());
405        let recorder = make_recorder(80, 24);
406        let mut writer = AsciicastWriter::new(output, recorder);
407
408        writer.write_all(b"test data").unwrap();
409        writer.flush().unwrap();
410
411        let (output, recording) = writer.finish().unwrap();
412        let output_str = String::from_utf8(output.into_inner()).unwrap();
413        let recording_str = String::from_utf8(recording.into_inner()).unwrap();
414
415        assert_eq!(output_str, "test data");
416        assert!(recording_str.contains("test data"));
417    }
418
419    #[test]
420    fn writer_record_input_works() {
421        let output = Cursor::new(Vec::new());
422        let recorder = make_recorder(80, 24);
423        let mut writer = AsciicastWriter::new(output, recorder);
424
425        writer.record_input(b"key press").unwrap();
426
427        let (_, recording) = writer.finish().unwrap();
428        let recording_str = String::from_utf8(recording.into_inner()).unwrap();
429        assert!(recording_str.contains("\"i\""));
430        assert!(recording_str.contains("key press"));
431    }
432
433    #[test]
434    fn writer_recorder_mut_accessible() {
435        let output = Cursor::new(Vec::new());
436        let recorder = make_recorder(80, 24);
437        let mut writer = AsciicastWriter::new(output, recorder);
438
439        assert_eq!(writer.recorder_mut().event_count(), 0);
440        writer.write_all(b"x").unwrap();
441        assert_eq!(writer.recorder_mut().event_count(), 1);
442    }
443
444    // --- Empty recording test ---
445
446    #[test]
447    fn finish_with_no_events_produces_header_only() {
448        let recorder = make_recorder(80, 24);
449        let output = output_string(recorder);
450        let lines: Vec<&str> = output.lines().collect();
451        assert_eq!(lines.len(), 1); // header only
452    }
453}