use std::path::Path;
use anyhow::Result;
use chrono::{DateTime, Local};
use crate::app::recorder::{RecordLine, Replay, SessionHeader, session_fingerprint};
use crate::domain::{Msg, State, update};
use crate::models::MessageRole;
pub struct ReplayReport {
pub header: SessionHeader,
pub total_entries: usize,
pub applied: usize,
pub skipped: Vec<(usize, String)>,
pub stopped_at: Option<usize>,
pub not_applied_after_stop: usize,
pub second_session_at: Option<usize>,
pub deterministic: bool,
pub live_fingerprint: Option<String>,
pub matches_live: Option<bool>,
pub state: State,
}
pub fn replay_recording(path: &Path) -> Result<ReplayReport> {
let (header, lines) = Replay::open(path)?;
let mut msgs: Vec<(usize, DateTime<Local>, Msg)> = Vec::new();
let mut skipped = Vec::new();
let mut second_session_at = None;
let mut trailer = None;
let mut line_no = 1usize; for line in lines {
line_no += 1;
match line? {
RecordLine::Entry(entry) => match entry.to_msg() {
Ok(msg) => msgs.push((line_no, entry.ts, msg)),
Err(err) => skipped.push((line_no, format!("{err:#}"))),
},
RecordLine::Trailer(t) => {
trailer.get_or_insert(t);
},
RecordLine::Header(_) => {
second_session_at = Some(line_no);
break;
},
RecordLine::Malformed { error, .. } => {
skipped.push((line_no, format!("malformed line: {error}")));
},
}
}
let total_entries = msgs.len() + skipped.len();
let (state, stopped_at) = fold(&header, &msgs);
let (second, _) = fold(&header, &msgs);
let deterministic = format!("{state:?}") == format!("{second:?}");
let live_fingerprint = trailer.map(|t| t.final_session_fingerprint);
let matches_live = live_fingerprint
.as_ref()
.map(|live| *live == session_fingerprint(&state.session));
let not_applied_after_stop = match stopped_at {
Some(stop) => msgs.iter().filter(|(line, ..)| *line > stop).count(),
None => 0,
};
let applied = msgs.len() - not_applied_after_stop;
Ok(ReplayReport {
header,
total_entries,
applied,
skipped,
stopped_at,
not_applied_after_stop,
second_session_at,
deterministic,
live_fingerprint,
matches_live,
state,
})
}
fn fold(header: &SessionHeader, msgs: &[(usize, DateTime<Local>, Msg)]) -> (State, Option<usize>) {
let mut state = State::new(
header.config.clone(),
header.cwd.clone(),
header.model_id.clone(),
header.ts,
);
if let Some(seed) = header.seed_conversation.clone() {
state.seed_conversation(seed);
}
let mut stopped_at = None;
for (line, ts, msg) in msgs {
state.now = *ts;
let (next, _cmds) = update(state, msg.clone());
state = next;
if state.should_exit {
stopped_at = Some(*line);
break;
}
}
(state, stopped_at)
}
pub fn run_replay(path: &Path) -> Result<bool> {
let report = replay_recording(path)?;
print!("{}", render_report(path, &report));
Ok(report.deterministic)
}
fn render_report(path: &Path, report: &ReplayReport) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let h = &report.header;
let _ = writeln!(out, "replay: {}", path.display());
let _ = writeln!(
out,
" recorded: {} · model: {} · cwd: {}",
h.ts.to_rfc3339(),
h.model_id,
h.cwd.display()
);
match &h.seed_conversation {
Some(seed) => {
let _ = writeln!(
out,
" seed: continued from {} ({} messages)",
seed.id,
seed.messages.len()
);
},
None => {
let _ = writeln!(out, " seed: fresh session");
},
}
let _ = writeln!(
out,
" entries: {} total · {} applied · {} skipped",
report.total_entries,
report.applied,
report.skipped.len()
);
for (line, reason) in &report.skipped {
let _ = writeln!(out, " line {line}: {reason}");
}
if let Some(stop) = report.stopped_at {
let _ = writeln!(
out,
" note: session quit at line {stop}; {} later entries not applied",
report.not_applied_after_stop
);
}
if let Some(line) = report.second_session_at {
let _ = writeln!(
out,
" note: a second appended session starts at line {line} — replayed the first only"
);
}
let _ = writeln!(
out,
" determinism: {}",
if report.deterministic {
"PASS — folding the log twice produced identical state"
} else {
"FAIL — two folds of the same log diverged (reducer purity bug)"
}
);
let _ = writeln!(
out,
" live match: {}",
match report.matches_live {
Some(true) => "yes — the fold reproduces the live session's recorded outcome",
Some(false) =>
"no — the fold differs from the live session (expected if redaction \
fired mid-session or the reducer changed since recording)",
None => "unknown — recording has no clean-exit fingerprint",
}
);
let messages = report.state.session.messages();
let _ = writeln!(out);
let _ = writeln!(
out,
"transcript: {} — {} messages",
report.state.session.conversation.title,
messages.len()
);
for msg in messages {
let role = match msg.role {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::System => "system",
MessageRole::Tool => "tool",
};
let mut lines = msg.content.lines();
let first = lines.next().unwrap_or_default();
let _ = writeln!(out, " [{role}] {first}");
for line in lines {
let _ = writeln!(out, " {line}");
}
if let Some(calls) = &msg.tool_calls
&& !calls.is_empty()
{
let names: Vec<&str> = calls.iter().map(|c| c.function.name.as_str()).collect();
let _ = writeln!(
out,
" ({} tool calls: {})",
calls.len(),
names.join(", ")
);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::Config;
use crate::app::recorder::{RECORDING_FORMAT_VERSION, Recorder};
use crate::domain::TurnId;
use crate::models::{FinishReason, TokenUsage};
use std::path::PathBuf;
fn tmpfile(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join("mermaid_replay_tests");
let _ = std::fs::create_dir_all(&dir);
dir.join(name)
}
fn fixed_ts(offset_secs: i64) -> DateTime<Local> {
chrono::DateTime::parse_from_rfc3339("2026-07-02T12:00:00.500+00:00")
.unwrap()
.with_timezone(&Local)
+ chrono::Duration::seconds(offset_secs)
}
fn header(ts: DateTime<Local>) -> SessionHeader {
SessionHeader {
format: RECORDING_FORMAT_VERSION,
ts,
model_id: "ollama/test".to_string(),
cwd: PathBuf::from("/tmp/replay-project"),
config: Config::default(),
seed_conversation: None,
}
}
fn record_session(path: &PathBuf) {
let _ = std::fs::remove_file(path);
let h = header(fixed_ts(0));
let mut r = Recorder::open(path).expect("open recorder");
r.record_header(&h).expect("header");
let mut state = State::new(h.config.clone(), h.cwd.clone(), h.model_id.clone(), h.ts);
let script: Vec<(i64, Msg)> = vec![
(
1,
Msg::SubmitPrompt {
text: "hello there".to_string(),
attachment_ids: Vec::new(),
},
),
(
2,
Msg::StreamText {
turn: TurnId(1),
chunk: "Hi — ".to_string(),
},
),
(
3,
Msg::StreamText {
turn: TurnId(1),
chunk: "hello!".to_string(),
},
),
(
4,
Msg::StreamDone {
turn: TurnId(1),
usage: Some(TokenUsage::provider(10, 5)),
provider_continuation: None,
stop_reason: Some(FinishReason::Stop),
},
),
(5, Msg::Quit),
];
for (offset, msg) in script {
let now = fixed_ts(offset);
r.record_msg(now, &msg).expect("record");
state.now = now;
let (next, _cmds) = update(state, msg);
state = next;
}
r.record_trailer(fixed_ts(9), &state.session)
.expect("trailer");
r.flush().expect("flush");
}
#[test]
fn replay_reconstructs_a_recorded_session_deterministically() {
let path = tmpfile("session.jsonl");
record_session(&path);
let report = replay_recording(&path).expect("replay");
assert!(
report.deterministic,
"double fold must produce identical state"
);
assert_eq!(report.total_entries, 5);
assert_eq!(report.applied, 5, "skipped: {:?}", report.skipped);
assert!(report.skipped.is_empty());
assert_eq!(report.stopped_at, Some(6), "Quit is line 6 of the file");
assert!(report.state.should_exit);
let messages = report.state.session.messages();
let user = messages
.iter()
.find(|m| m.role == MessageRole::User)
.expect("user message");
assert_eq!(user.content, "hello there");
let assistant = messages
.iter()
.find(|m| m.role == MessageRole::Assistant)
.expect("assistant message");
assert_eq!(assistant.content, "Hi — hello!");
let expected_id = format!("{}", fixed_ts(0).format("%Y%m%d_%H%M%S_%3f"));
assert_eq!(report.state.session.conversation.id, expected_id);
assert_eq!(user.timestamp, fixed_ts(1));
let _ = std::fs::remove_file(&path);
}
#[test]
fn replay_report_renders_transcript_and_verdict() {
let path = tmpfile("render.jsonl");
record_session(&path);
let report = replay_recording(&path).expect("replay");
let text = render_report(&path, &report);
assert!(text.contains("determinism: PASS"));
assert!(text.contains("live match: yes"));
assert!(text.contains("[user] hello there"));
assert!(text.contains("[assistant] Hi — hello!"));
assert!(text.contains("5 applied"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn replay_verifies_against_the_live_fingerprint() {
let path = tmpfile("livematch.jsonl");
record_session(&path);
let report = replay_recording(&path).expect("replay");
assert_eq!(
report.matches_live,
Some(true),
"a faithful fold must match the live session's seal"
);
assert!(
report
.live_fingerprint
.as_deref()
.expect("trailer present")
.starts_with("sha256:")
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn tampered_recording_fails_live_match_but_stays_deterministic() {
let path = tmpfile("tamper.jsonl");
record_session(&path);
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("hello!"));
std::fs::write(&path, raw.replace("hello!", "goodbye")).unwrap();
let report = replay_recording(&path).expect("replay");
assert_eq!(
report.matches_live,
Some(false),
"an altered log must not match the live fingerprint"
);
assert!(
report.deterministic,
"tampering must not affect fold self-consistency"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn recording_without_trailer_reports_unknown_live_match() {
let path = tmpfile("crash.jsonl");
record_session(&path);
let raw = std::fs::read_to_string(&path).unwrap();
let without_trailer: String = raw
.lines()
.filter(|l| !l.contains("final_session_fingerprint"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
std::fs::write(&path, without_trailer).unwrap();
let report = replay_recording(&path).expect("replay");
assert_eq!(report.matches_live, None);
assert!(report.live_fingerprint.is_none());
assert!(render_report(&path, &report).contains("live match: unknown"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn replay_stops_at_second_appended_session() {
let path = tmpfile("two.jsonl");
record_session(&path);
{
let mut r = Recorder::open(&path).expect("reopen");
r.record_header(&header(fixed_ts(100))).expect("header2");
r.record_msg(fixed_ts(101), &Msg::SessionSaved)
.expect("record");
}
let report = replay_recording(&path).expect("replay");
assert_eq!(report.second_session_at, Some(8));
assert_eq!(report.total_entries, 5, "second session must not count");
assert_eq!(
report.matches_live,
Some(true),
"the first session's trailer still verifies"
);
assert!(report.deterministic);
let _ = std::fs::remove_file(&path);
}
#[test]
fn replay_skips_unknown_msg_variants_with_line_numbers() {
let path = tmpfile("unknown.jsonl");
record_session(&path);
let raw = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<&str> = raw.lines().collect();
let future = r#"{"ts":"2026-07-02T12:00:04.700+00:00","kind":"HoloDeck","turn":null,"msg":{"HoloDeck":{"program":"bridge"}}}"#;
lines.insert(5, future);
std::fs::write(&path, lines.join("\n") + "\n").unwrap();
let report = replay_recording(&path).expect("replay");
assert_eq!(report.skipped.len(), 1);
assert_eq!(report.skipped[0].0, 6, "inserted at file line 6");
assert!(report.skipped[0].1.contains("HoloDeck"));
assert!(report.deterministic);
assert_eq!(report.applied, 5);
let _ = std::fs::remove_file(&path);
}
#[test]
#[ignore = "writes a fixture for manual --replay QA"]
fn write_replay_fixture() {
let path = std::env::var("MERMAID_REPLAY_FIXTURE")
.unwrap_or_else(|_| "/tmp/mermaid-replay-fixture.jsonl".to_string());
let path = PathBuf::from(path);
record_session(&path);
eprintln!("fixture written: {}", path.display());
}
#[test]
fn same_log_folded_under_different_wall_clock_is_identical() {
let path = tmpfile("stable.jsonl");
record_session(&path);
let a = replay_recording(&path).expect("first");
std::thread::sleep(std::time::Duration::from_millis(25));
let b = replay_recording(&path).expect("second");
assert_eq!(
format!("{:?}", a.state),
format!("{:?}", b.state),
"replay must not depend on the machine's wall clock"
);
let _ = std::fs::remove_file(&path);
}
}