#![allow(dead_code)]
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::time::Instant;
pub fn terminal_size() -> (u32, u32) {
#[cfg(unix)]
{
use std::mem::MaybeUninit;
let mut ws = MaybeUninit::<libc::winsize>::uninit();
let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, ws.as_mut_ptr()) };
if ret == 0 {
let ws = unsafe { ws.assume_init() };
if ws.ws_col > 0 && ws.ws_row > 0 {
return (ws.ws_col as u32, ws.ws_row as u32);
}
}
}
(80, 24)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AsciicastHeader {
pub version: u32,
pub width: u32,
pub height: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idle_time_limit: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[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>>,
}
impl Default for AsciicastHeader {
fn default() -> Self {
Self {
version: 2,
width: 80,
height: 24,
timestamp: Some(Utc::now().timestamp()),
duration: None,
idle_time_limit: None,
command: None,
title: None,
env: None,
}
}
}
impl AsciicastHeader {
pub fn new() -> Self {
Self::default()
}
pub fn from_terminal() -> Self {
let (w, h) = terminal_size();
Self::with_size(w, h)
}
pub fn with_size(width: u32, height: u32) -> Self {
Self {
width,
height,
..Default::default()
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_command(mut self, command: impl Into<String>) -> Self {
self.command = Some(command.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventType {
Output,
Input,
}
impl EventType {
fn as_str(&self) -> &'static str {
match self {
EventType::Output => "o",
EventType::Input => "i",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"o" => Some(EventType::Output),
"i" => Some(EventType::Input),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct AsciicastEvent {
pub time: f64,
pub event_type: EventType,
pub data: String,
}
impl AsciicastEvent {
pub fn new(time: f64, event_type: EventType, data: impl Into<String>) -> Self {
Self {
time,
event_type,
data: data.into(),
}
}
pub fn to_json(&self) -> String {
format!(
"[{:.6},{:?},{:?}]",
self.time,
self.event_type.as_str(),
self.data
)
}
pub fn from_json(s: &str) -> Option<Self> {
let v: serde_json::Value = serde_json::from_str(s).ok()?;
let arr = v.as_array()?;
if arr.len() != 3 {
return None;
}
let time = arr[0].as_f64()?;
let event_type = EventType::from_str(arr[1].as_str()?)?;
let data = arr[2].as_str()?.to_string();
Some(Self {
time,
event_type,
data,
})
}
}
pub struct AsciicastRecorder {
header: AsciicastHeader,
events: Vec<AsciicastEvent>,
start_time: Instant,
output_path: PathBuf,
}
impl AsciicastRecorder {
pub fn new(output_path: impl Into<PathBuf>) -> Self {
Self {
header: AsciicastHeader::default(),
events: Vec::new(),
start_time: Instant::now(),
output_path: output_path.into(),
}
}
pub fn with_header(output_path: impl Into<PathBuf>, header: AsciicastHeader) -> Self {
Self {
header,
events: Vec::new(),
start_time: Instant::now(),
output_path: output_path.into(),
}
}
pub fn record_output(&mut self, data: impl Into<String>) {
let time = self.start_time.elapsed().as_secs_f64();
self.events
.push(AsciicastEvent::new(time, EventType::Output, data));
}
pub fn record_input(&mut self, data: impl Into<String>) {
let time = self.start_time.elapsed().as_secs_f64();
self.events
.push(AsciicastEvent::new(time, EventType::Input, data));
}
pub fn elapsed(&self) -> f64 {
self.start_time.elapsed().as_secs_f64()
}
pub fn save(&mut self) -> Result<()> {
self.header.duration = Some(self.elapsed());
let mut file = File::create(&self.output_path)?;
writeln!(file, "{}", serde_json::to_string(&self.header)?)?;
for event in &self.events {
writeln!(file, "{}", event.to_json())?;
}
Ok(())
}
pub fn path(&self) -> &PathBuf {
&self.output_path
}
}
pub fn read_asciicast(path: impl Into<PathBuf>) -> Result<(AsciicastHeader, Vec<AsciicastEvent>)> {
let file = File::open(path.into())?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
let header_line = lines
.next()
.ok_or_else(|| anyhow::anyhow!("Empty asciicast file"))??;
let header: AsciicastHeader = serde_json::from_str(&header_line)?;
let mut events = Vec::new();
for line in lines {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Some(event) = AsciicastEvent::from_json(&line) {
events.push(event);
}
}
Ok((header, events))
}
pub fn default_recordings_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agentkernel")
.join("recordings")
}
pub fn generate_recording_name(sandbox_name: &str) -> String {
let now: DateTime<Utc> = Utc::now();
format!("{}-{}.cast", sandbox_name, now.format("%Y%m%d-%H%M%S"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_header_serialization() {
let header = AsciicastHeader::new();
let json = serde_json::to_string(&header).unwrap();
assert!(json.contains("\"version\":2"));
assert!(json.contains("\"width\":80"));
assert!(json.contains("\"height\":24"));
}
#[test]
fn test_event_serialization() {
let event = AsciicastEvent::new(1.5, EventType::Output, "hello");
let json = event.to_json();
assert!(json.contains("1.5"));
assert!(json.contains("\"o\""));
assert!(json.contains("\"hello\""));
}
#[test]
fn test_recorder_save() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.cast");
let mut recorder = AsciicastRecorder::new(&path);
recorder.record_output("hello\r\n");
recorder.record_input("ls\r\n");
recorder.record_output("file1.txt\r\n");
recorder.save().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("\"version\":2"));
assert!(content.contains("hello"));
}
#[test]
fn test_read_asciicast() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.cast");
let mut recorder =
AsciicastRecorder::with_header(&path, AsciicastHeader::with_size(120, 40));
recorder.record_output("test output");
recorder.save().unwrap();
let (header, events) = read_asciicast(&path).unwrap();
assert_eq!(header.width, 120);
assert_eq!(header.height, 40);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, EventType::Output);
}
}