Skip to main content

mermaid_cli/app/
recorder.rs

1//! `--record` / `--replay` support.
2//!
3//! The Elm/MVU architecture makes deterministic replay nearly free:
4//! if you capture every `Msg` the reducer sees, you can reconstruct
5//! the exact final `State` by folding over that log. This module
6//! implements both sides.
7//!
8//! Wire format: one JSON object per line (JSONL). Each object is
9//! `{ts, kind, body}`:
10//!   - `ts`: RFC3339 timestamp (for debugging, not replay).
11//!   - `kind`: `MsgKind` variant tag (matches `Msg::kind().into()`).
12//!   - `body`: best-effort structured payload from `record_msg_body`.
13//!
14//! Not every `Msg` field is safely serializable today — raw image
15//! bytes in `Paste::Image`, for example. Unsupported payloads are
16//! marked with `"recordable": false` and compact metadata. Replay is
17//! a best-effort reconstruction.
18//!
19//! For C6 this ships the on-disk shape + a `Recorder` type that
20//! writes; replay reading is available but opt-in (serialize
21//! support is wired on a subset of Msg variants that don't carry
22//! binary payloads). C9 rounds out coverage with the parity
23//! harness.
24
25use std::fs::{File, OpenOptions};
26use std::io::{BufRead, BufReader, BufWriter, Write};
27use std::path::{Path, PathBuf};
28
29use anyhow::{Context, Result};
30use chrono::Local;
31
32use crate::domain::{KeyCode, Msg, MsgKind, Paste, SlashCmd, ToolOutcome, TurnId};
33use crate::providers::{ProgressEvent, SubagentPhase};
34
35/// Append-only recorder. Writes one JSONL line per `Msg` the main
36/// loop chooses to log.
37pub struct Recorder {
38    writer: BufWriter<File>,
39    path: PathBuf,
40}
41
42impl Recorder {
43    /// Open `path` for append. Creates the file if it doesn't exist.
44    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
45        let path = path.into();
46        let mut opts = OpenOptions::new();
47        opts.create(true).append(true);
48        // A recording stores the full conversation — prompts, model output, and
49        // tool results (e.g. a `read_file` of a private doc) — in cleartext;
50        // only credential-shaped strings are scrubbed. Create it owner-only so a
51        // shared temp/cwd doesn't leak it (#132).
52        #[cfg(unix)]
53        {
54            use std::os::unix::fs::OpenOptionsExt;
55            opts.mode(0o600);
56        }
57        let file = opts
58            .open(&path)
59            .with_context(|| format!("open {} for recording", path.display()))?;
60        // `mode` only applies on create; tighten an existing recording too.
61        #[cfg(unix)]
62        {
63            use std::os::unix::fs::PermissionsExt;
64            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
65        }
66        tracing::warn!(
67            path = %path.display(),
68            "session recording is ON: this file stores prompts, model output, and \
69             tool results (including file contents) in cleartext; only \
70             credential-shaped strings are redacted",
71        );
72        Ok(Self {
73            writer: BufWriter::new(file),
74            path,
75        })
76    }
77
78    pub fn path(&self) -> &Path {
79        &self.path
80    }
81
82    /// Record a single `MsgKind` + optional body JSON. Meant for the
83    /// narrow subset of variants that survive round-trip. Full
84    /// Msg-graph coverage comes in C9.
85    pub fn record_kind(
86        &mut self,
87        kind: MsgKind,
88        turn: Option<TurnId>,
89        mut body: serde_json::Value,
90    ) -> Result<()> {
91        // Single redaction choke point: scrub credential-shaped strings out of
92        // every recorded payload before it hits disk. A `read_file .env` result,
93        // a pasted token, or an API error echoing a key would otherwise be
94        // persisted in cleartext in the `--record` log (#17).
95        crate::utils::redact_json(&mut body);
96        let entry = serde_json::json!({
97            "ts": Local::now().to_rfc3339(),
98            "kind": format!("{:?}", kind),
99            "turn": turn.map(|t| t.0),
100            "body": body,
101        });
102        writeln!(self.writer, "{}", entry).context("write jsonl line")?;
103        Ok(())
104    }
105
106    pub fn flush(&mut self) -> Result<()> {
107        self.writer.flush().context("flush recorder")
108    }
109}
110
111impl Drop for Recorder {
112    fn drop(&mut self) {
113        let _ = self.writer.flush();
114    }
115}
116
117/// Compact, best-effort JSON body for a reducer `Msg`.
118///
119/// This deliberately records useful payloads without claiming the full
120/// `Msg` graph can round-trip through serde today. Runtime-only or
121/// binary-heavy variants are marked explicitly so replay tooling can
122/// decide how to handle them.
123pub fn record_msg_body(msg: &Msg) -> serde_json::Value {
124    match msg {
125        Msg::Key(key) => serde_json::json!({
126            "code": key_code_body(key.code),
127            "modifiers": {
128                "ctrl": key.modifiers.ctrl,
129                "alt": key.modifiers.alt,
130                "shift": key.modifiers.shift,
131            },
132        }),
133        Msg::Paste(Paste::Text(text)) => serde_json::json!({
134            "type": "text",
135            "text": text,
136        }),
137        Msg::Paste(Paste::Image { bytes, format }) => serde_json::json!({
138            "recordable": false,
139            "reason": "binary paste image omitted",
140            "type": "image",
141            "format": format,
142            "size_bytes": bytes.len(),
143        }),
144        Msg::SubmitPrompt {
145            text,
146            attachment_ids,
147        } => serde_json::json!({
148            "text": text,
149            "attachment_ids": attachment_ids,
150        }),
151        Msg::Slash(cmd) => slash_body(cmd),
152        Msg::CancelTurn => serde_json::json!({}),
153        Msg::ConfirmAccepted => serde_json::json!({"accepted": true}),
154        Msg::ConfirmDeclined => serde_json::json!({"accepted": false}),
155        Msg::Quit => serde_json::json!({}),
156        Msg::RuntimeSignal(signal) => serde_json::json!({
157            "signal": signal.as_str(),
158        }),
159        Msg::StreamText { chunk, .. } => serde_json::json!({"chunk": chunk}),
160        Msg::StreamReasoning { chunk, .. } => serde_json::json!({
161            "text": chunk.text,
162            "signature": chunk.signature,
163        }),
164        Msg::StreamToolCall { call, .. } => serde_json::to_value(call)
165            .unwrap_or_else(|_| unsupported("tool call was not serializable")),
166        Msg::BuiltinToolSchemaTokens(tokens) => serde_json::json!({"tokens": tokens}),
167        Msg::ContextUsageEstimated { snapshot, .. } => serde_json::json!({
168            "used_tokens": snapshot.used_tokens,
169            "max_tokens": snapshot.max_tokens,
170            "remaining_tokens": snapshot.remaining_tokens,
171            "used_percent": snapshot.used_percent,
172            "source": format!("{:?}", snapshot.source),
173            "breakdown": snapshot.breakdown.as_ref().map(|b| serde_json::json!({
174                "system_tokens": b.system_tokens,
175                "instructions_tokens": b.instructions_tokens,
176                "message_tokens": b.message_tokens,
177                "tool_schema_tokens": b.tool_schema_tokens,
178                "image_count": b.image_count,
179                "message_count": b.message_count,
180                "tool_count": b.tool_count,
181            })),
182        }),
183        Msg::ProviderContextResolved {
184            model_max,
185            effective,
186            source,
187            ..
188        } => serde_json::json!({
189            "model_max": model_max,
190            "effective": effective,
191            "source": source.map(|s| s.label()),
192        }),
193        Msg::OllamaPlacementResolved {
194            model_id,
195            size_vram_bytes,
196            total_bytes,
197            suggested_num_ctx,
198        } => serde_json::json!({
199            "model_id": model_id,
200            "size_vram_bytes": size_vram_bytes,
201            "total_bytes": total_bytes,
202            "offloaded": size_vram_bytes < total_bytes,
203            "suggested_num_ctx": suggested_num_ctx,
204        }),
205        Msg::CompactionFinished { result, .. } => serde_json::json!({
206            "id": result.record.id,
207            "trigger": result.record.trigger.as_str(),
208            "before_tokens": result.record.before_tokens,
209            "after_tokens": result.record.after_tokens,
210            "archived_message_count": result.record.archived_message_count,
211            "preserved_message_count": result.record.preserved_message_count,
212            "duration_secs": result.record.duration_secs,
213        }),
214        Msg::CompactionFailed {
215            trigger,
216            message,
217            kind,
218            ..
219        } => serde_json::json!({
220            "trigger": trigger.as_str(),
221            "message": message,
222            "kind": format!("{:?}", kind),
223        }),
224        Msg::StreamDone {
225            usage,
226            thinking_signature,
227            ..
228        } => serde_json::json!({
229            "usage": usage.as_ref().map(|u| serde_json::json!({
230                "prompt_tokens": u.prompt_tokens,
231                "completion_tokens": u.completion_tokens,
232                "total_tokens": u.total_tokens,
233                "cached_input_tokens": u.cached_input_tokens,
234                "cache_creation_input_tokens": u.cache_creation_input_tokens,
235                "reasoning_output_tokens": u.reasoning_output_tokens,
236                "source": format!("{:?}", u.source),
237            })),
238            "thinking_signature": thinking_signature,
239        }),
240        Msg::UpstreamError { error, .. } => serde_json::to_value(error)
241            .unwrap_or_else(|_| unsupported("upstream error was not serializable")),
242        Msg::TurnCancelled(_) => serde_json::json!({}),
243        Msg::ToolStarted { call_id, .. } => serde_json::json!({"call_id": call_id.0}),
244        Msg::ToolProgress { call_id, event, .. } => serde_json::json!({
245            "call_id": call_id.0,
246            "event": progress_body(event),
247        }),
248        Msg::ToolFinished {
249            call_id, outcome, ..
250        } => serde_json::json!({
251            "call_id": call_id.0,
252            "outcome": outcome_body(outcome),
253        }),
254        Msg::ApprovalRequested {
255            call_id,
256            tool,
257            risk,
258            ..
259        } => serde_json::json!({
260            "call_id": call_id.0,
261            "tool": tool,
262            "risk": risk,
263        }),
264        Msg::McpServerReady { name, tools } => serde_json::json!({
265            "name": name,
266            "tools": tools.iter().map(|tool| serde_json::json!({
267                "name": tool.name,
268                "description": tool.description,
269                "input_schema": tool.input_schema,
270            })).collect::<Vec<_>>(),
271        }),
272        Msg::McpServerErrored { name, reason } => serde_json::json!({
273            "name": name,
274            "reason": reason,
275        }),
276        Msg::McpServerStopped { name } => serde_json::json!({"name": name}),
277        Msg::InstructionsChanged(loaded) => match loaded {
278            Some(loaded) => serde_json::json!({
279                "path": loaded.path,
280                "byte_len": loaded.byte_len,
281                "truncated": loaded.truncated,
282            }),
283            None => serde_json::json!({"path": null}),
284        },
285        Msg::MemoryChanged(loaded) => match loaded {
286            Some(loaded) => serde_json::json!({
287                "entries": loaded.entries.len(),
288                "truncated": loaded.truncated,
289            }),
290            None => serde_json::json!({"entries": 0}),
291        },
292        Msg::SessionSaved => serde_json::json!({}),
293        Msg::ConversationLoaded(history) => serde_json::json!({
294            "id": history.id,
295            "message_count": history.messages.len(),
296            "title": history.title,
297        }),
298        Msg::ConversationsListed(summaries) => serde_json::json!({
299            "count": summaries.len(),
300            "ids": summaries.iter().map(|summary| summary.id.as_str()).collect::<Vec<_>>(),
301        }),
302        Msg::RuntimeTasksListed(tasks) => serde_json::json!({
303            "count": tasks.len(),
304            "ids": tasks.iter().map(|task| task.id.as_str()).collect::<Vec<_>>(),
305        }),
306        Msg::RuntimeTaskLoaded { task, events } => serde_json::json!({
307            "id": task.as_ref().map(|task| task.id.as_str()),
308            "found": task.is_some(),
309            "event_count": events.len(),
310        }),
311        Msg::RuntimeProcessesListed(processes) => serde_json::json!({
312            "count": processes.len(),
313            "ids": processes.iter().map(|process| process.id.as_str()).collect::<Vec<_>>(),
314        }),
315        Msg::RuntimeText(text) => serde_json::json!({"chars": text.len()}),
316        Msg::RuntimeApprovalsListed(approvals) => serde_json::json!({
317            "count": approvals.len(),
318            "ids": approvals.iter().map(|approval| approval.id.as_str()).collect::<Vec<_>>(),
319        }),
320        Msg::RuntimeCheckpointsListed(checkpoints) => serde_json::json!({
321            "count": checkpoints.len(),
322            "ids": checkpoints.iter().map(|checkpoint| checkpoint.id.as_str()).collect::<Vec<_>>(),
323        }),
324        Msg::RuntimePluginsListed(plugins) => serde_json::json!({
325            "count": plugins.len(),
326            "ids": plugins.iter().map(|plugin| plugin.id.as_str()).collect::<Vec<_>>(),
327        }),
328        Msg::ModelPullFinished { model } => serde_json::json!({"model": model}),
329        Msg::ModelPullProgress(line) => serde_json::json!({"line": line}),
330        Msg::Tick => serde_json::json!({}),
331        Msg::StatusDismiss => serde_json::json!({}),
332        Msg::Resize { width, height } => serde_json::json!({
333            "width": width,
334            "height": height,
335        }),
336        Msg::TransientStatus {
337            text,
338            kind,
339            dismiss_ms,
340        } => serde_json::json!({
341            "text": text,
342            "kind": format!("{:?}", kind),
343            "dismiss_ms": dismiss_ms,
344        }),
345        Msg::MouseScroll { delta } => serde_json::json!({"delta": delta}),
346        Msg::OpenImageAt {
347            message_index,
348            image_index,
349        } => serde_json::json!({
350            "message_index": message_index,
351            "image_index": image_index,
352        }),
353        // Record only the selection length — the copied text doesn't affect
354        // reducer State (it just emits a clipboard Cmd) and may be large/sensitive.
355        Msg::CopySelection(text) => serde_json::json!({ "len": text.chars().count() }),
356    }
357}
358
359fn key_code_body(code: KeyCode) -> serde_json::Value {
360    match code {
361        KeyCode::Char(c) => serde_json::json!({"char": c.to_string()}),
362        KeyCode::F(n) => serde_json::json!({"f": n}),
363        other => serde_json::json!(format!("{:?}", other)),
364    }
365}
366
367fn slash_body(cmd: &SlashCmd) -> serde_json::Value {
368    match cmd {
369        SlashCmd::Model(model) => serde_json::json!({"command": "model", "arg": model}),
370        SlashCmd::Reasoning(level) => serde_json::json!({
371            "command": "reasoning",
372            "arg": level.map(|level| level.as_str()),
373        }),
374        SlashCmd::VisibleReasoning(arg) => {
375            serde_json::json!({"command": "visible-reasoning", "arg": arg})
376        },
377        SlashCmd::Safety(mode) => serde_json::json!({
378            "command": "safety",
379            "arg": mode.map(|m| m.as_str()),
380        }),
381        SlashCmd::Clear => serde_json::json!({"command": "clear"}),
382        SlashCmd::Save(name) => serde_json::json!({"command": "save", "arg": name}),
383        SlashCmd::Load(name) => serde_json::json!({"command": "load", "arg": name}),
384        SlashCmd::List => serde_json::json!({"command": "list"}),
385        SlashCmd::Usage => serde_json::json!({"command": "usage"}),
386        SlashCmd::Context(cmd) => {
387            serde_json::json!({"command": "context", "arg": format!("{cmd:?}")})
388        },
389        SlashCmd::Compact(instructions) => {
390            serde_json::json!({"command": "compact", "arg": instructions})
391        },
392        SlashCmd::Memory => serde_json::json!({"command": "memory"}),
393        SlashCmd::Remember(text) => serde_json::json!({"command": "remember", "arg": text}),
394        SlashCmd::Forget(id) => serde_json::json!({"command": "forget", "arg": id}),
395        SlashCmd::ConsolidateMemory => serde_json::json!({"command": "consolidate-memory"}),
396        SlashCmd::Doctor => serde_json::json!({"command": "doctor"}),
397        SlashCmd::Tasks => serde_json::json!({"command": "tasks"}),
398        SlashCmd::Task(id) => serde_json::json!({"command": "task", "arg": id}),
399        SlashCmd::Pause(id) => serde_json::json!({"command": "pause", "arg": id}),
400        SlashCmd::Resume(id) => serde_json::json!({"command": "resume", "arg": id}),
401        SlashCmd::Cancel(id) => serde_json::json!({"command": "cancel", "arg": id}),
402        SlashCmd::Handoff(id) => serde_json::json!({"command": "handoff", "arg": id}),
403        SlashCmd::Report(id) => serde_json::json!({"command": "report", "arg": id}),
404        SlashCmd::Processes => serde_json::json!({"command": "processes"}),
405        SlashCmd::Logs(id) => serde_json::json!({"command": "logs", "arg": id}),
406        SlashCmd::Stop(id) => serde_json::json!({"command": "stop", "arg": id}),
407        SlashCmd::Restart(id) => serde_json::json!({"command": "restart", "arg": id}),
408        SlashCmd::Open(target) => serde_json::json!({"command": "open", "arg": target}),
409        SlashCmd::Ports => serde_json::json!({"command": "ports"}),
410        SlashCmd::Approvals => serde_json::json!({"command": "approvals"}),
411        SlashCmd::Approve(id) => serde_json::json!({"command": "approve", "arg": id}),
412        SlashCmd::Deny(id) => serde_json::json!({"command": "deny", "arg": id}),
413        SlashCmd::Checkpoint(paths) => serde_json::json!({"command": "checkpoint", "arg": paths}),
414        SlashCmd::Checkpoints => serde_json::json!({"command": "checkpoints"}),
415        SlashCmd::Restore(id) => serde_json::json!({"command": "restore", "arg": id}),
416        SlashCmd::ModelInfo(model) => serde_json::json!({"command": "model-info", "arg": model}),
417        SlashCmd::Plugins => serde_json::json!({"command": "plugins"}),
418        SlashCmd::CloudSetup => serde_json::json!({"command": "cloud-setup"}),
419        SlashCmd::Help => serde_json::json!({"command": "help"}),
420        SlashCmd::Quit => serde_json::json!({"command": "quit"}),
421        SlashCmd::Unknown(name) => serde_json::json!({"command": "unknown", "name": name}),
422    }
423}
424
425fn progress_body(event: &ProgressEvent) -> serde_json::Value {
426    match event {
427        ProgressEvent::Output(text) => serde_json::json!({"type": "output", "text": text}),
428        ProgressEvent::Status(text) => serde_json::json!({"type": "status", "text": text}),
429        ProgressEvent::Bytes { done, total } => serde_json::json!({
430            "type": "bytes",
431            "done": done,
432            "total": total,
433        }),
434        ProgressEvent::Artifact {
435            mime,
436            data,
437            caption,
438        } => serde_json::json!({
439            "recordable": false,
440            "type": "artifact",
441            "mime": mime,
442            "caption": caption,
443            "size_bytes": data.len(),
444        }),
445        ProgressEvent::SubagentToolCall {
446            child_call_id,
447            tool_name,
448            phase,
449        } => serde_json::json!({
450            "type": "subagent_tool_call",
451            "child_call_id": child_call_id.0,
452            "tool_name": tool_name,
453            "phase": subagent_phase(*phase),
454        }),
455        ProgressEvent::SubagentText(text) => {
456            serde_json::json!({"type": "subagent_text", "text": text})
457        },
458    }
459}
460
461fn subagent_phase(phase: SubagentPhase) -> &'static str {
462    match phase {
463        SubagentPhase::Started => "started",
464        SubagentPhase::Finished => "finished",
465        SubagentPhase::Errored => "errored",
466    }
467}
468
469fn outcome_body(outcome: &ToolOutcome) -> serde_json::Value {
470    serde_json::json!({
471        "status": match outcome.status {
472            crate::domain::ToolStatus::Success => "success",
473            crate::domain::ToolStatus::Error => "error",
474            crate::domain::ToolStatus::Cancelled => "cancelled",
475        },
476        "summary": outcome.summary,
477        "model_content": outcome.model_content,
478        "error": outcome.error,
479        "image_count": outcome.images().map(|images| images.len()).unwrap_or(0),
480        "duration_secs": outcome.duration_secs,
481        "metadata": outcome.metadata,
482        "artifacts": outcome.artifacts,
483    })
484}
485
486fn unsupported(reason: &str) -> serde_json::Value {
487    serde_json::json!({
488        "recordable": false,
489        "reason": reason,
490    })
491}
492
493/// Read a JSONL log back. Iterates one line at a time so a huge
494/// replay doesn't allocate the whole file upfront.
495pub struct Replay {
496    lines: std::io::Lines<BufReader<File>>,
497    path: PathBuf,
498}
499
500impl Replay {
501    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
502        let path = path.into();
503        let file =
504            File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
505        Ok(Self {
506            lines: BufReader::new(file).lines(),
507            path,
508        })
509    }
510
511    pub fn path(&self) -> &Path {
512        &self.path
513    }
514}
515
516impl Iterator for Replay {
517    type Item = Result<ReplayEntry>;
518
519    fn next(&mut self) -> Option<Self::Item> {
520        let line = self.lines.next()?;
521        Some(match line {
522            Ok(raw) => serde_json::from_str::<ReplayEntry>(&raw)
523                .with_context(|| format!("parse replay line: {}", raw)),
524            Err(e) => Err(anyhow::Error::from(e)),
525        })
526    }
527}
528
529/// Parsed JSONL entry. Fields mirror what `Recorder::record_kind`
530/// writes.
531#[derive(Debug, serde::Serialize, serde::Deserialize)]
532pub struct ReplayEntry {
533    pub ts: String,
534    pub kind: String,
535    pub turn: Option<u64>,
536    pub body: serde_json::Value,
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    fn tmpfile(name: &str) -> PathBuf {
544        let dir = std::env::temp_dir().join("mermaid_recorder_tests");
545        let _ = std::fs::create_dir_all(&dir);
546        dir.join(name)
547    }
548
549    #[cfg(unix)]
550    #[test]
551    fn recording_file_is_owner_only() {
552        // #132: recordings hold cleartext prompts/output/file-contents, so they
553        // must be created 0600 rather than inheriting a world-readable umask.
554        use std::os::unix::fs::PermissionsExt;
555        let path = tmpfile("perms.jsonl");
556        let _ = std::fs::remove_file(&path);
557        let _ = Recorder::open(&path).expect("open");
558        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
559        assert_eq!(mode, 0o600, "recording must be created owner-only");
560        let _ = std::fs::remove_file(&path);
561    }
562
563    #[test]
564    fn record_and_replay_roundtrip() {
565        let path = tmpfile("roundtrip.jsonl");
566        let _ = std::fs::remove_file(&path);
567
568        {
569            let mut r = Recorder::open(&path).expect("open");
570            r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
571                .expect("record");
572            r.record_kind(
573                MsgKind::SubmitPrompt,
574                None,
575                serde_json::json!({"text": "hello"}),
576            )
577            .expect("record");
578            r.record_kind(
579                MsgKind::StreamText,
580                Some(TurnId(7)),
581                serde_json::json!({"chunk": "partial"}),
582            )
583            .expect("record");
584            r.flush().expect("flush");
585        }
586
587        let replay = Replay::open(&path).expect("open replay");
588        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
589        assert_eq!(entries.len(), 3);
590        assert_eq!(entries[0].kind, "Tick");
591        assert_eq!(entries[1].body["text"], "hello");
592        assert_eq!(entries[2].turn, Some(7));
593
594        let _ = std::fs::remove_file(&path);
595    }
596
597    #[test]
598    fn record_kind_redacts_secrets_in_body() {
599        // A recorded payload carrying a credential (e.g. a `read_file .env`
600        // result or an API error echoing a key) must hit disk scrubbed (#17).
601        let path = tmpfile("redact.jsonl");
602        let _ = std::fs::remove_file(&path);
603        {
604            let mut r = Recorder::open(&path).expect("open");
605            r.record_kind(
606                MsgKind::StreamText,
607                None,
608                serde_json::json!({
609                    "chunk": "OPENAI_API_KEY=sk-abcdefghijklmnop1234",
610                    "nested": ["Authorization: Bearer abcdef123456ghijkl"],
611                }),
612            )
613            .expect("record");
614            r.flush().expect("flush");
615        }
616
617        let raw = std::fs::read_to_string(&path).expect("read back");
618        assert!(
619            !raw.contains("sk-abcdefghijklmnop1234"),
620            "raw secret leaked: {raw}"
621        );
622        assert!(
623            !raw.contains("abcdef123456ghijkl"),
624            "bearer token leaked: {raw}"
625        );
626        assert!(
627            raw.contains("[REDACTED]"),
628            "expected redaction marker: {raw}"
629        );
630
631        let replay = Replay::open(&path).expect("replay");
632        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
633        assert_eq!(entries[0].body["chunk"], "OPENAI_API_KEY=[REDACTED]");
634        assert_eq!(
635            entries[0].body["nested"][0],
636            "Authorization: Bearer [REDACTED]"
637        );
638
639        let _ = std::fs::remove_file(&path);
640    }
641
642    #[test]
643    fn replay_parses_malformed_line_as_err() {
644        let path = tmpfile("bad.jsonl");
645        std::fs::write(&path, "not-json\n").expect("write");
646        let mut replay = Replay::open(&path).expect("open");
647        let first = replay.next().expect("first entry");
648        assert!(first.is_err());
649        let _ = std::fs::remove_file(&path);
650    }
651
652    #[test]
653    fn record_creates_file_on_open() {
654        let path = tmpfile("creates.jsonl");
655        let _ = std::fs::remove_file(&path);
656        assert!(!path.exists());
657        let _ = Recorder::open(&path).expect("open");
658        assert!(path.exists());
659        let _ = std::fs::remove_file(&path);
660    }
661
662    #[test]
663    fn record_append_preserves_existing_lines() {
664        let path = tmpfile("append.jsonl");
665        let _ = std::fs::remove_file(&path);
666        {
667            let mut r = Recorder::open(&path).expect("open");
668            r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
669                .expect("record");
670        }
671        {
672            let mut r = Recorder::open(&path).expect("reopen");
673            r.record_kind(MsgKind::Quit, None, serde_json::json!({}))
674                .expect("record");
675        }
676        let replay = Replay::open(&path).expect("replay");
677        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
678        assert_eq!(entries.len(), 2);
679        assert_eq!(entries[0].kind, "Tick");
680        assert_eq!(entries[1].kind, "Quit");
681        let _ = std::fs::remove_file(&path);
682    }
683
684    #[test]
685    fn record_msg_body_submit_prompt_keeps_text_and_attachments() {
686        let body = record_msg_body(&crate::domain::Msg::SubmitPrompt {
687            text: "explain main.rs".to_string(),
688            attachment_ids: vec![3, 9],
689        });
690        assert_eq!(body["text"], "explain main.rs");
691        assert_eq!(body["attachment_ids"][0], 3);
692        assert_eq!(body["attachment_ids"][1], 9);
693    }
694
695    #[test]
696    fn record_msg_body_slash_model_keeps_command_and_arg() {
697        let body = record_msg_body(&crate::domain::Msg::Slash(crate::domain::SlashCmd::Model(
698            Some("anthropic/opus".to_string()),
699        )));
700        assert_eq!(body["command"], "model");
701        assert_eq!(body["arg"], "anthropic/opus");
702    }
703
704    #[test]
705    fn record_msg_body_runtime_signal_keeps_signal_name() {
706        let body = record_msg_body(&crate::domain::Msg::RuntimeSignal(
707            crate::domain::RuntimeSignal::Terminate,
708        ));
709        assert_eq!(body["signal"], "terminate");
710    }
711
712    #[test]
713    fn record_msg_body_marks_binary_paste_image_unrecordable() {
714        let body = record_msg_body(&crate::domain::Msg::Paste(crate::domain::Paste::Image {
715            bytes: vec![1, 2, 3],
716            format: "png".to_string(),
717        }));
718        assert_eq!(body["recordable"], false);
719        assert_eq!(body["type"], "image");
720        assert_eq!(body["size_bytes"], 3);
721    }
722}