1use std::path::Path;
21
22use anyhow::Result;
23use chrono::{DateTime, Local};
24
25use crate::app::recorder::{RecordLine, Replay, SessionHeader, session_fingerprint};
26use crate::domain::{Msg, State, update};
27use crate::models::MessageRole;
28
29pub struct ReplayReport {
31 pub header: SessionHeader,
32 pub total_entries: usize,
34 pub applied: usize,
36 pub skipped: Vec<(usize, String)>,
40 pub stopped_at: Option<usize>,
43 pub not_applied_after_stop: usize,
45 pub second_session_at: Option<usize>,
48 pub deterministic: bool,
51 pub live_fingerprint: Option<String>,
54 pub matches_live: Option<bool>,
59 pub state: State,
61}
62
63pub fn replay_recording(path: &Path) -> Result<ReplayReport> {
66 let (header, lines) = Replay::open(path)?;
67
68 let mut msgs: Vec<(usize, DateTime<Local>, Msg)> = Vec::new();
71 let mut skipped = Vec::new();
72 let mut second_session_at = None;
73 let mut trailer = None;
74 let mut line_no = 1usize; for line in lines {
76 line_no += 1;
77 match line? {
78 RecordLine::Entry(entry) => match entry.to_msg() {
79 Ok(msg) => msgs.push((line_no, entry.ts, msg)),
80 Err(err) => skipped.push((line_no, format!("{err:#}"))),
81 },
82 RecordLine::Trailer(t) => {
83 trailer.get_or_insert(t);
87 },
88 RecordLine::Header(_) => {
89 second_session_at = Some(line_no);
92 break;
93 },
94 RecordLine::Malformed { error, .. } => {
95 skipped.push((line_no, format!("malformed line: {error}")));
96 },
97 }
98 }
99 let total_entries = msgs.len() + skipped.len();
100
101 let (state, stopped_at) = fold(&header, &msgs);
102 let (second, _) = fold(&header, &msgs);
103 let deterministic = format!("{state:?}") == format!("{second:?}");
104
105 let live_fingerprint = trailer.map(|t| t.final_session_fingerprint);
108 let matches_live = live_fingerprint
109 .as_ref()
110 .map(|live| *live == session_fingerprint(&state.session));
111
112 let not_applied_after_stop = match stopped_at {
113 Some(stop) => msgs.iter().filter(|(line, ..)| *line > stop).count(),
114 None => 0,
115 };
116 let applied = msgs.len() - not_applied_after_stop;
117
118 Ok(ReplayReport {
119 header,
120 total_entries,
121 applied,
122 skipped,
123 stopped_at,
124 not_applied_after_stop,
125 second_session_at,
126 deterministic,
127 live_fingerprint,
128 matches_live,
129 state,
130 })
131}
132
133fn fold(header: &SessionHeader, msgs: &[(usize, DateTime<Local>, Msg)]) -> (State, Option<usize>) {
138 let mut state = State::new(
139 header.config.clone(),
140 header.cwd.clone(),
141 header.model_id.clone(),
142 header.ts,
143 );
144 if let Some(seed) = header.seed_conversation.clone() {
145 state.seed_conversation(seed);
146 }
147 let mut stopped_at = None;
148 for (line, ts, msg) in msgs {
149 state.now = *ts;
150 let (next, _cmds) = update(state, msg.clone());
151 state = next;
152 if state.should_exit {
153 stopped_at = Some(*line);
154 break;
155 }
156 }
157 (state, stopped_at)
158}
159
160pub fn run_replay(path: &Path) -> Result<bool> {
164 let report = replay_recording(path)?;
165 print!("{}", render_report(path, &report));
166 Ok(report.deterministic)
167}
168
169fn render_report(path: &Path, report: &ReplayReport) -> String {
171 use std::fmt::Write as _;
172
173 let mut out = String::new();
174 let h = &report.header;
175 let _ = writeln!(out, "replay: {}", path.display());
176 let _ = writeln!(
177 out,
178 " recorded: {} · model: {} · cwd: {}",
179 h.ts.to_rfc3339(),
180 h.model_id,
181 h.cwd.display()
182 );
183 match &h.seed_conversation {
184 Some(seed) => {
185 let _ = writeln!(
186 out,
187 " seed: continued from {} ({} messages)",
188 seed.id,
189 seed.messages().len()
190 );
191 },
192 None => {
193 let _ = writeln!(out, " seed: fresh session");
194 },
195 }
196 let _ = writeln!(
197 out,
198 " entries: {} total · {} applied · {} skipped",
199 report.total_entries,
200 report.applied,
201 report.skipped.len()
202 );
203 for (line, reason) in &report.skipped {
204 let _ = writeln!(out, " line {line}: {reason}");
205 }
206 if let Some(stop) = report.stopped_at {
207 let _ = writeln!(
208 out,
209 " note: session quit at line {stop}; {} later entries not applied",
210 report.not_applied_after_stop
211 );
212 }
213 if let Some(line) = report.second_session_at {
214 let _ = writeln!(
215 out,
216 " note: a second appended session starts at line {line} — replayed the first only"
217 );
218 }
219 let _ = writeln!(
220 out,
221 " determinism: {}",
222 if report.deterministic {
223 "PASS — folding the log twice produced identical state"
224 } else {
225 "FAIL — two folds of the same log diverged (reducer purity bug)"
226 }
227 );
228 let _ = writeln!(
229 out,
230 " live match: {}",
231 match report.matches_live {
232 Some(true) => "yes — the fold reproduces the live session's recorded outcome",
233 Some(false) =>
234 "no — the fold differs from the live session (expected if redaction \
235 fired mid-session or the reducer changed since recording)",
236 None => "unknown — recording has no clean-exit fingerprint",
237 }
238 );
239
240 let messages = report.state.session.messages();
241 let _ = writeln!(out);
242 let _ = writeln!(
243 out,
244 "transcript: {} — {} messages",
245 report.state.session.conversation.title,
246 messages.len()
247 );
248 for msg in messages {
249 let role = match msg.role {
250 MessageRole::User => "user",
251 MessageRole::Assistant => "assistant",
252 MessageRole::System => "system",
253 MessageRole::Tool => "tool",
254 };
255 let mut lines = msg.content.lines();
258 let first = lines.next().unwrap_or_default();
259 let _ = writeln!(out, " [{role}] {first}");
260 for line in lines {
261 let _ = writeln!(out, " {line}");
262 }
263 if let Some(calls) = &msg.tool_calls
264 && !calls.is_empty()
265 {
266 let names: Vec<&str> = calls.iter().map(|c| c.function.name.as_str()).collect();
267 let _ = writeln!(
268 out,
269 " ({} tool calls: {})",
270 calls.len(),
271 names.join(", ")
272 );
273 }
274 }
275 out
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::app::Config;
282 use crate::app::recorder::{RECORDING_FORMAT_VERSION, Recorder};
283 use crate::domain::TurnId;
284 use crate::models::{FinishReason, TokenUsage};
285 use std::path::PathBuf;
286
287 fn tmpfile(name: &str) -> PathBuf {
288 let dir = std::env::temp_dir().join("mermaid_replay_tests");
289 let _ = std::fs::create_dir_all(&dir);
290 dir.join(name)
291 }
292
293 fn fixed_ts(offset_secs: i64) -> DateTime<Local> {
294 chrono::DateTime::parse_from_rfc3339("2026-07-02T12:00:00.500+00:00")
295 .unwrap()
296 .with_timezone(&Local)
297 + chrono::Duration::seconds(offset_secs)
298 }
299
300 fn header(ts: DateTime<Local>) -> SessionHeader {
301 SessionHeader {
302 format: RECORDING_FORMAT_VERSION,
303 ts,
304 model_id: "ollama/test".to_string(),
305 cwd: PathBuf::from("/tmp/replay-project"),
306 config: Config::default(),
307 seed_conversation: None,
308 }
309 }
310
311 fn record_session(path: &PathBuf) {
314 let _ = std::fs::remove_file(path);
315 let h = header(fixed_ts(0));
316 let mut r = Recorder::open(path).expect("open recorder");
317 r.record_header(&h).expect("header");
318 let mut state = State::new(h.config.clone(), h.cwd.clone(), h.model_id.clone(), h.ts);
321 let script: Vec<(i64, Msg)> = vec![
322 (
323 1,
324 Msg::SubmitPrompt {
325 text: "hello there".to_string(),
326 attachment_ids: Vec::new(),
327 },
328 ),
329 (
330 2,
331 Msg::StreamText {
332 turn: TurnId(1),
333 chunk: "Hi — ".to_string(),
334 },
335 ),
336 (
337 3,
338 Msg::StreamText {
339 turn: TurnId(1),
340 chunk: "hello!".to_string(),
341 },
342 ),
343 (
344 4,
345 Msg::StreamDone {
346 turn: TurnId(1),
347 usage: Some(TokenUsage::provider(10, 5)),
348 provider_continuation: None,
349 stop_reason: Some(FinishReason::Stop),
350 },
351 ),
352 (5, Msg::Quit),
353 ];
354 for (offset, msg) in script {
355 let now = fixed_ts(offset);
356 r.record_msg(now, &msg).expect("record");
357 state.now = now;
358 let (next, _cmds) = update(state, msg);
359 state = next;
360 }
361 r.record_trailer(fixed_ts(9), &state.session)
362 .expect("trailer");
363 r.flush().expect("flush");
364 }
365
366 #[test]
367 fn replay_reconstructs_a_recorded_session_deterministically() {
368 let path = tmpfile("session.jsonl");
369 record_session(&path);
370
371 let report = replay_recording(&path).expect("replay");
372 assert!(
373 report.deterministic,
374 "double fold must produce identical state"
375 );
376 assert_eq!(report.total_entries, 5);
377 assert_eq!(report.applied, 5, "skipped: {:?}", report.skipped);
378 assert!(report.skipped.is_empty());
379 assert_eq!(report.stopped_at, Some(6), "Quit is line 6 of the file");
380 assert!(report.state.should_exit);
381
382 let messages = report.state.session.messages();
385 let user = messages
386 .iter()
387 .find(|m| m.role == MessageRole::User)
388 .expect("user message");
389 assert_eq!(user.content, "hello there");
390 let assistant = messages
391 .iter()
392 .find(|m| m.role == MessageRole::Assistant)
393 .expect("assistant message");
394 assert_eq!(assistant.content, "Hi — hello!");
395
396 let expected_id = format!("{}", fixed_ts(0).format("%Y%m%d_%H%M%S_%3f"));
399 assert_eq!(report.state.session.conversation.id, expected_id);
400
401 assert_eq!(user.timestamp, fixed_ts(1));
403
404 let _ = std::fs::remove_file(&path);
405 }
406
407 #[test]
408 fn replay_report_renders_transcript_and_verdict() {
409 let path = tmpfile("render.jsonl");
410 record_session(&path);
411 let report = replay_recording(&path).expect("replay");
412 let text = render_report(&path, &report);
413 assert!(text.contains("determinism: PASS"));
414 assert!(text.contains("live match: yes"));
415 assert!(text.contains("[user] hello there"));
416 assert!(text.contains("[assistant] Hi — hello!"));
417 assert!(text.contains("5 applied"));
418 let _ = std::fs::remove_file(&path);
419 }
420
421 #[test]
422 fn replay_verifies_against_the_live_fingerprint() {
423 let path = tmpfile("livematch.jsonl");
424 record_session(&path);
425 let report = replay_recording(&path).expect("replay");
426 assert_eq!(
427 report.matches_live,
428 Some(true),
429 "a faithful fold must match the live session's seal"
430 );
431 assert!(
432 report
433 .live_fingerprint
434 .as_deref()
435 .expect("trailer present")
436 .starts_with("sha256:")
437 );
438 let _ = std::fs::remove_file(&path);
439 }
440
441 #[test]
442 fn tampered_recording_fails_live_match_but_stays_deterministic() {
443 let path = tmpfile("tamper.jsonl");
444 record_session(&path);
445 let raw = std::fs::read_to_string(&path).unwrap();
449 assert!(raw.contains("hello!"));
450 std::fs::write(&path, raw.replace("hello!", "goodbye")).unwrap();
451
452 let report = replay_recording(&path).expect("replay");
453 assert_eq!(
454 report.matches_live,
455 Some(false),
456 "an altered log must not match the live fingerprint"
457 );
458 assert!(
459 report.deterministic,
460 "tampering must not affect fold self-consistency"
461 );
462 let _ = std::fs::remove_file(&path);
463 }
464
465 #[test]
466 fn recording_without_trailer_reports_unknown_live_match() {
467 let path = tmpfile("crash.jsonl");
469 record_session(&path);
470 let raw = std::fs::read_to_string(&path).unwrap();
471 let without_trailer: String = raw
472 .lines()
473 .filter(|l| !l.contains("final_session_fingerprint"))
474 .collect::<Vec<_>>()
475 .join("\n")
476 + "\n";
477 std::fs::write(&path, without_trailer).unwrap();
478
479 let report = replay_recording(&path).expect("replay");
480 assert_eq!(report.matches_live, None);
481 assert!(report.live_fingerprint.is_none());
482 assert!(render_report(&path, &report).contains("live match: unknown"));
483 let _ = std::fs::remove_file(&path);
484 }
485
486 #[test]
487 fn replay_stops_at_second_appended_session() {
488 let path = tmpfile("two.jsonl");
489 record_session(&path);
490 {
492 let mut r = Recorder::open(&path).expect("reopen");
493 r.record_header(&header(fixed_ts(100))).expect("header2");
494 r.record_msg(fixed_ts(101), &Msg::SessionSaved)
495 .expect("record");
496 }
497 let report = replay_recording(&path).expect("replay");
498 assert_eq!(report.second_session_at, Some(8));
500 assert_eq!(report.total_entries, 5, "second session must not count");
501 assert_eq!(
502 report.matches_live,
503 Some(true),
504 "the first session's trailer still verifies"
505 );
506 assert!(report.deterministic);
507 let _ = std::fs::remove_file(&path);
508 }
509
510 #[test]
511 fn replay_skips_unknown_msg_variants_with_line_numbers() {
512 let path = tmpfile("unknown.jsonl");
513 record_session(&path);
514 let raw = std::fs::read_to_string(&path).unwrap();
517 let mut lines: Vec<&str> = raw.lines().collect();
518 let future = r#"{"ts":"2026-07-02T12:00:04.700+00:00","kind":"HoloDeck","turn":null,"msg":{"HoloDeck":{"program":"bridge"}}}"#;
519 lines.insert(5, future);
520 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
521
522 let report = replay_recording(&path).expect("replay");
523 assert_eq!(report.skipped.len(), 1);
524 assert_eq!(report.skipped[0].0, 6, "inserted at file line 6");
525 assert!(report.skipped[0].1.contains("HoloDeck"));
526 assert!(report.deterministic);
527 assert_eq!(report.applied, 5);
529 let _ = std::fs::remove_file(&path);
530 }
531
532 #[test]
537 #[ignore = "writes a fixture for manual --replay QA"]
538 fn write_replay_fixture() {
539 let path = std::env::var("MERMAID_REPLAY_FIXTURE")
540 .unwrap_or_else(|_| "/tmp/mermaid-replay-fixture.jsonl".to_string());
541 let path = PathBuf::from(path);
542 record_session(&path);
543 eprintln!("fixture written: {}", path.display());
544 }
545
546 #[test]
547 fn same_log_folded_under_different_wall_clock_is_identical() {
548 let path = tmpfile("stable.jsonl");
552 record_session(&path);
553 let a = replay_recording(&path).expect("first");
554 std::thread::sleep(std::time::Duration::from_millis(25));
555 let b = replay_recording(&path).expect("second");
556 assert_eq!(
557 format!("{:?}", a.state),
558 format!("{:?}", b.state),
559 "replay must not depend on the machine's wall clock"
560 );
561 let _ = std::fs::remove_file(&path);
562 }
563}