diff --git a/Cargo.lock b/Cargo.lock index dca43f7..abba36e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,6 +15,9 @@ dependencies = [ "anyhow", "mio", "nix", + "serde", + "serde_json", + "tempfile", "termion", ] @@ -36,12 +39,40 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "errno" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + [[package]] name = "libc" version = "0.2.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" +[[package]] +name = "linux-raw-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" + [[package]] name = "log" version = "0.4.20" @@ -77,6 +108,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" +[[package]] +name = "proc-macro2" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -86,13 +135,96 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_termios" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" dependencies = [ - "redox_syscall", + "redox_syscall 0.2.16", +] + +[[package]] +name = "rustix" +version = "0.38.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67ce50cb2e16c2903e30d1cbccfd8387a74b9d4c938b6a4c5ec6cc7556f7a8a0" +dependencies = [ + "bitflags 2.4.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "serde" +version = "1.0.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall 0.3.5", + "rustix", + "windows-sys", ] [[package]] @@ -103,10 +235,16 @@ checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90" dependencies = [ "libc", "numtoa", - "redox_syscall", + "redox_syscall 0.2.16", "redox_termios", ] +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 38daed5..1c9c2b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,8 @@ anyhow = "1.0.75" nix = { version = "0.27", features = [ "fs", "term", "process", "signal" ] } mio = { version ="0.8", features = ["os-poll", "os-ext"] } termion = "2.0.1" +serde = { version = "1.0.189", features = ["derive"] } +serde_json = "1.0.107" + +[dev-dependencies] +tempfile = "3.8.0" diff --git a/src/asciicast.rs b/src/asciicast.rs new file mode 100644 index 0000000..aad513c --- /dev/null +++ b/src/asciicast.rs @@ -0,0 +1,283 @@ +use anyhow::bail; +use serde::Deserialize; +use std::fmt::{self, Display}; +use std::fs; +use std::io; +use std::io::BufRead; +use std::io::Write; +use std::path::Path; + +pub struct Writer { + file: fs::File, + time_offset: f64, +} + +pub struct Header { + pub terminal_size: (usize, usize), + pub idle_time_limit: Option, +} + +#[derive(Deserialize)] +pub struct V2Header { + pub width: usize, + pub height: usize, + pub idle_time_limit: Option, +} + +pub struct Event { + pub time: f64, + pub code: EventCode, + pub data: String, +} + +#[derive(PartialEq, Eq, Debug)] +pub enum EventCode { + Output, + Input, + Resize, + Marker, + Other(char), +} + +pub fn open( + reader: R, +) -> anyhow::Result<(Header, impl Iterator>)> { + let mut lines = reader.lines(); + let first_line = lines.next().ok_or(anyhow::anyhow!("empty"))??; + let v2_header: V2Header = serde_json::from_str(&first_line)?; + let header: Header = v2_header.into(); + + let events = lines + .filter(|l| l.as_ref().map_or(true, |l| !l.is_empty())) + .enumerate() + .map(|(i, l)| l.map(|l| parse_event(l, i + 2))?); + + Ok((header, events)) +} + +fn parse_event(line: String, i: usize) -> anyhow::Result { + use EventCode::*; + + let value: serde_json::Value = serde_json::from_str(&line)?; + + let time = value[0] + .as_f64() + .ok_or(anyhow::anyhow!("line {}: invalid event time", i))?; + + let code = match value[1].as_str() { + Some("o") => Output, + Some("i") => Input, + Some("r") => Resize, + Some("m") => Marker, + Some(s) if !s.is_empty() => Other(s.chars().next().unwrap()), + Some(_) => bail!("line {}: missing event code", i), + None => bail!("line {}: event code must be a string", i), + }; + + let data = match value[2].as_str() { + Some(data) => data.to_owned(), + None => bail!("line {}: event data must be a string", i), + }; + + Ok(Event { time, code, data }) +} + +pub fn write_header(sink: &mut W, header: &Header) -> io::Result<()> { + writeln!(sink, "{}", serde_json::to_string(header)?) +} + +pub fn write_event(sink: &mut W, event: &Event) -> io::Result<()> { + writeln!(sink, "{}", serde_json::to_string(event)?) +} + +pub fn get_duration>(path: S) -> anyhow::Result { + let file = fs::File::open(path)?; + let reader = io::BufReader::new(file); + let (_header, events) = open(reader)?; + let time = events.last().map_or(Ok(0.0), |e| e.map(|e| e.time))?; + + Ok(time) +} + +impl Writer { + pub fn new>(path: S, append: bool) -> anyhow::Result { + if append { + Self::append(path) + } else { + Self::create(path) + } + } + + pub fn create>(path: S) -> anyhow::Result { + let file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path)?; + + Ok(Self { + file, + time_offset: 0.0, + }) + } + + pub fn append>(path: S) -> anyhow::Result { + let time_offset = get_duration(&path)?; + let file = fs::OpenOptions::new().append(true).open(path)?; + + Ok(Self { file, time_offset }) + } + + pub fn write_header(&mut self, header: &Header) -> io::Result<()> { + if self.time_offset == 0.0 { + write_header(&mut self.file, header) + } else { + Ok(()) + } + } + + pub fn write_event(&mut self, mut event: Event) -> io::Result<()> { + event.time += self.time_offset; + + write_event(&mut self.file, &event) + } +} + +impl Event { + pub fn output(time: f64, data: &[u8]) -> Self { + Event { + time, + code: EventCode::Output, + data: String::from_utf8_lossy(data).to_string(), + } + } + + pub fn input(time: f64, data: &[u8]) -> Self { + Event { + time, + code: EventCode::Input, + data: String::from_utf8_lossy(data).to_string(), + } + } +} + +impl Display for EventCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { + use EventCode::*; + + match self { + Output => f.write_str("o"), + Input => f.write_str("i"), + Resize => f.write_str("r"), + Marker => f.write_str("m"), + Other(t) => f.write_str(&t.to_string()), + } + } +} + +impl serde::Serialize for Header { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("version", &2)?; + map.serialize_entry("width", &self.terminal_size.0)?; + map.serialize_entry("height", &self.terminal_size.1)?; + // TODO idle_time_limit + map.end() + } +} + +impl serde::Serialize for Event { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeTuple; + let mut tup = serializer.serialize_tuple(3)?; + tup.serialize_element(&self.time)?; + tup.serialize_element(&self.code.to_string())?; + tup.serialize_element(&self.data)?; + tup.end() + } +} + +impl From for Header { + fn from(v2: V2Header) -> Self { + Self { + terminal_size: (v2.width, v2.height), + idle_time_limit: v2.idle_time_limit, + } + } +} + +#[cfg(test)] +mod tests { + use super::{Event, EventCode, Header, Writer}; + use std::fs::{self, File}; + use std::io; + use tempfile::tempdir; + + #[test] + fn open() { + let file = File::open("tests/demo.cast").unwrap(); + let (header, events) = super::open(io::BufReader::new(file)).unwrap(); + + let events = events + .take(7) + .collect::>>() + .unwrap(); + + assert_eq!(header.terminal_size, (75, 18)); + + assert_eq!(events[1].time, 0.100989); + assert_eq!(events[1].code, EventCode::Output); + assert_eq!(events[1].data, "\u{1b}[?2004h"); + + assert_eq!(events[5].time, 1.511526); + assert_eq!(events[5].code, EventCode::Input); + assert_eq!(events[5].data, "v"); + + assert_eq!(events[6].time, 1.511937); + assert_eq!(events[6].code, EventCode::Output); + assert_eq!(events[6].data, "v"); + } + + #[test] + fn writer() { + let tmp_dir = tempdir().unwrap(); + let tmp_path = tmp_dir.path().join("test.cast"); + + { + let header = Header { + terminal_size: (80, 24), + idle_time_limit: None, + }; + + let mut fw = Writer::create(&tmp_path).unwrap(); + + fw.write_header(&header).unwrap(); + + fw.write_event(Event { + time: 1.0, + code: EventCode::Output, + data: "hello\r\n".to_owned(), + }) + .unwrap(); + } + + { + let mut fw = Writer::append(&tmp_path).unwrap(); + + fw.write_event(Event { + time: 1.0, + code: EventCode::Output, + data: "world".to_owned(), + }) + .unwrap(); + } + + assert_eq!(fs::read_to_string(tmp_path).unwrap(), "{\"version\":2,\"width\":80,\"height\":24}\n[1.0,\"o\",\"hello\\r\\n\"]\n[2.0,\"o\",\"world\"]\n"); + } +} diff --git a/src/main.rs b/src/main.rs index f5e5042..cba7986 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod asciicast; mod pty; mod recorder; use anyhow::{anyhow, Result}; @@ -8,7 +9,7 @@ fn main() -> Result<()> { .nth(1) .ok_or(anyhow!("output filename missing"))?; - let mut recorder = recorder::new(path, recorder::Format::Raw, false, true)?; + let mut recorder = recorder::new(path, recorder::Format::Asciicast, false, true)?; pty::exec(&["/bin/bash"], &mut recorder)?; Ok(()) diff --git a/src/recorder.rs b/src/recorder.rs index 1fcd851..07191bb 100644 --- a/src/recorder.rs +++ b/src/recorder.rs @@ -1,10 +1,13 @@ +use crate::asciicast; use crate::pty; use std::fs::{self, File}; use std::io::{self, Write}; +use std::time; pub struct Recorder { writer: Box, record_input: bool, + start_time: time::Instant, } trait FileWriter { @@ -18,11 +21,6 @@ pub enum Format { Raw, } -struct AsciicastWriter { - file: File, - append: bool, -} - struct RawWriter { file: File, append: bool, @@ -33,25 +31,62 @@ pub fn new>( format: Format, append: bool, record_input: bool, -) -> io::Result { +) -> anyhow::Result { let path = path.into(); let writer: Box = match format { - Format::Asciicast => Box::new(AsciicastWriter::new(path, append)?), + Format::Asciicast => Box::new(asciicast::Writer::new(path, append)?), Format::Raw => Box::new(RawWriter::new(path, append)?), }; Ok(Recorder { writer, record_input, + start_time: time::Instant::now(), }) } -impl AsciicastWriter { - fn new(path: String, append: bool) -> io::Result { - let file = File::create(path)?; +impl Recorder { + fn elapsed_time(&self) -> f64 { + self.start_time.elapsed().as_secs_f64() + } +} - Ok(Self { file, append }) +impl pty::Recorder for Recorder { + fn start(&mut self, size: (u16, u16)) -> io::Result<()> { + self.start_time = time::Instant::now(); + self.writer.header(size) + } + + fn output(&mut self, data: &[u8]) { + let _ = self.writer.output(self.elapsed_time(), data); + // TODO use notifier for error reporting + } + + fn input(&mut self, data: &[u8]) { + if self.record_input { + let _ = self.writer.input(self.elapsed_time(), data); + // TODO use notifier for error reporting + } + } +} + +impl FileWriter for asciicast::Writer { + fn header(&mut self, size: (u16, u16)) -> io::Result<()> { + let header = asciicast::Header { + terminal_size: (size.0 as usize, size.1 as usize), + idle_time_limit: None, + }; + + self.write_header(&header) + } + + fn output(&mut self, time: f64, data: &[u8]) -> io::Result<()> { + self.write_event(asciicast::Event::output(time, data)) + } + + fn input(&mut self, time: f64, data: &[u8]) -> io::Result<()> { + self.write_event(asciicast::Event::input(time, data)) } } @@ -71,20 +106,6 @@ impl RawWriter { } } -impl FileWriter for AsciicastWriter { - fn header(&mut self, size: (u16, u16)) -> io::Result<()> { - todo!() - } - - fn output(&mut self, time: f64, data: &[u8]) -> io::Result<()> { - todo!() - } - - fn input(&mut self, time: f64, data: &[u8]) -> io::Result<()> { - todo!() - } -} - impl FileWriter for RawWriter { fn header(&mut self, size: (u16, u16)) -> io::Result<()> { if self.append { @@ -102,19 +123,3 @@ impl FileWriter for RawWriter { Ok(()) } } - -impl pty::Recorder for Recorder { - fn start(&mut self, size: (u16, u16)) -> io::Result<()> { - self.writer.header(size) - } - - fn output(&mut self, data: &[u8]) { - let _ = self.writer.output(0.0, data); - } - - fn input(&mut self, data: &[u8]) { - if self.record_input { - let _ = self.writer.input(0.0, data); - } - } -}