use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CastHeader {
pub version: u32,
pub width: u32,
pub height: u32,
pub timestamp: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, String>>,
}
#[derive(Debug, Clone)]
pub struct CastEvent {
pub time: f64,
pub event_type: String, pub data: String,
}
impl CastEvent {
pub fn to_json(&self) -> String {
format!(
"[{:.6}, \"{}\", {}]",
self.time,
self.event_type,
serde_json::to_string(&self.data).unwrap_or_default()
)
}
pub fn from_json(line: &str) -> Option<Self> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
let arr = v.as_array()?;
if arr.len() < 3 {
return None;
}
Some(CastEvent {
time: arr[0].as_f64()?,
event_type: arr[1].as_str()?.to_string(),
data: arr[2].as_str()?.to_string(),
})
}
}
pub struct SessionRecorder {
writer: Mutex<BufWriter<File>>,
start: Instant,
}
impl SessionRecorder {
pub fn new(
path: &Path,
width: u32,
height: u32,
title: Option<String>,
) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
let header = CastHeader {
version: 2,
width,
height,
timestamp: chrono::Utc::now().timestamp(),
title,
env: None,
};
let header_json = serde_json::to_string(&header).unwrap_or_default();
writeln!(writer, "{}", header_json)?;
writer.flush()?;
Ok(Self {
writer: Mutex::new(writer),
start: Instant::now(),
})
}
pub fn record_output(&self, data: &[u8]) {
self.record_event("o", data);
}
pub fn record_input(&self, data: &[u8]) {
self.record_event("i", data);
}
fn record_event(&self, event_type: &str, data: &[u8]) {
let elapsed = self.start.elapsed().as_secs_f64();
let text = String::from_utf8_lossy(data);
let event = CastEvent {
time: elapsed,
event_type: event_type.to_string(),
data: text.to_string(),
};
if let Ok(mut w) = self.writer.lock() {
writeln!(w, "{}", event.to_json()).ok();
w.flush().ok();
}
}
}
pub fn parse_cast_file(path: &Path) -> std::io::Result<(CastHeader, Vec<CastEvent>)> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
let header_line = lines
.next()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "Empty cast file"))??;
let header: CastHeader = serde_json::from_str(&header_line)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut events = Vec::new();
for line in lines {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Some(event) = CastEvent::from_json(&line) {
events.push(event);
}
}
Ok((header, events))
}
pub fn list_recordings() -> std::io::Result<Vec<(String, PathBuf)>> {
let dir = recording_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut recordings = Vec::new();
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("cast") {
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
recordings.push((name, path));
}
}
recordings.sort_by(|a, b| a.0.cmp(&b.0));
Ok(recordings)
}
pub fn recording_dir() -> PathBuf {
dirs::home_dir()
.expect("home dir")
.join(".essh")
.join("recordings")
}
pub fn recording_path(session_id: &str) -> PathBuf {
recording_dir().join(format!("{}.cast", session_id))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_cast_event_roundtrip() {
let event = CastEvent {
time: 1.234567,
event_type: "o".to_string(),
data: "hello world\r\n".to_string(),
};
let json = event.to_json();
let parsed = CastEvent::from_json(&json).unwrap();
assert!((parsed.time - 1.234567).abs() < 0.001);
assert_eq!(parsed.event_type, "o");
assert_eq!(parsed.data, "hello world\r\n");
}
#[test]
fn test_cast_event_special_chars() {
let event = CastEvent {
time: 0.5,
event_type: "o".to_string(),
data: "line1\nline2\ttab\"quote".to_string(),
};
let json = event.to_json();
let parsed = CastEvent::from_json(&json).unwrap();
assert_eq!(parsed.data, "line1\nline2\ttab\"quote");
}
#[test]
fn test_cast_event_input_type() {
let event = CastEvent {
time: 2.0,
event_type: "i".to_string(),
data: "ls\r".to_string(),
};
let json = event.to_json();
let parsed = CastEvent::from_json(&json).unwrap();
assert_eq!(parsed.event_type, "i");
}
#[test]
fn test_recorder_creates_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.cast");
let recorder = SessionRecorder::new(&path, 80, 24, Some("test".to_string())).unwrap();
recorder.record_output(b"hello");
recorder.record_input(b"world");
drop(recorder);
let (header, events) = parse_cast_file(&path).unwrap();
assert_eq!(header.version, 2);
assert_eq!(header.width, 80);
assert_eq!(header.height, 24);
assert_eq!(header.title, Some("test".to_string()));
assert_eq!(events.len(), 2);
assert_eq!(events[0].event_type, "o");
assert_eq!(events[0].data, "hello");
assert_eq!(events[1].event_type, "i");
assert_eq!(events[1].data, "world");
}
#[test]
fn test_recorder_timestamps_increase() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("timestamps.cast");
let recorder = SessionRecorder::new(&path, 80, 24, None).unwrap();
recorder.record_output(b"first");
std::thread::sleep(std::time::Duration::from_millis(10));
recorder.record_output(b"second");
drop(recorder);
let (_, events) = parse_cast_file(&path).unwrap();
assert!(events[1].time > events[0].time);
}
#[test]
fn test_parse_cast_file_empty_events() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("empty.cast");
let recorder = SessionRecorder::new(&path, 120, 40, None).unwrap();
drop(recorder);
let (header, events) = parse_cast_file(&path).unwrap();
assert_eq!(header.width, 120);
assert_eq!(header.height, 40);
assert!(events.is_empty());
}
#[test]
fn test_cast_header_serde() {
let header = CastHeader {
version: 2,
width: 80,
height: 24,
timestamp: 1700000000,
title: Some("my session".to_string()),
env: None,
};
let json = serde_json::to_string(&header).unwrap();
let parsed: CastHeader = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, 2);
assert_eq!(parsed.width, 80);
assert_eq!(parsed.title, Some("my session".to_string()));
}
}