diff --git a/src/asciicast.rs b/src/asciicast.rs index 8516e19..39c4d4d 100644 --- a/src/asciicast.rs +++ b/src/asciicast.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::fmt::{self, Display}; @@ -17,8 +17,37 @@ pub struct Writer { time_offset: u64, } -#[derive(Deserialize)] pub struct Header { + pub version: u8, + pub cols: u16, + pub rows: u16, + pub timestamp: Option, + pub idle_time_limit: Option, + pub command: Option, + pub title: Option, + pub env: Option>, +} + +#[derive(Debug, Deserialize)] +struct V1 { + version: u8, + width: u16, + height: u16, + command: Option, + title: Option, + env: Option>, + stdout: Vec, +} + +#[derive(Debug, Deserialize)] +struct V1Stdout { + #[serde(deserialize_with = "deserialize_time")] + time: u64, + data: String, +} + +#[derive(Deserialize)] +pub struct V2Header { pub version: u8, pub width: u16, pub height: u16, @@ -59,7 +88,8 @@ where } pub fn write_header(&mut self, header: &Header) -> io::Result<()> { - writeln!(self.writer, "{}", serde_json::to_string(header)?) + let header: V2Header = header.into(); + writeln!(self.writer, "{}", serde_json::to_string(&header)?) } pub fn write_event(&mut self, mut event: Event) -> io::Result<()> { @@ -87,10 +117,38 @@ pub fn open_from_path>(path: S) -> Result> { pub fn open<'a, R: BufRead + 'a>(reader: R) -> Result> { let mut lines = reader.lines(); let first_line = lines.next().ok_or(anyhow!("empty file"))??; - let header: Header = serde_json::from_str(&first_line)?; - let events = Box::new(lines.filter_map(parse_event)); - Ok(Reader { header, events }) + if let Ok(header) = serde_json::from_str::(&first_line) { + if header.version != 2 { + bail!("unsupported asciicast version") + } + + let header: Header = header.into(); + let events = Box::new(lines.filter_map(parse_event)); + + Ok(Reader { header, events }) + } else { + let json = std::iter::once(Ok(first_line)) + .chain(lines) + .collect::>()?; + + let asciicast: V1 = serde_json::from_str(&json)?; + + if asciicast.version != 1 { + bail!("unsupported asciicast version") + } + + let header: Header = (&asciicast).into(); + + let events = Box::new( + asciicast + .stdout + .into_iter() + .map(|e| Ok(Event::output(e.time, e.data.as_bytes()))), + ); + + Ok(Reader { header, events }) + } } fn parse_event(line: io::Result) -> Option> { @@ -200,7 +258,7 @@ impl Display for EventCode { } } -impl serde::Serialize for Header { +impl serde::Serialize for V2Header { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, @@ -253,6 +311,51 @@ impl serde::Serialize for Header { } } +impl From<&Header> for V2Header { + fn from(header: &Header) -> Self { + V2Header { + version: 2, + width: header.cols, + height: header.rows, + timestamp: header.timestamp, + idle_time_limit: header.idle_time_limit, + command: header.command.clone(), + title: header.title.clone(), + env: header.env.clone(), + } + } +} + +impl From for Header { + fn from(header: V2Header) -> Self { + Header { + version: 2, + cols: header.width, + rows: header.height, + timestamp: None, + idle_time_limit: None, + command: header.command, + title: header.title, + env: header.env, + } + } +} + +impl From<&V1> for Header { + fn from(header: &V1) -> Self { + Header { + version: 1, + cols: header.width, + rows: header.height, + timestamp: None, + idle_time_limit: None, + command: header.command.clone(), + title: header.title.clone(), + env: header.env.clone(), + } + } +} + fn serialize_event(event: &Event) -> Result { Ok(format!( "[{}, {}, {}]", @@ -312,13 +415,48 @@ mod tests { use std::io; #[test] - fn open() { - let file = File::open("tests/demo.cast").unwrap(); + fn open_v1_minimal() { + let file = File::open("tests/casts/minimal.json").unwrap(); let Reader { header, events } = super::open(io::BufReader::new(file)).unwrap(); + let events = events.collect::>>().unwrap(); + assert_eq!(header.version, 1); + assert_eq!((header.cols, header.rows), (100, 50)); + + assert_eq!(events[0].time, 1230000); + assert_eq!(events[0].code, EventCode::Output); + assert_eq!(events[0].data, "hello"); + } + + #[test] + fn open_v1_full() { + let file = File::open("tests/casts/full.json").unwrap(); + let Reader { header, events } = super::open(io::BufReader::new(file)).unwrap(); + let events = events.collect::>>().unwrap(); + + assert_eq!(header.version, 1); + assert_eq!((header.cols, header.rows), (100, 50)); + + assert_eq!(events[0].time, 1); + assert_eq!(events[0].code, EventCode::Output); + assert_eq!(events[0].data, "ż"); + + assert_eq!(events[1].time, 100000); + assert_eq!(events[1].code, EventCode::Output); + assert_eq!(events[1].data, "ółć"); + + assert_eq!(events[2].time, 10500000); + assert_eq!(events[2].code, EventCode::Output); + assert_eq!(events[2].data, "\r\n"); + } + + #[test] + fn open_v2() { + let file = File::open("tests/casts/demo.cast").unwrap(); + let Reader { header, events } = super::open(io::BufReader::new(file)).unwrap(); let events = events.take(7).collect::>>().unwrap(); - assert_eq!((header.width, header.height), (75, 18)); + assert_eq!((header.cols, header.rows), (75, 18)); assert_eq!(events[1].time, 100989); assert_eq!(events[1].code, EventCode::Output); @@ -342,8 +480,8 @@ mod tests { let header = Header { version: 2, - width: 80, - height: 24, + cols: 80, + rows: 24, timestamp: None, idle_time_limit: None, command: None, @@ -403,8 +541,8 @@ mod tests { let header = Header { version: 2, - width: 80, - height: 24, + cols: 80, + rows: 24, timestamp: Some(1704719152), idle_time_limit: Some(1.5), command: Some("/bin/bash".to_owned()), diff --git a/src/output/asciicast.rs b/src/output/asciicast.rs index fec8262..150fbcb 100644 --- a/src/output/asciicast.rs +++ b/src/output/asciicast.rs @@ -30,12 +30,12 @@ where } fn build_header(&self, timestamp: u64, tty_size: &tty::TtySize) -> Header { - let (width, height) = (*tty_size).into(); + let (cols, rows) = (*tty_size).into(); Header { version: 2, - width, - height, + cols, + rows, timestamp: Some(timestamp), idle_time_limit: self.metadata.idle_time_limit, command: self.metadata.command.clone(), diff --git a/tests/demo.json b/tests/casts/demo.json similarity index 100% rename from tests/demo.json rename to tests/casts/demo.json diff --git a/tests/casts/full.json b/tests/casts/full.json new file mode 100644 index 0000000..faeba52 --- /dev/null +++ b/tests/casts/full.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "width": 100, + "height": 50, + "duration": 10.5, + "command": "/bin/bash", + "title": null, + "env": { + "TERM": "xterm-256color", + "SHELL": "/bin/bash" + }, + "stdout": [ + [ + 0.000001, + "ż" + ], + [ + 0.100000, + "ółć" + ], + [ + 10.500000, + "\r\n" + ] + ] +} diff --git a/tests/casts/minimal.json b/tests/casts/minimal.json new file mode 100644 index 0000000..36f0958 --- /dev/null +++ b/tests/casts/minimal.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "width": 100, + "height": 50, + "stdout": [ + [ + 1.230000, + "hello" + ] + ] +} diff --git a/tests/demo.cast b/tests/demo.cast deleted file mode 100644 index fe55360..0000000 --- a/tests/demo.cast +++ /dev/null @@ -1,40 +0,0 @@ -{"env": {"TERM": "xterm-256color", "SHELL": "/usr/local/bin/fish"}, "width": 75, "height": 18, "timestamp": 1509091818, "version": 2, "idle_time_limit": 2.0} -[0.089436, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"] -[0.100989, "o", "\u001b[?2004h"] -[0.164215, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"] -[0.164513, "o", "\u001b[38;5;237m⏎\u001b(B\u001b[m \r⏎ \r\u001b[2K"] -[0.164709, "o", "\u001b[32m~/c/a/asciinema\u001b[30m\u001b(B\u001b[m (develop ↩☡=) \u001b[30m\u001b(B\u001b[m\u001b[K"] -[1.511526, "i", "v"] -[1.511937, "o", "v"] -[1.512148, "o", "\b\u001b[38;2;0;95;215mv\u001b[30m\u001b(B\u001b[m"] -[1.514564, "o", "\u001b[38;2;85;85;85mim tests/vim.cast \u001b[18D\u001b[30m\u001b(B\u001b[m"] -[1.615727, "i", "i"] -[1.616261, "o", "\u001b[38;2;0;95;215mi\u001b[38;2;85;85;85mm tests/vim.cast \u001b[17D\u001b[30m\u001b(B\u001b[m"] -[1.694908, "i", "m"] -[1.695262, "o", "\u001b[38;2;0;95;215mm\u001b[38;2;85;85;85m tests/vim.cast \u001b[16D\u001b[30m\u001b(B\u001b[m"] -[2.751713, "i", "\r"] -[2.752186, "o", "\u001b[K\r\n\u001b[30m"] -[2.752381, "o", "\u001b(B\u001b[m\u001b[?2004l"] -[2.752718, "o", "\u001b]0;vim /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m\r"] -[2.86619, "o", "\u001b[?1000h\u001b[?2004h\u001b[?1049h\u001b[?1h\u001b=\u001b[?2004h"] -[2.867669, "o", "\u001b[1;18r\u001b[?12h\u001b[?12l\u001b[27m\u001b[29m\u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[H\u001b[2J\u001b[2;1H▽\u001b[6n\u001b[2;1H \u001b[1;1H\u001b[>c"] -[2.868169, "i", "\u001b[2;2R\u001b[>0;95;0c"] -[2.869918, "o", "\u001b[?1000l\u001b[?1002h\u001b[?12$p"] -[2.870136, "o", "\u001b[?25l\u001b[1;1H\u001b[93m1 \u001b[m\u001b[38;5;231m\u001b[48;5;235m\r\n\u001b[38;5;59m\u001b[48;5;236m~ \u001b[3;1H~ \u001b[4;1H~ \u001b[5;1H~ \u001b[6;1H~ \u001b[7;1H~ \u001b[8;1H~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ "] -[2.870245, "o", " \u001b[14;1H~ \u001b[15;1H~ \u001b[16;1H~ \u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[17;1H\u001b[1m\u001b[38;5;231m\u001b[48;5;236m[No Name] (unix/utf-8/) (line 0/1, col 000)\u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[3;30HVIM - Vi IMproved\u001b[5;30Hversion 8.0.1171\u001b[6;26Hby Bram Moolenaar et al.\u001b[7;17HVim is open source and freely distributable\u001b[9;24HBecome a registered Vim user!\u001b[10;15Htype :help register\u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m for information \u001b[12;15Htype :q\u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m to exit \u001b[13;15Htype :help\u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m or \u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m for on-line help\u001b[14;15Htype :help version8\u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m for version"] -[2.870302, "o", " info\u001b[1;5H\u001b[?25h"] -[5.63147, "i", ":"] -[5.631755, "o", "\u001b[?25l\u001b[18;65H:\u001b[1;5H"] -[5.631934, "o", "\u001b[18;65H\u001b[K\u001b[18;1H:\u001b[?2004l\u001b[?2004h\u001b[?25h"] -[6.16692, "i", "q"] -[6.167137, "o", "q\u001b[?25l\u001b[?25h"] -[7.463349, "i", "\r"] -[7.463561, "o", "\r"] -[7.498922, "o", "\u001b[?25l\u001b[?1002l\u001b[?2004l"] -[7.604236, "o", "\u001b[18;1H\u001b[K\u001b[18;1H\u001b[?2004l\u001b[?1l\u001b>\u001b[?25h\u001b[?1049l"] -[7.612576, "o", "\u001b[?2004h"] -[7.655999, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"] -[7.656239, "o", "\u001b[38;5;237m⏎\u001b(B\u001b[m \r⏎ \r\u001b[2K\u001b[32m~/c/a/asciinema\u001b[30m\u001b(B\u001b[m (develop ↩☡=) \u001b[30m\u001b(B\u001b[m\u001b[K"] -[11.891762, "i", "\u0004"] -[11.893297, "o", "\r\n\u001b[30m\u001b(B\u001b[m\u001b[30m\u001b(B\u001b[m"] -[11.89348, "o", "\u001b[?2004l"]