use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Local;
use crate::domain::{KeyCode, Msg, MsgKind, Paste, SlashCmd, ToolOutcome, TurnId};
use crate::providers::{ProgressEvent, SubagentPhase};
pub struct Recorder {
writer: BufWriter<File>,
path: PathBuf,
}
impl Recorder {
pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("open {} for recording", path.display()))?;
Ok(Self {
writer: BufWriter::new(file),
path,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn record_kind(
&mut self,
kind: MsgKind,
turn: Option<TurnId>,
body: serde_json::Value,
) -> Result<()> {
let entry = serde_json::json!({
"ts": Local::now().to_rfc3339(),
"kind": format!("{:?}", kind),
"turn": turn.map(|t| t.0),
"body": body,
});
writeln!(self.writer, "{}", entry).context("write jsonl line")?;
Ok(())
}
pub fn flush(&mut self) -> Result<()> {
self.writer.flush().context("flush recorder")
}
}
impl Drop for Recorder {
fn drop(&mut self) {
let _ = self.writer.flush();
}
}
pub fn record_msg_body(msg: &Msg) -> serde_json::Value {
match msg {
Msg::Key(key) => serde_json::json!({
"code": key_code_body(key.code),
"modifiers": {
"ctrl": key.modifiers.ctrl,
"alt": key.modifiers.alt,
"shift": key.modifiers.shift,
},
}),
Msg::Paste(Paste::Text(text)) => serde_json::json!({
"type": "text",
"text": text,
}),
Msg::Paste(Paste::Image { bytes, format }) => serde_json::json!({
"recordable": false,
"reason": "binary paste image omitted",
"type": "image",
"format": format,
"size_bytes": bytes.len(),
}),
Msg::SubmitPrompt {
text,
attachment_ids,
} => serde_json::json!({
"text": text,
"attachment_ids": attachment_ids,
}),
Msg::Slash(cmd) => slash_body(cmd),
Msg::CancelTurn => serde_json::json!({}),
Msg::ConfirmAccepted => serde_json::json!({"accepted": true}),
Msg::ConfirmDeclined => serde_json::json!({"accepted": false}),
Msg::Quit => serde_json::json!({}),
Msg::RuntimeSignal(signal) => serde_json::json!({
"signal": signal.as_str(),
}),
Msg::StreamText { chunk, .. } => serde_json::json!({"chunk": chunk}),
Msg::StreamReasoning { chunk, .. } => serde_json::json!({
"text": chunk.text,
"signature": chunk.signature,
}),
Msg::StreamToolCall { call, .. } => serde_json::to_value(call)
.unwrap_or_else(|_| unsupported("tool call was not serializable")),
Msg::ContextUsageEstimated { snapshot, .. } => serde_json::json!({
"used_tokens": snapshot.used_tokens,
"max_tokens": snapshot.max_tokens,
"remaining_tokens": snapshot.remaining_tokens,
"used_percent": snapshot.used_percent,
"source": format!("{:?}", snapshot.source),
"breakdown": snapshot.breakdown.as_ref().map(|b| serde_json::json!({
"system_tokens": b.system_tokens,
"instructions_tokens": b.instructions_tokens,
"message_tokens": b.message_tokens,
"tool_schema_tokens": b.tool_schema_tokens,
"image_count": b.image_count,
"message_count": b.message_count,
"tool_count": b.tool_count,
})),
}),
Msg::CompactionFinished { result, .. } => serde_json::json!({
"id": result.record.id,
"trigger": result.record.trigger.as_str(),
"before_tokens": result.record.before_tokens,
"after_tokens": result.record.after_tokens,
"archived_message_count": result.record.archived_message_count,
"preserved_message_count": result.record.preserved_message_count,
"duration_secs": result.record.duration_secs,
}),
Msg::CompactionFailed {
trigger,
message,
kind,
..
} => serde_json::json!({
"trigger": trigger.as_str(),
"message": message,
"kind": format!("{:?}", kind),
}),
Msg::StreamDone {
usage,
thinking_signature,
..
} => serde_json::json!({
"usage": usage.as_ref().map(|u| serde_json::json!({
"prompt_tokens": u.prompt_tokens,
"completion_tokens": u.completion_tokens,
"total_tokens": u.total_tokens,
"cached_input_tokens": u.cached_input_tokens,
"cache_creation_input_tokens": u.cache_creation_input_tokens,
"reasoning_output_tokens": u.reasoning_output_tokens,
"source": format!("{:?}", u.source),
})),
"thinking_signature": thinking_signature,
}),
Msg::UpstreamError { error, .. } => serde_json::to_value(error)
.unwrap_or_else(|_| unsupported("upstream error was not serializable")),
Msg::TurnCancelled(_) => serde_json::json!({}),
Msg::ToolStarted { call_id, .. } => serde_json::json!({"call_id": call_id.0}),
Msg::ToolProgress { call_id, event, .. } => serde_json::json!({
"call_id": call_id.0,
"event": progress_body(event),
}),
Msg::ToolFinished {
call_id, outcome, ..
} => serde_json::json!({
"call_id": call_id.0,
"outcome": outcome_body(outcome),
}),
Msg::McpServerReady { name, tools } => serde_json::json!({
"name": name,
"tools": tools.iter().map(|tool| serde_json::json!({
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
})).collect::<Vec<_>>(),
}),
Msg::McpServerErrored { name, reason } => serde_json::json!({
"name": name,
"reason": reason,
}),
Msg::McpServerStopped { name } => serde_json::json!({"name": name}),
Msg::InstructionsChanged(loaded) => match loaded {
Some(loaded) => serde_json::json!({
"path": loaded.path,
"byte_len": loaded.byte_len,
"truncated": loaded.truncated,
}),
None => serde_json::json!({"path": null}),
},
Msg::SessionSaved => serde_json::json!({}),
Msg::ConversationLoaded(history) => serde_json::json!({
"id": history.id,
"message_count": history.messages.len(),
"title": history.title,
}),
Msg::ConversationsListed(summaries) => serde_json::json!({
"count": summaries.len(),
"ids": summaries.iter().map(|summary| summary.id.as_str()).collect::<Vec<_>>(),
}),
Msg::ModelPullFinished { model } => serde_json::json!({"model": model}),
Msg::ModelPullProgress(line) => serde_json::json!({"line": line}),
Msg::Tick => serde_json::json!({}),
Msg::StatusDismiss => serde_json::json!({}),
Msg::Resize { width, height } => serde_json::json!({
"width": width,
"height": height,
}),
Msg::TransientStatus {
text,
kind,
dismiss_ms,
} => serde_json::json!({
"text": text,
"kind": format!("{:?}", kind),
"dismiss_ms": dismiss_ms,
}),
Msg::MouseScroll { delta } => serde_json::json!({"delta": delta}),
Msg::OpenImageAt {
message_index,
image_index,
} => serde_json::json!({
"message_index": message_index,
"image_index": image_index,
}),
}
}
fn key_code_body(code: KeyCode) -> serde_json::Value {
match code {
KeyCode::Char(c) => serde_json::json!({"char": c.to_string()}),
KeyCode::F(n) => serde_json::json!({"f": n}),
other => serde_json::json!(format!("{:?}", other)),
}
}
fn slash_body(cmd: &SlashCmd) -> serde_json::Value {
match cmd {
SlashCmd::Model(model) => serde_json::json!({"command": "model", "arg": model}),
SlashCmd::Reasoning(level) => serde_json::json!({
"command": "reasoning",
"arg": level.map(|level| level.as_str()),
}),
SlashCmd::Clear => serde_json::json!({"command": "clear"}),
SlashCmd::Save(name) => serde_json::json!({"command": "save", "arg": name}),
SlashCmd::Load(name) => serde_json::json!({"command": "load", "arg": name}),
SlashCmd::List => serde_json::json!({"command": "list"}),
SlashCmd::Usage => serde_json::json!({"command": "usage"}),
SlashCmd::Context => serde_json::json!({"command": "context"}),
SlashCmd::Compact(instructions) => {
serde_json::json!({"command": "compact", "arg": instructions})
},
SlashCmd::CloudSetup => serde_json::json!({"command": "cloud-setup"}),
SlashCmd::Help => serde_json::json!({"command": "help"}),
SlashCmd::Quit => serde_json::json!({"command": "quit"}),
SlashCmd::Unknown(name) => serde_json::json!({"command": "unknown", "name": name}),
}
}
fn progress_body(event: &ProgressEvent) -> serde_json::Value {
match event {
ProgressEvent::Output(text) => serde_json::json!({"type": "output", "text": text}),
ProgressEvent::Status(text) => serde_json::json!({"type": "status", "text": text}),
ProgressEvent::Bytes { done, total } => serde_json::json!({
"type": "bytes",
"done": done,
"total": total,
}),
ProgressEvent::Artifact {
mime,
data,
caption,
} => serde_json::json!({
"recordable": false,
"type": "artifact",
"mime": mime,
"caption": caption,
"size_bytes": data.len(),
}),
ProgressEvent::SubagentToolCall {
child_call_id,
tool_name,
phase,
} => serde_json::json!({
"type": "subagent_tool_call",
"child_call_id": child_call_id.0,
"tool_name": tool_name,
"phase": subagent_phase(*phase),
}),
ProgressEvent::SubagentText(text) => {
serde_json::json!({"type": "subagent_text", "text": text})
},
}
}
fn subagent_phase(phase: SubagentPhase) -> &'static str {
match phase {
SubagentPhase::Started => "started",
SubagentPhase::Finished => "finished",
SubagentPhase::Errored => "errored",
}
}
fn outcome_body(outcome: &ToolOutcome) -> serde_json::Value {
serde_json::json!({
"status": match outcome.status {
crate::domain::ToolStatus::Success => "success",
crate::domain::ToolStatus::Error => "error",
crate::domain::ToolStatus::Cancelled => "cancelled",
},
"summary": outcome.summary,
"model_content": outcome.model_content,
"error": outcome.error,
"image_count": outcome.images().map(|images| images.len()).unwrap_or(0),
"duration_secs": outcome.duration_secs,
"metadata": outcome.metadata,
"artifacts": outcome.artifacts,
})
}
fn unsupported(reason: &str) -> serde_json::Value {
serde_json::json!({
"recordable": false,
"reason": reason,
})
}
pub struct Replay {
lines: std::io::Lines<BufReader<File>>,
path: PathBuf,
}
impl Replay {
pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
let file =
File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
Ok(Self {
lines: BufReader::new(file).lines(),
path,
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Iterator for Replay {
type Item = Result<ReplayEntry>;
fn next(&mut self) -> Option<Self::Item> {
let line = self.lines.next()?;
Some(match line {
Ok(raw) => serde_json::from_str::<ReplayEntry>(&raw)
.with_context(|| format!("parse replay line: {}", raw)),
Err(e) => Err(anyhow::Error::from(e)),
})
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ReplayEntry {
pub ts: String,
pub kind: String,
pub turn: Option<u64>,
pub body: serde_json::Value,
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpfile(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join("mermaid_recorder_tests");
let _ = std::fs::create_dir_all(&dir);
dir.join(name)
}
#[test]
fn record_and_replay_roundtrip() {
let path = tmpfile("roundtrip.jsonl");
let _ = std::fs::remove_file(&path);
{
let mut r = Recorder::open(&path).expect("open");
r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
.expect("record");
r.record_kind(
MsgKind::SubmitPrompt,
None,
serde_json::json!({"text": "hello"}),
)
.expect("record");
r.record_kind(
MsgKind::StreamText,
Some(TurnId(7)),
serde_json::json!({"chunk": "partial"}),
)
.expect("record");
r.flush().expect("flush");
}
let replay = Replay::open(&path).expect("open replay");
let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].kind, "Tick");
assert_eq!(entries[1].body["text"], "hello");
assert_eq!(entries[2].turn, Some(7));
let _ = std::fs::remove_file(&path);
}
#[test]
fn replay_parses_malformed_line_as_err() {
let path = tmpfile("bad.jsonl");
std::fs::write(&path, "not-json\n").expect("write");
let mut replay = Replay::open(&path).expect("open");
let first = replay.next().expect("first entry");
assert!(first.is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn record_creates_file_on_open() {
let path = tmpfile("creates.jsonl");
let _ = std::fs::remove_file(&path);
assert!(!path.exists());
let _ = Recorder::open(&path).expect("open");
assert!(path.exists());
let _ = std::fs::remove_file(&path);
}
#[test]
fn record_append_preserves_existing_lines() {
let path = tmpfile("append.jsonl");
let _ = std::fs::remove_file(&path);
{
let mut r = Recorder::open(&path).expect("open");
r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
.expect("record");
}
{
let mut r = Recorder::open(&path).expect("reopen");
r.record_kind(MsgKind::Quit, None, serde_json::json!({}))
.expect("record");
}
let replay = Replay::open(&path).expect("replay");
let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].kind, "Tick");
assert_eq!(entries[1].kind, "Quit");
let _ = std::fs::remove_file(&path);
}
#[test]
fn record_msg_body_submit_prompt_keeps_text_and_attachments() {
let body = record_msg_body(&crate::domain::Msg::SubmitPrompt {
text: "explain main.rs".to_string(),
attachment_ids: vec![3, 9],
});
assert_eq!(body["text"], "explain main.rs");
assert_eq!(body["attachment_ids"][0], 3);
assert_eq!(body["attachment_ids"][1], 9);
}
#[test]
fn record_msg_body_slash_model_keeps_command_and_arg() {
let body = record_msg_body(&crate::domain::Msg::Slash(crate::domain::SlashCmd::Model(
Some("anthropic/opus".to_string()),
)));
assert_eq!(body["command"], "model");
assert_eq!(body["arg"], "anthropic/opus");
}
#[test]
fn record_msg_body_runtime_signal_keeps_signal_name() {
let body = record_msg_body(&crate::domain::Msg::RuntimeSignal(
crate::domain::RuntimeSignal::Terminate,
));
assert_eq!(body["signal"], "terminate");
}
#[test]
fn record_msg_body_marks_binary_paste_image_unrecordable() {
let body = record_msg_body(&crate::domain::Msg::Paste(crate::domain::Paste::Image {
bytes: vec![1, 2, 3],
format: "png".to_string(),
}));
assert_eq!(body["recordable"], false);
assert_eq!(body["type"], "image");
assert_eq!(body["size_bytes"], 3);
}
}