1#![forbid(unsafe_code)]
2
3use 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#[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 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 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 pub fn record_output(&mut self, data: &[u8]) -> io::Result<()> {
79 self.record_event("o", data)
80 }
81
82 pub fn record_input(&mut self, data: &[u8]) -> io::Result<()> {
84 self.record_event("i", data)
85 }
86
87 #[must_use]
89 pub const fn event_count(&self) -> u64 {
90 self.event_count
91 }
92
93 #[must_use]
95 pub fn duration(&self) -> Duration {
96 self.start.elapsed()
97 }
98
99 #[must_use]
101 pub const fn width(&self) -> u16 {
102 self.width
103 }
104
105 #[must_use]
107 pub const fn height(&self) -> u16 {
108 self.height
109 }
110
111 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#[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 pub const fn new(inner: W, recorder: AsciicastRecorder<R>) -> Self {
176 Self { inner, recorder }
177 }
178
179 pub fn recorder_mut(&mut self) -> &mut AsciicastRecorder<R> {
181 &mut self.recorder
182 }
183
184 pub fn record_input(&mut self, data: &[u8]) -> io::Result<()> {
186 self.recorder.record_input(data)
187 }
188
189 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 #[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 #[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 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 #[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 #[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 #[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); }
453}