use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayEvent {
pub timestamp_ms: u64,
#[serde(flatten)]
pub kind: ReplayEventKind,
}
#[derive(Debug, Clone)]
pub enum ReplayEventKind {
PtyBytes(Vec<u8>),
Resize { cols: u16, rows: u16 },
Snapshot(String),
}
impl<'de> Deserialize<'de> for ReplayEventKind {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Helper {
#[serde(rename = "type")]
typ: String,
#[serde(default)]
data: Option<serde_json::Value>,
#[serde(default)]
cols: Option<u16>,
#[serde(default)]
rows: Option<u16>,
}
let h = Helper::deserialize(d)?;
match h.typ.as_str() {
"PtyBytes" => {
if let Some(val) = h.data {
let s = val.as_str().unwrap_or("");
let bytes = hex::decode(s).unwrap_or_default();
Ok(ReplayEventKind::PtyBytes(bytes))
} else {
Ok(ReplayEventKind::PtyBytes(Vec::new()))
}
}
"Resize" => {
let cols = h.cols.unwrap_or(80);
let rows = h.rows.unwrap_or(24);
Ok(ReplayEventKind::Resize { cols, rows })
}
"Snapshot" => {
let label = h
.data
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
Ok(ReplayEventKind::Snapshot(label))
}
_ => Ok(ReplayEventKind::Snapshot("unknown".to_string())),
}
}
}
impl Serialize for ReplayEventKind {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
match self {
ReplayEventKind::PtyBytes(bytes) => {
let mut st = s.serialize_struct("ReplayEventKind", 2)?;
st.serialize_field("type", "PtyBytes")?;
st.serialize_field("data", &hex::encode(bytes))?;
st.end()
}
ReplayEventKind::Resize { cols, rows } => {
let mut st = s.serialize_struct("ReplayEventKind", 3)?;
st.serialize_field("type", "Resize")?;
st.serialize_field("cols", cols)?;
st.serialize_field("rows", rows)?;
st.end()
}
ReplayEventKind::Snapshot(label) => {
let mut st = s.serialize_struct("ReplayEventKind", 2)?;
st.serialize_field("type", "Snapshot")?;
st.serialize_field("data", label)?;
st.end()
}
}
}
}
impl ReplayEvent {
pub fn pty_bytes(data: &[u8]) -> Self {
Self {
timestamp_ms: 0,
kind: ReplayEventKind::PtyBytes(data.to_vec()),
}
}
pub fn resize(cols: u16, rows: u16) -> Self {
Self {
timestamp_ms: 0,
kind: ReplayEventKind::Resize { cols, rows },
}
}
pub fn snapshot(label: impl Into<String>) -> Self {
Self {
timestamp_ms: 0,
kind: ReplayEventKind::Snapshot(label.into()),
}
}
}
use std::io::Write;
pub fn write_jsonl(path: &str, events: &[ReplayEvent]) -> Result<(), Box<dyn std::error::Error>> {
let file = std::fs::File::create(path)?;
let mut writer = std::io::BufWriter::new(file);
for event in events {
let line = serde_json::to_string(event)?;
writeln!(writer, "{}", line)?;
}
Ok(())
}
pub fn read_jsonl(path: &str) -> Result<Vec<ReplayEvent>, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let mut events = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") {
continue;
}
let event: ReplayEvent = serde_json::from_str(trimmed)?;
events.push(event);
}
Ok(events)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pty_bytes_roundtrip_json() {
let ev = ReplayEvent::pty_bytes(b"\x1b[31mred\x1b[0m");
let json = serde_json::to_string(&ev).unwrap();
let parsed: ReplayEvent = serde_json::from_str(&json).unwrap();
assert_eq!(ev.timestamp_ms, parsed.timestamp_ms);
if let ReplayEventKind::PtyBytes(d) = &parsed.kind {
assert_eq!(d, b"\x1b[31mred\x1b[0m");
} else {
panic!("wrong variant");
}
}
#[test]
fn test_resize_roundtrip_json() {
let ev = ReplayEvent::resize(120, 40);
let json = serde_json::to_string(&ev).unwrap();
let parsed: ReplayEvent = serde_json::from_str(&json).unwrap();
if let ReplayEventKind::Resize { cols, rows } = parsed.kind {
assert_eq!(cols, 120);
assert_eq!(rows, 40);
} else {
panic!("wrong variant");
}
}
#[test]
fn test_snapshot_roundtrip_json() {
let ev = ReplayEvent::snapshot("test_label");
let json = serde_json::to_string(&ev).unwrap();
let parsed: ReplayEvent = serde_json::from_str(&json).unwrap();
if let ReplayEventKind::Snapshot(label) = &parsed.kind {
assert_eq!(label, "test_label");
} else {
panic!("wrong variant");
}
}
#[test]
fn test_skip_comments_and_blanks() {
let dir = std::env::temp_dir();
let path = dir.join("test_skip.jsonl");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "// comment line").unwrap();
writeln!(f).unwrap();
writeln!(f, r#"{{"timestamp_ms":0,"type":"PtyBytes","data":"00"}}"#).unwrap();
writeln!(f, " ").unwrap();
}
let events = read_jsonl(path.to_str().unwrap()).unwrap();
assert_eq!(events.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_write_then_read_jsonl() {
let dir = std::env::temp_dir();
let path = dir.join("test_write_read.jsonl");
let events = vec![
ReplayEvent::pty_bytes(b"data1"),
ReplayEvent::resize(80, 24),
ReplayEvent::snapshot("end"),
];
write_jsonl(path.to_str().unwrap(), &events).unwrap();
let parsed = read_jsonl(path.to_str().unwrap()).unwrap();
assert_eq!(parsed.len(), 3);
assert!(matches!(parsed[0].kind, ReplayEventKind::PtyBytes(_)));
assert!(matches!(parsed[1].kind, ReplayEventKind::Resize { .. }));
assert!(matches!(parsed[2].kind, ReplayEventKind::Snapshot(_)));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_replay_event_empty_ptybytes() {
let ev = ReplayEvent::pty_bytes(b"");
if let ReplayEventKind::PtyBytes(d) = &ev.kind {
assert!(d.is_empty());
} else {
panic!("wrong variant");
}
}
}