panasyn 0.1.0

A lightweight GPU-accelerated terminal emulator for macOS and Linux.
use serde::{Deserialize, Deserializer, Serialize, Serializer};

/// A single recorded terminal event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayEvent {
    /// Milliseconds since session start.
    pub timestamp_ms: u64,
    /// The event payload.
    #[serde(flatten)]
    pub kind: ReplayEventKind,
}

#[derive(Debug, Clone)]
pub enum ReplayEventKind {
    /// Raw bytes from PTY output, hex-encoded in JSON.
    PtyBytes(Vec<u8>),
    /// Window resize event.
    Resize { cols: u16, rows: u16 },
    /// A snapshot marker for regression testing.
    Snapshot(String),
}

impl<'de> Deserialize<'de> for ReplayEventKind {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        // Manually parse the tagged union
        #[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;

/// Write events as JSONL (one JSON object per line).
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(())
}

/// Read events from a JSONL file.
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");
        }
    }
}