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(chars.next().unwrap());
406 } else {
407 break;
408 }
409 }
410 }
411 if hex.len() == 4
412 && let Ok(code) = u32::from_str_radix(&hex, 16)
413 && let Some(ch) = char::from_u32(code)
414 {
415 result.push(ch);
416 continue;
417 }
418 result.push_str("\\u");
420 result.push_str(&hex);
421 }
422 Some(c) => {
423 result.push('\\');
424 result.push(c);
425 }
426 None => result.push('\\'),
427 }
428 } else {
429 result.push(c);
430 }
431 }
432 result
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn asciicast_header() {
441 let header = AsciicastHeader::new(80, 24);
442 let json = header.to_json();
443 assert!(json.contains("\"version\": 2"));
444 assert!(json.contains("\"width\": 80"));
445 }
446
447 #[test]
448 fn escape_special_chars() {
449 assert_eq!(escape_json("hello\nworld"), "hello\\nworld");
450 assert_eq!(escape_json("say \"hi\""), "say \\\"hi\\\"");
451 }
452
453 #[test]
454 fn roundtrip() {
455 let mut transcript = Transcript::new(TranscriptMetadata::new(80, 24));
456 transcript.push(TranscriptEvent::output(
457 Duration::from_millis(100),
458 b"hello",
459 ));
460
461 let mut buf = Vec::new();
462 write_asciicast(&mut buf, &transcript).unwrap();
463
464 let parsed = read_asciicast(buf.as_slice()).unwrap();
465 assert_eq!(parsed.events.len(), 1);
466 }
467
468 #[test]
469 fn parse_json_number_basic() {
470 let json = r#"{"version": 2, "width": 120, "height": 40}"#;
471 assert_eq!(parse_json_number(json, "version"), Some(2));
472 assert_eq!(parse_json_number(json, "width"), Some(120));
473 assert_eq!(parse_json_number(json, "height"), Some(40));
474 assert_eq!(parse_json_number(json, "nonexistent"), None);
475 }
476
477 #[test]
478 fn parse_json_number_negative() {
479 let json = r#"{"offset": -100}"#;
480 assert_eq!(parse_json_number(json, "offset"), Some(-100));
481 }
482
483 #[test]
484 fn parse_json_float_basic() {
485 let json = r#"{"duration": 123.456789, "idle_time_limit": 2.5}"#;
486 assert!((parse_json_float(json, "duration").unwrap() - 123.456_789).abs() < 0.000_001);
487 assert!((parse_json_float(json, "idle_time_limit").unwrap() - 2.5).abs() < 0.000_001);
488 assert_eq!(parse_json_float(json, "nonexistent"), None);
489 }
490
491 #[test]
492 fn parse_json_float_scientific() {
493 let json = r#"{"value": 1.5e10}"#;
494 assert!((parse_json_float(json, "value").unwrap() - 1.5e10).abs() < 1.0);
495 }
496
497 #[test]
498 fn parse_json_string_basic() {
499 let json = r#"{"command": "/bin/bash", "title": "My Recording"}"#;
500 assert_eq!(
501 parse_json_string(json, "command"),
502 Some("/bin/bash".to_string())
503 );
504 assert_eq!(
505 parse_json_string(json, "title"),
506 Some("My Recording".to_string())
507 );
508 assert_eq!(parse_json_string(json, "nonexistent"), None);
509 }
510
511 #[test]
512 fn parse_json_string_escaped() {
513 let json = r#"{"path": "C:\\Users\\test", "msg": "say \"hello\""}"#;
514 assert_eq!(
515 parse_json_string(json, "path"),
516 Some("C:\\Users\\test".to_string())
517 );
518 assert_eq!(
519 parse_json_string(json, "msg"),
520 Some("say \"hello\"".to_string())
521 );
522 }
523
524 #[test]
525 fn parse_json_object_basic() {
526 let json = r#"{"env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}}"#;
527 let env = parse_json_object(json, "env").unwrap();
528 assert_eq!(env.get("SHELL"), Some(&"/bin/bash".to_string()));
529 assert_eq!(env.get("TERM"), Some(&"xterm-256color".to_string()));
530 }
531
532 #[test]
533 fn parse_json_object_empty() {
534 let json = r#"{"env": {}}"#;
535 let env = parse_json_object(json, "env").unwrap();
536 assert!(env.is_empty());
537 }
538
539 #[test]
540 fn parse_header_full() {
541 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"}}"#;
542 let header = parse_header(header_json);
543
544 assert_eq!(header.version, 2);
545 assert_eq!(header.width, 120);
546 assert_eq!(header.height, 40);
547 assert_eq!(header.timestamp, Some(1_704_067_200));
548 assert!((header.duration.unwrap() - 60.5).abs() < 0.001);
549 assert!((header.idle_time_limit.unwrap() - 2.0).abs() < 0.001);
550 assert_eq!(header.command, Some("/bin/zsh".to_string()));
551 assert_eq!(header.title, Some("Demo".to_string()));
552 assert_eq!(header.env.get("SHELL"), Some(&"/bin/zsh".to_string()));
553 }
554
555 #[test]
556 fn parse_header_minimal() {
557 let header_json = r#"{"version": 2, "width": 80, "height": 24}"#;
558 let header = parse_header(header_json);
559
560 assert_eq!(header.version, 2);
561 assert_eq!(header.width, 80);
562 assert_eq!(header.height, 24);
563 assert_eq!(header.timestamp, None);
564 assert_eq!(header.duration, None);
565 assert_eq!(header.command, None);
566 assert!(header.env.is_empty());
567 }
568
569 #[test]
570 fn unescape_json_sequences() {
571 assert_eq!(unescape_json("hello\\nworld"), "hello\nworld");
572 assert_eq!(unescape_json("tab\\there"), "tab\there");
573 assert_eq!(unescape_json("quote\\\"here"), "quote\"here");
574 assert_eq!(unescape_json("back\\\\slash"), "back\\slash");
575 assert_eq!(unescape_json("return\\rhere"), "return\rhere");
576 }
577
578 #[test]
579 fn unescape_json_backspace_formfeed() {
580 assert_eq!(unescape_json("back\\bspace"), "back\u{0008}space");
581 assert_eq!(unescape_json("form\\ffeed"), "form\u{000C}feed");
582 }
583
584 #[test]
585 fn unescape_json_forward_slash() {
586 assert_eq!(unescape_json("path\\/to\\/file"), "path/to/file");
588 assert_eq!(unescape_json("path/to/file"), "path/to/file");
589 }
590
591 #[test]
592 fn unescape_json_unicode() {
593 assert_eq!(unescape_json("\\u0041"), "A");
595 assert_eq!(unescape_json("\\u0048\\u0069"), "Hi");
596
597 assert_eq!(unescape_json("\\u001b"), "\u{001b}"); assert_eq!(unescape_json("\\u0000"), "\u{0000}"); assert_eq!(unescape_json("\\u00e9"), "é");
603 assert_eq!(unescape_json("\\u4e2d\\u6587"), "中文");
604
605 assert_eq!(unescape_json("hello\\u0020world"), "hello world");
607 assert_eq!(unescape_json("\\u0041\\u0042\\u0043"), "ABC");
608 }
609
610 #[test]
611 fn unescape_json_unicode_invalid() {
612 assert_eq!(unescape_json("\\u00"), "\\u00");
614 assert_eq!(unescape_json("\\u0"), "\\u0");
615 assert_eq!(unescape_json("\\u"), "\\u");
616
617 assert_eq!(unescape_json("\\u00GH"), "\\u00GH");
619 }
620
621 #[test]
622 fn unescape_json_mixed_escapes() {
623 assert_eq!(
625 unescape_json("line1\\nline2\\ttab\\u0021"),
626 "line1\nline2\ttab!"
627 );
628 assert_eq!(
629 unescape_json("\\\"quoted\\\" and \\u003Ctag\\u003E"),
630 "\"quoted\" and <tag>"
631 );
632 }
633
634 #[test]
635 fn escape_json_control_chars() {
636 assert_eq!(escape_json("\u{001b}"), "\\u001b"); assert_eq!(escape_json("\u{0007}"), "\\u0007"); }
640
641 #[test]
642 fn roundtrip_with_metadata() {
643 let mut metadata = TranscriptMetadata::new(120, 40);
644 metadata.command = Some("/bin/bash".to_string());
645 metadata.title = Some("Test Recording".to_string());
646 metadata.timestamp = Some(1_704_067_200);
647 metadata.duration = Some(Duration::from_secs_f64(30.5));
648 metadata
649 .env
650 .insert("SHELL".to_string(), "/bin/bash".to_string());
651 metadata.env.insert("TERM".to_string(), "xterm".to_string());
652
653 let mut transcript = Transcript::new(metadata);
654 transcript.push(TranscriptEvent::output(Duration::from_millis(100), b"$ "));
655 transcript.push(TranscriptEvent::input(Duration::from_millis(200), b"ls\n"));
656 transcript.push(TranscriptEvent::output(
657 Duration::from_millis(300),
658 b"file1.txt\nfile2.txt\n",
659 ));
660
661 let mut buf = Vec::new();
662 write_asciicast(&mut buf, &transcript).unwrap();
663
664 let parsed = read_asciicast(buf.as_slice()).unwrap();
665 assert_eq!(parsed.metadata.width, 120);
666 assert_eq!(parsed.metadata.height, 40);
667 assert_eq!(parsed.metadata.command, Some("/bin/bash".to_string()));
668 assert_eq!(parsed.metadata.title, Some("Test Recording".to_string()));
669 assert_eq!(parsed.metadata.timestamp, Some(1_704_067_200));
670 assert_eq!(parsed.events.len(), 3);
671 }
672}