rust_expect/transcript/
asciicast.rs

1//! Asciinema asciicast v2 format support.
2
3use 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/// Asciicast v2 header.
11#[derive(Debug, Clone)]
12pub struct AsciicastHeader {
13    /// Format version.
14    pub version: u8,
15    /// Terminal width.
16    pub width: u16,
17    /// Terminal height.
18    pub height: u16,
19    /// Recording timestamp.
20    pub timestamp: Option<u64>,
21    /// Total duration.
22    pub duration: Option<f64>,
23    /// Idle time limit.
24    pub idle_time_limit: Option<f64>,
25    /// Command.
26    pub command: Option<String>,
27    /// Title.
28    pub title: Option<String>,
29    /// Environment.
30    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    /// Create a new header.
51    #[must_use]
52    pub fn new(width: u16, height: u16) -> Self {
53        Self {
54            width,
55            height,
56            ..Default::default()
57        }
58    }
59
60    /// Convert to JSON string.
61    #[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
97/// Write a transcript in asciicast v2 format.
98pub 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    // Write header
111    writeln!(writer, "{}", header.to_json())
112        .map_err(|e| ExpectError::io_context("writing asciicast header", e))?;
113
114    // Write events
115    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
137/// Read a transcript from asciicast v2 format.
138pub fn read_asciicast<R: BufRead>(reader: R) -> Result<Transcript> {
139    let mut lines = reader.lines();
140
141    // Parse header
142    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    // Parse events
162    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    // Parse numeric fields
177    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    // Parse string fields
197    header.command = parse_json_string(line, "command");
198    header.title = parse_json_string(line, "title");
199
200    // Parse env object (simplified - handles flat env objects)
201    if let Some(env) = parse_json_object(line, "env") {
202        header.env = env;
203    }
204
205    header
206}
207
208/// Parse a numeric JSON field.
209fn 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    // Find the end of the number
216    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
223/// Parse a floating-point JSON field.
224fn 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    // Find the end of the number (including decimal point and exponent)
231    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
240/// Parse a string JSON field.
241fn 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    // Must start with a quote
248    if !rest.starts_with('"') {
249        return None;
250    }
251
252    // Find the closing quote (handling escapes)
253    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        // No closing quote found, check if string is at end
274        end = content.len();
275    }
276
277    Some(unescape_json(&content[..end]))
278}
279
280/// Parse a JSON object field (simplified, handles flat string-value objects).
281fn 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    // Must start with {
288    if !rest.starts_with('{') {
289        return None;
290    }
291
292    // Find matching closing brace
293    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]; // Content inside braces
315    let mut result = std::collections::HashMap::new();
316
317    // Parse key-value pairs
318    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}'), // backspace
395                Some('f') => result.push('\u{000C}'), // form feed
396                Some('"') => result.push('"'),
397                Some('\\') => result.push('\\'),
398                Some('/') => result.push('/'),
399                Some('u') => {
400                    // Parse \uXXXX unicode escape
401                    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                    // Invalid escape, keep as-is
419                    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        // Forward slash can be escaped but doesn't need to be
587        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        // Basic ASCII via unicode escape
594        assert_eq!(unescape_json("\\u0041"), "A");
595        assert_eq!(unescape_json("\\u0048\\u0069"), "Hi");
596
597        // Control characters
598        assert_eq!(unescape_json("\\u001b"), "\u{001b}"); // ESC
599        assert_eq!(unescape_json("\\u0000"), "\u{0000}"); // NULL
600
601        // Non-ASCII unicode
602        assert_eq!(unescape_json("\\u00e9"), "é");
603        assert_eq!(unescape_json("\\u4e2d\\u6587"), "中文");
604
605        // Mixed content
606        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        // Invalid: not enough hex digits
613        assert_eq!(unescape_json("\\u00"), "\\u00");
614        assert_eq!(unescape_json("\\u0"), "\\u0");
615        assert_eq!(unescape_json("\\u"), "\\u");
616
617        // Invalid: non-hex characters
618        assert_eq!(unescape_json("\\u00GH"), "\\u00GH");
619    }
620
621    #[test]
622    fn unescape_json_mixed_escapes() {
623        // Combine various escape types
624        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        // Control characters should be escaped as \uXXXX
637        assert_eq!(escape_json("\u{001b}"), "\\u001b"); // ESC
638        assert_eq!(escape_json("\u{0007}"), "\\u0007"); // BEL
639    }
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}