1use std::fmt::Write as FmtWrite;
4use std::io::{BufRead, Write};
5use std::time::Duration;
6
7use super::format::{EventType, Transcript, TranscriptEvent, TranscriptMetadata};
8use crate::error::{ExpectError, Result};
9
10#[derive(Debug, Clone)]
12pub struct AsciicastHeader {
13 pub version: u8,
15 pub width: u16,
17 pub height: u16,
19 pub timestamp: Option<u64>,
21 pub duration: Option<f64>,
23 pub idle_time_limit: Option<f64>,
25 pub command: Option<String>,
27 pub title: Option<String>,
29 pub env: std::collections::HashMap<String, String>,
31}
32
33impl Default for AsciicastHeader {
34 fn default() -> Self {
35 Self {
36 version: 2,
37 width: 80,
38 height: 24,
39 timestamp: None,
40 duration: None,
41 idle_time_limit: None,
42 command: None,
43 title: None,
44 env: std::collections::HashMap::new(),
45 }
46 }
47}
48
49impl AsciicastHeader {
50 #[must_use]
52 pub fn new(width: u16, height: u16) -> Self {
53 Self {
54 width,
55 height,
56 ..Default::default()
57 }
58 }
59
60 #[must_use]
62 pub fn to_json(&self) -> String {
63 let mut parts = vec![
64 format!("\"version\": {}", self.version),
65 format!("\"width\": {}", self.width),
66 format!("\"height\": {}", self.height),
67 ];
68
69 if let Some(ts) = self.timestamp {
70 parts.push(format!("\"timestamp\": {ts}"));
71 }
72 if let Some(dur) = self.duration {
73 parts.push(format!("\"duration\": {dur:.6}"));
74 }
75 if let Some(limit) = self.idle_time_limit {
76 parts.push(format!("\"idle_time_limit\": {limit:.1}"));
77 }
78 if let Some(ref cmd) = self.command {
79 parts.push(format!("\"command\": \"{}\"", escape_json(cmd)));
80 }
81 if let Some(ref title) = self.title {
82 parts.push(format!("\"title\": \"{}\"", escape_json(title)));
83 }
84 if !self.env.is_empty() {
85 let env_parts: Vec<String> = self
86 .env
87 .iter()
88 .map(|(k, v)| format!("\"{}\": \"{}\"", escape_json(k), escape_json(v)))
89 .collect();
90 parts.push(format!("\"env\": {{{}}}", env_parts.join(", ")));
91 }
92
93 format!("{{{}}}", parts.join(", "))
94 }
95}
96
97pub fn write_asciicast<W: Write>(writer: &mut W, transcript: &Transcript) -> Result<()> {
99 let header = AsciicastHeader {
100 width: transcript.metadata.width,
101 height: transcript.metadata.height,
102 timestamp: transcript.metadata.timestamp,
103 duration: transcript.metadata.duration.map(|d| d.as_secs_f64()),
104 command: transcript.metadata.command.clone(),
105 title: transcript.metadata.title.clone(),
106 env: transcript.metadata.env.clone(),
107 ..Default::default()
108 };
109
110 writeln!(writer, "{}", header.to_json())
112 .map_err(|e| ExpectError::io_context("writing asciicast header", e))?;
113
114 for event in &transcript.events {
116 let time = event.timestamp.as_secs_f64();
117 let event_type = match event.event_type {
118 EventType::Output => "o",
119 EventType::Input => "i",
120 EventType::Resize => "r",
121 EventType::Marker => "m",
122 };
123 let data = String::from_utf8_lossy(&event.data);
124 writeln!(
125 writer,
126 "[{:.6}, \"{}\", \"{}\"]",
127 time,
128 event_type,
129 escape_json(&data)
130 )
131 .map_err(|e| ExpectError::io_context("writing asciicast event", e))?;
132 }
133
134 Ok(())
135}
136
137pub fn read_asciicast<R: BufRead>(reader: R) -> Result<Transcript> {
139 let mut lines = reader.lines();
140
141 let header_line = lines
143 .next()
144 .ok_or_else(|| ExpectError::config("Empty asciicast file"))?
145 .map_err(|e| ExpectError::io_context("reading asciicast header line", e))?;
146
147 let header = parse_header(&header_line);
148
149 let metadata = TranscriptMetadata {
150 width: header.width,
151 height: header.height,
152 command: header.command,
153 title: header.title,
154 timestamp: header.timestamp,
155 duration: header.duration.map(Duration::from_secs_f64),
156 env: header.env,
157 };
158
159 let mut transcript = Transcript::new(metadata);
160
161 for line in lines {
163 let line = line.map_err(|e| ExpectError::io_context("reading asciicast event line", e))?;
164 if line.trim().is_empty() {
165 continue;
166 }
167 if let Some(event) = parse_event(&line)? {
168 transcript.push(event);
169 }
170 }
171
172 Ok(transcript)
173}
174
175fn parse_header(line: &str) -> AsciicastHeader {
176 let mut header = AsciicastHeader {
178 width: parse_json_number(line, "width").unwrap_or(80) as u16,
179 height: parse_json_number(line, "height").unwrap_or(24) as u16,
180 version: parse_json_number(line, "version").unwrap_or(2) as u8,
181 ..Default::default()
182 };
183
184 if let Some(ts) = parse_json_number(line, "timestamp") {
185 header.timestamp = Some(ts as u64);
186 }
187
188 if let Some(dur) = parse_json_float(line, "duration") {
189 header.duration = Some(dur);
190 }
191
192 if let Some(limit) = parse_json_float(line, "idle_time_limit") {
193 header.idle_time_limit = Some(limit);
194 }
195
196 header.command = parse_json_string(line, "command");
198 header.title = parse_json_string(line, "title");
199
200 if let Some(env) = parse_json_object(line, "env") {
202 header.env = env;
203 }
204
205 header
206}
207
208fn parse_json_number(json: &str, field: &str) -> Option<i64> {
210 let pattern = format!("\"{field}\":");
211 let start = json.find(&pattern)?;
212 let rest = &json[start + pattern.len()..];
213 let rest = rest.trim_start();
214
215 let end = rest
217 .find(|c: char| !c.is_ascii_digit() && c != '-')
218 .unwrap_or(rest.len());
219
220 rest[..end].trim().parse().ok()
221}
222
223fn parse_json_float(json: &str, field: &str) -> Option<f64> {
225 let pattern = format!("\"{field}\":");
226 let start = json.find(&pattern)?;
227 let rest = &json[start + pattern.len()..];
228 let rest = rest.trim_start();
229
230 let end = rest
232 .find(|c: char| {
233 !c.is_ascii_digit() && c != '.' && c != '-' && c != 'e' && c != 'E' && c != '+'
234 })
235 .unwrap_or(rest.len());
236
237 rest[..end].trim().parse().ok()
238}
239
240fn parse_json_string(json: &str, field: &str) -> Option<String> {
242 let pattern = format!("\"{field}\":");
243 let start = json.find(&pattern)?;
244 let rest = &json[start + pattern.len()..];
245 let rest = rest.trim_start();
246
247 if !rest.starts_with('"') {
249 return None;
250 }
251
252 let content = &rest[1..];
254 let mut end = 0;
255 let mut escaped = false;
256
257 for (i, c) in content.char_indices() {
258 if escaped {
259 escaped = false;
260 continue;
261 }
262 if c == '\\' {
263 escaped = true;
264 continue;
265 }
266 if c == '"' {
267 end = i;
268 break;
269 }
270 }
271
272 if end == 0 && !content.is_empty() && !content.starts_with('"') {
273 end = content.len();
275 }
276
277 Some(unescape_json(&content[..end]))
278}
279
280fn parse_json_object(json: &str, field: &str) -> Option<std::collections::HashMap<String, String>> {
282 let pattern = format!("\"{field}\":");
283 let start = json.find(&pattern)?;
284 let rest = &json[start + pattern.len()..];
285 let rest = rest.trim_start();
286
287 if !rest.starts_with('{') {
289 return None;
290 }
291
292 let mut depth = 0;
294 let mut end = 0;
295
296 for (i, c) in rest.char_indices() {
297 match c {
298 '{' => depth += 1,
299 '}' => {
300 depth -= 1;
301 if depth == 0 {
302 end = i + 1;
303 break;
304 }
305 }
306 _ => {}
307 }
308 }
309
310 if end == 0 {
311 return None;
312 }
313
314 let obj_str = &rest[1..end - 1]; let mut result = std::collections::HashMap::new();
316
317 for pair in obj_str.split(',') {
319 let pair = pair.trim();
320 if let Some(colon) = pair.find(':') {
321 let key = pair[..colon].trim().trim_matches('"');
322 let value = pair[colon + 1..].trim().trim_matches('"');
323 if !key.is_empty() {
324 result.insert(key.to_string(), unescape_json(value));
325 }
326 }
327 }
328
329 Some(result)
330}
331
332fn parse_event(line: &str) -> Result<Option<TranscriptEvent>> {
333 let line = line.trim();
334 if !line.starts_with('[') || !line.ends_with(']') {
335 return Ok(None);
336 }
337
338 let inner = &line[1..line.len() - 1];
339 let parts: Vec<&str> = inner.splitn(3, ',').collect();
340 if parts.len() < 3 {
341 return Ok(None);
342 }
343
344 let time: f64 = parts[0]
345 .trim()
346 .parse()
347 .map_err(|_| ExpectError::config("Invalid timestamp"))?;
348
349 let event_type = parts[1].trim().trim_matches('"');
350 let data = parts[2].trim().trim_matches('"');
351
352 let event_type = match event_type {
353 "o" => EventType::Output,
354 "i" => EventType::Input,
355 "r" => EventType::Resize,
356 "m" => EventType::Marker,
357 _ => return Ok(None),
358 };
359
360 Ok(Some(TranscriptEvent {
361 timestamp: Duration::from_secs_f64(time),
362 event_type,
363 data: unescape_json(data).into_bytes(),
364 }))
365}
366
367fn escape_json(s: &str) -> String {
368 let mut result = String::with_capacity(s.len());
369 for c in s.chars() {
370 match c {
371 '"' => result.push_str("\\\""),
372 '\\' => result.push_str("\\\\"),
373 '\n' => result.push_str("\\n"),
374 '\r' => result.push_str("\\r"),
375 '\t' => result.push_str("\\t"),
376 c if c.is_control() => {
377 let _ = write!(result, "\\u{:04x}", c as u32);
378 }
379 c => result.push(c),
380 }
381 }
382 result
383}
384
385fn unescape_json(s: &str) -> String {
386 let mut result = String::with_capacity(s.len());
387 let mut chars = s.chars().peekable();
388 while let Some(c) = chars.next() {
389 if c == '\\' {
390 match chars.next() {
391 Some('n') => result.push('\n'),
392 Some('r') => result.push('\r'),
393 Some('t') => result.push('\t'),
394 Some('b') => result.push('\u{0008}'), Some('f') => result.push('\u{000C}'), Some('"') => result.push('"'),
397 Some('\\') => result.push('\\'),
398 Some('/') => result.push('/'),
399 Some('u') => {
400 let mut hex = String::with_capacity(4);
402 for _ in 0..4 {
403 if let Some(&c) = chars.peek() {
404 if c.is_ascii_hexdigit() {
405 hex.push(
406 chars.next().expect("peek confirmed a hex digit is present"),
407 );
408 } else {
409 break;
410 }
411 }
412 }
413 if hex.len() == 4
414 && let Ok(code) = u32::from_str_radix(&hex, 16)
415 && let Some(ch) = char::from_u32(code)
416 {
417 result.push(ch);
418 continue;
419 }
420 result.push_str("\\u");
422 result.push_str(&hex);
423 }
424 Some(c) => {
425 result.push('\\');
426 result.push(c);
427 }
428 None => result.push('\\'),
429 }
430 } else {
431 result.push(c);
432 }
433 }
434 result
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn asciicast_header() {
443 let header = AsciicastHeader::new(80, 24);
444 let json = header.to_json();
445 assert!(json.contains("\"version\": 2"));
446 assert!(json.contains("\"width\": 80"));
447 }
448
449 #[test]
450 fn escape_special_chars() {
451 assert_eq!(escape_json("hello\nworld"), "hello\\nworld");
452 assert_eq!(escape_json("say \"hi\""), "say \\\"hi\\\"");
453 }
454
455 #[test]
456 fn roundtrip() {
457 let mut transcript = Transcript::new(TranscriptMetadata::new(80, 24));
458 transcript.push(TranscriptEvent::output(
459 Duration::from_millis(100),
460 b"hello",
461 ));
462
463 let mut buf = Vec::new();
464 write_asciicast(&mut buf, &transcript).unwrap();
465
466 let parsed = read_asciicast(buf.as_slice()).unwrap();
467 assert_eq!(parsed.events.len(), 1);
468 }
469
470 #[test]
471 fn parse_json_number_basic() {
472 let json = r#"{"version": 2, "width": 120, "height": 40}"#;
473 assert_eq!(parse_json_number(json, "version"), Some(2));
474 assert_eq!(parse_json_number(json, "width"), Some(120));
475 assert_eq!(parse_json_number(json, "height"), Some(40));
476 assert_eq!(parse_json_number(json, "nonexistent"), None);
477 }
478
479 #[test]
480 fn parse_json_number_negative() {
481 let json = r#"{"offset": -100}"#;
482 assert_eq!(parse_json_number(json, "offset"), Some(-100));
483 }
484
485 #[test]
486 fn parse_json_float_basic() {
487 let json = r#"{"duration": 123.456789, "idle_time_limit": 2.5}"#;
488 assert!((parse_json_float(json, "duration").unwrap() - 123.456_789).abs() < 0.000_001);
489 assert!((parse_json_float(json, "idle_time_limit").unwrap() - 2.5).abs() < 0.000_001);
490 assert_eq!(parse_json_float(json, "nonexistent"), None);
491 }
492
493 #[test]
494 fn parse_json_float_scientific() {
495 let json = r#"{"value": 1.5e10}"#;
496 assert!((parse_json_float(json, "value").unwrap() - 1.5e10).abs() < 1.0);
497 }
498
499 #[test]
500 fn parse_json_string_basic() {
501 let json = r#"{"command": "/bin/bash", "title": "My Recording"}"#;
502 assert_eq!(
503 parse_json_string(json, "command"),
504 Some("/bin/bash".to_string())
505 );
506 assert_eq!(
507 parse_json_string(json, "title"),
508 Some("My Recording".to_string())
509 );
510 assert_eq!(parse_json_string(json, "nonexistent"), None);
511 }
512
513 #[test]
514 fn parse_json_string_escaped() {
515 let json = r#"{"path": "C:\\Users\\test", "msg": "say \"hello\""}"#;
516 assert_eq!(
517 parse_json_string(json, "path"),
518 Some("C:\\Users\\test".to_string())
519 );
520 assert_eq!(
521 parse_json_string(json, "msg"),
522 Some("say \"hello\"".to_string())
523 );
524 }
525
526 #[test]
527 fn parse_json_object_basic() {
528 let json = r#"{"env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}}"#;
529 let env = parse_json_object(json, "env").unwrap();
530 assert_eq!(env.get("SHELL"), Some(&"/bin/bash".to_string()));
531 assert_eq!(env.get("TERM"), Some(&"xterm-256color".to_string()));
532 }
533
534 #[test]
535 fn parse_json_object_empty() {
536 let json = r#"{"env": {}}"#;
537 let env = parse_json_object(json, "env").unwrap();
538 assert!(env.is_empty());
539 }
540
541 #[test]
542 fn parse_header_full() {
543 let header_json = r#"{"version": 2, "width": 120, "height": 40, "timestamp": 1704067200, "duration": 60.5, "idle_time_limit": 2.0, "command": "/bin/zsh", "title": "Demo", "env": {"SHELL": "/bin/zsh"}}"#;
544 let header = parse_header(header_json);
545
546 assert_eq!(header.version, 2);
547 assert_eq!(header.width, 120);
548 assert_eq!(header.height, 40);
549 assert_eq!(header.timestamp, Some(1_704_067_200));
550 assert!((header.duration.unwrap() - 60.5).abs() < 0.001);
551 assert!((header.idle_time_limit.unwrap() - 2.0).abs() < 0.001);
552 assert_eq!(header.command, Some("/bin/zsh".to_string()));
553 assert_eq!(header.title, Some("Demo".to_string()));
554 assert_eq!(header.env.get("SHELL"), Some(&"/bin/zsh".to_string()));
555 }
556
557 #[test]
558 fn parse_header_minimal() {
559 let header_json = r#"{"version": 2, "width": 80, "height": 24}"#;
560 let header = parse_header(header_json);
561
562 assert_eq!(header.version, 2);
563 assert_eq!(header.width, 80);
564 assert_eq!(header.height, 24);
565 assert_eq!(header.timestamp, None);
566 assert_eq!(header.duration, None);
567 assert_eq!(header.command, None);
568 assert!(header.env.is_empty());
569 }
570
571 #[test]
572 fn unescape_json_sequences() {
573 assert_eq!(unescape_json("hello\\nworld"), "hello\nworld");
574 assert_eq!(unescape_json("tab\\there"), "tab\there");
575 assert_eq!(unescape_json("quote\\\"here"), "quote\"here");
576 assert_eq!(unescape_json("back\\\\slash"), "back\\slash");
577 assert_eq!(unescape_json("return\\rhere"), "return\rhere");
578 }
579
580 #[test]
581 fn unescape_json_backspace_formfeed() {
582 assert_eq!(unescape_json("back\\bspace"), "back\u{0008}space");
583 assert_eq!(unescape_json("form\\ffeed"), "form\u{000C}feed");
584 }
585
586 #[test]
587 fn unescape_json_forward_slash() {
588 assert_eq!(unescape_json("path\\/to\\/file"), "path/to/file");
590 assert_eq!(unescape_json("path/to/file"), "path/to/file");
591 }
592
593 #[test]
594 fn unescape_json_unicode() {
595 assert_eq!(unescape_json("\\u0041"), "A");
597 assert_eq!(unescape_json("\\u0048\\u0069"), "Hi");
598
599 assert_eq!(unescape_json("\\u001b"), "\u{001b}"); assert_eq!(unescape_json("\\u0000"), "\u{0000}"); assert_eq!(unescape_json("\\u00e9"), "é");
605 assert_eq!(unescape_json("\\u4e2d\\u6587"), "中文");
606
607 assert_eq!(unescape_json("hello\\u0020world"), "hello world");
609 assert_eq!(unescape_json("\\u0041\\u0042\\u0043"), "ABC");
610 }
611
612 #[test]
613 fn unescape_json_unicode_invalid() {
614 assert_eq!(unescape_json("\\u00"), "\\u00");
616 assert_eq!(unescape_json("\\u0"), "\\u0");
617 assert_eq!(unescape_json("\\u"), "\\u");
618
619 assert_eq!(unescape_json("\\u00GH"), "\\u00GH");
621 }
622
623 #[test]
624 fn unescape_json_mixed_escapes() {
625 assert_eq!(
627 unescape_json("line1\\nline2\\ttab\\u0021"),
628 "line1\nline2\ttab!"
629 );
630 assert_eq!(
631 unescape_json("\\\"quoted\\\" and \\u003Ctag\\u003E"),
632 "\"quoted\" and <tag>"
633 );
634 }
635
636 #[test]
637 fn escape_json_control_chars() {
638 assert_eq!(escape_json("\u{001b}"), "\\u001b"); assert_eq!(escape_json("\u{0007}"), "\\u0007"); }
642
643 #[test]
644 fn roundtrip_with_metadata() {
645 let mut metadata = TranscriptMetadata::new(120, 40);
646 metadata.command = Some("/bin/bash".to_string());
647 metadata.title = Some("Test Recording".to_string());
648 metadata.timestamp = Some(1_704_067_200);
649 metadata.duration = Some(Duration::from_secs_f64(30.5));
650 metadata
651 .env
652 .insert("SHELL".to_string(), "/bin/bash".to_string());
653 metadata.env.insert("TERM".to_string(), "xterm".to_string());
654
655 let mut transcript = Transcript::new(metadata);
656 transcript.push(TranscriptEvent::output(Duration::from_millis(100), b"$ "));
657 transcript.push(TranscriptEvent::input(Duration::from_millis(200), b"ls\n"));
658 transcript.push(TranscriptEvent::output(
659 Duration::from_millis(300),
660 b"file1.txt\nfile2.txt\n",
661 ));
662
663 let mut buf = Vec::new();
664 write_asciicast(&mut buf, &transcript).unwrap();
665
666 let parsed = read_asciicast(buf.as_slice()).unwrap();
667 assert_eq!(parsed.metadata.width, 120);
668 assert_eq!(parsed.metadata.height, 40);
669 assert_eq!(parsed.metadata.command, Some("/bin/bash".to_string()));
670 assert_eq!(parsed.metadata.title, Some("Test Recording".to_string()));
671 assert_eq!(parsed.metadata.timestamp, Some(1_704_067_200));
672 assert_eq!(parsed.events.len(), 3);
673 }
674}