Skip to main content

mermaid_cli/session/
conversation.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Local};
3use mermaid_domain::ConversationHistory;
4use mermaid_model::models::{ChatMessage, MessageRole};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::SystemTime;
11
12/// Reject a conversation id that doesn't match the generated shape
13/// (`%Y%m%d_%H%M%S_%3f` => `YYYYMMDD_HHMMSS_mmm`). Without this, a
14/// user-typed `/load <id>` (or `delete`) joins arbitrary text into a
15/// filesystem path — `../../secret` would read/delete files outside the
16/// project. Digits-and-underscores can't contain `/`, `\`, `..`, or a drive
17/// prefix, so the format check alone closes the traversal.
18fn validate_conversation_id(id: &str) -> Result<()> {
19    let valid = id.len() == 19
20        && id.as_bytes().iter().enumerate().all(|(i, b)| match i {
21            8 | 15 => *b == b'_',
22            _ => b.is_ascii_digit(),
23        });
24    anyhow::ensure!(valid, "invalid conversation id: {id:?}");
25    Ok(())
26}
27
28/// Upper bound on a conversation file we'll read into memory (#129). A giant or
29/// hostile `.mermaid/conversations/*.json` (or one with an enormous `content`)
30/// would otherwise OOM the process — `--continue` walks every file. 64 MiB is
31/// far above any real transcript yet bounds the worst case.
32const MAX_CONVERSATION_BYTES: u64 = 64 * 1024 * 1024;
33
34/// Read a conversation file with the [`MAX_CONVERSATION_BYTES`] cap enforced
35/// *before* the bytes are pulled into RAM.
36fn read_conversation_capped(path: &Path) -> std::io::Result<String> {
37    let len = fs::metadata(path)?.len();
38    if len > MAX_CONVERSATION_BYTES {
39        return Err(std::io::Error::new(
40            std::io::ErrorKind::InvalidData,
41            format!(
42                "conversation file {} is {len} bytes, over the {} MiB cap",
43                path.display(),
44                MAX_CONVERSATION_BYTES / (1024 * 1024)
45            ),
46        ));
47    }
48    fs::read_to_string(path)
49}
50
51/// Marker left in a message's text when its tool-image bytes are dropped on save.
52const TOOL_IMAGE_ELIDED_MARKER: &str = "\n[tool image not persisted]";
53
54/// Top-level key in a checkpoint file naming the last log `seq` folded into
55/// it. Absent on legacy snapshots and on any file written before the log
56/// existed, which is exactly the signal to fold from zero instead of
57/// trusting the checkpoint. See `docs/design/fold-first-resume.md`.
58const CHECKPOINT_SEQ_KEY: &str = "checkpoint_seq";
59
60/// Return a sanitized copy of `messages` with tool-returned image bytes
61/// removed before they reach durable storage (#99). Tool images (an MCP
62/// browser's page capture, say) can carry on-screen secrets and attach to
63/// **non-User** messages (the assistant message the capture is routed onto,
64/// or a tool outcome); user-supplied multimodal images attach to **User**
65/// messages and are intentional content, so they're preserved. The live
66/// in-memory conversation is untouched (this runs on a copy at the save
67/// chokepoint), so the chat and model context still see the image for the
68/// session — only the on-disk copy is scrubbed.
69///
70/// Returns `None` when nothing needed stripping, so the hot save path avoids a
71/// clone in the common (no-image) case.
72fn strip_persisted_tool_images(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
73    let needs = messages
74        .iter()
75        .any(|m| m.role != MessageRole::User && m.images.is_some());
76    if !needs {
77        return None;
78    }
79    let mut out = messages.to_vec();
80    for m in out.iter_mut() {
81        if m.role != MessageRole::User && m.images.is_some() {
82            m.images = None;
83            if !m.content.ends_with(TOOL_IMAGE_ELIDED_MARKER) {
84                m.content.push_str(TOOL_IMAGE_ELIDED_MARKER);
85            }
86        }
87    }
88    Some(out)
89}
90
91/// Best-effort current git branch of `dir`, for labelling `--resume` rows.
92/// `None` when `dir` isn't a git work tree, git is absent, or HEAD is
93/// detached. Kept out of the pure reducer — callers invoke it in the impure
94/// startup path and stamp the result onto the conversation.
95#[must_use]
96pub fn detect_git_branch(dir: &Path) -> Option<String> {
97    let output = std::process::Command::new("git")
98        .args(["rev-parse", "--abbrev-ref", "HEAD"])
99        .current_dir(dir)
100        .output()
101        .ok()?;
102    if !output.status.success() {
103        return None;
104    }
105    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
106    // A detached HEAD reports the literal "HEAD"; treat that as no branch.
107    (!branch.is_empty() && branch != "HEAD").then_some(branch)
108}
109
110/// Best-effort short git SHA of `dir`'s HEAD, for session provenance. `None`
111/// outside a git work tree or when git is absent. Impure — stamped at startup
112/// alongside `detect_git_branch`, never in the reducer.
113#[must_use]
114pub fn detect_git_sha(dir: &Path) -> Option<String> {
115    let output = std::process::Command::new("git")
116        .args(["rev-parse", "--short", "HEAD"])
117        .current_dir(dir)
118        .output()
119        .ok()?;
120    if !output.status.success() {
121        return None;
122    }
123    let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
124    (!sha.is_empty()).then_some(sha)
125}
126
127/// Lightweight session metadata, persisted as an `<id>.meta` sidecar so listing
128/// sessions doesn't have to read a transcript at all.
129///
130/// A cache of a cache: the log is the truth, the checkpoint materializes it,
131/// and this indexes the checkpoint. Written on every append, because with
132/// checkpoints on a coarse cadence a short session has no checkpoint yet and
133/// would otherwise be invisible to the picker. A session missing one is
134/// listed by reading its checkpoint, or failing that by folding its log.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ConversationMeta {
137    pub id: String,
138    pub title: String,
139    pub updated_at: DateTime<Local>,
140    #[serde(default)]
141    pub git_branch: Option<String>,
142    #[serde(default)]
143    pub message_count: usize,
144    #[serde(default)]
145    pub forked_from: Option<String>,
146}
147
148impl ConversationMeta {
149    fn from_history(h: &ConversationHistory) -> Self {
150        Self {
151            id: h.id.clone(),
152            title: h.title.clone(),
153            updated_at: h.updated_at,
154            git_branch: h.git_branch.clone(),
155            message_count: h.messages().len(),
156            forked_from: h.forked_from.clone(),
157        }
158    }
159}
160
161/// Read a checkpoint file: the conversation plus the log watermark it was
162/// materialized at, when it carries one.
163///
164/// The watermark is read off the raw JSON rather than the deserialized
165/// value, because it is deliberately not a field of `ConversationHistory`
166/// (see [`CHECKPOINT_SEQ_KEY`]). A file without it is a legacy snapshot or
167/// one written by a build that did not stamp, and the caller treats a
168/// missing watermark as "cannot trust this as a checkpoint".
169fn read_checkpoint(path: &Path) -> Result<(ConversationHistory, Option<u64>)> {
170    let json = read_conversation_capped(path)?;
171    let value: serde_json::Value = serde_json::from_str(&json)?;
172    let seq = value
173        .get(CHECKPOINT_SEQ_KEY)
174        .and_then(serde_json::Value::as_u64);
175    let conversation: ConversationHistory = serde_json::from_value(value)?;
176    Ok((conversation, seq))
177}
178
179/// Manages conversation persistence for a project
180#[derive(Clone)]
181pub struct ConversationManager {
182    /// The project directory the manager was built for — keys the
183    /// scratchpad cascade in [`ConversationManager::delete_conversation`].
184    project_dir: PathBuf,
185    conversations_dir: PathBuf,
186    /// The per-session `.jsonl` appender/reader (see `event_log`). Shared
187    /// across clones like `seen`, so one process keeps one seq cursor.
188    events: Arc<crate::session::event_log::EventLog>,
189}
190
191impl ConversationManager {
192    /// Create a new conversation manager for a project directory
193    ///
194    /// # Errors
195    ///
196    /// Creating `.mermaid/conversations` under `project_dir` — a read-only
197    /// or unwritable project. It is created here, so the later load and list
198    /// paths can treat an unreadable directory as "no conversations" rather
199    /// than a failure.
200    pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
201        let conversations_dir = project_dir.as_ref().join(".mermaid").join("conversations");
202        fs::create_dir_all(&conversations_dir)?;
203
204        Ok(Self {
205            project_dir: project_dir.as_ref().to_path_buf(),
206            events: Arc::new(crate::session::event_log::EventLog::new(
207                conversations_dir.clone(),
208            )),
209            conversations_dir,
210        })
211    }
212
213    /// Append session events for `snapshot` to its `.jsonl` log, creating
214    /// the log (with a backfill from the snapshot) on first touch. Called by
215    /// the persistence chain BEFORE the snapshot rewrite, so the history
216    /// lands before the file it explains is overwritten.
217    ///
218    /// # Errors
219    ///
220    /// An `id` that would escape the conversations dir, and the append I/O
221    /// itself. The caller treats a failure as a warning: the snapshot save
222    /// must still run, and the log self-heals on the next save.
223    pub fn append_session_events(
224        &self,
225        snapshot: &ConversationHistory,
226        events: &[mermaid_domain::SessionEvent],
227    ) -> Result<()> {
228        validate_conversation_id(&snapshot.id)?;
229        let appended = self.events.append(snapshot, events);
230        // The picker's index rides the append, not the checkpoint. With
231        // checkpoints on a ~200-event cadence a short session has none at
232        // all until it exits, so a sidecar written only alongside one would
233        // leave real sessions invisible to `--resume`. Best-effort as ever:
234        // the sidecar is a cache of a cache.
235        self.write_meta(snapshot);
236        appended
237    }
238
239    /// Write the tiny `<id>.meta` sidecar the session picker lists from.
240    /// Best-effort by construction — every field is recoverable from the
241    /// log, so a failed write costs a slower listing, never data.
242    fn write_meta(&self, conversation: &ConversationHistory) {
243        if conversation.messages().is_empty() {
244            return;
245        }
246        let meta = ConversationMeta::from_history(conversation);
247        if let Ok(json) = serde_json::to_string(&meta) {
248            let path = self
249                .conversations_dir
250                .join(format!("{}.meta", conversation.id));
251            let _ = mermaid_runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600);
252        }
253    }
254
255    /// Where a session's event log lives. The compaction bookkeeping row
256    /// records this instead of a per-compaction archive file: the dropped
257    /// messages are the log's earlier `message` events.
258    #[must_use]
259    pub fn event_log_path(&self, id: &str) -> PathBuf {
260        self.events.path_for(id)
261    }
262
263    /// Read a session's log as EVENTS rather than folding it into a
264    /// conversation — for readers that want the history in the order it
265    /// happened instead of the state it produced.
266    ///
267    /// The daemon's `subscribe_task` catch-up is that reader: a mid-run
268    /// attach replays these onto the `RunEvent` wire so a subscriber joining
269    /// at minute nine learns what the first nine produced. `Ok(None)` when
270    /// there is no readable log; a torn tail yields the prefix, exactly as a
271    /// fold does.
272    ///
273    /// # Errors
274    ///
275    /// An `id` that would escape the conversations dir, and read I/O on the
276    /// log file. A log that is absent, over the cap, or newer-format is
277    /// `Ok(None)`, not an error.
278    pub fn read_session_events(
279        &self,
280        id: &str,
281    ) -> Result<Option<Vec<mermaid_domain::SessionEvent>>> {
282        validate_conversation_id(id)?;
283        Ok(self
284            .events
285            .read_events(id, None)?
286            .map(|(events, _highest)| events))
287    }
288
289    /// Rebuild a conversation from its event log — the recovery source when
290    /// the snapshot is missing or will not parse. `Ok(None)` when there is
291    /// no (foldable) log.
292    ///
293    /// # Errors
294    ///
295    /// An `id` that would escape the conversations dir, and read I/O on the
296    /// log file. A log that is absent, capped, newer-format, or headerless
297    /// is `Ok(None)`, not an error.
298    pub fn fold_conversation_from_log(&self, id: &str) -> Result<Option<ConversationHistory>> {
299        validate_conversation_id(id)?;
300        let Some(folded) = self.events.fold(id)? else {
301            return Ok(None);
302        };
303        // The folded id drives later saves exactly like a parsed one; hold
304        // it to the same rule.
305        validate_conversation_id(&folded.id)?;
306        Ok(Some(folded))
307    }
308
309    /// Save a conversation to disk
310    ///
311    /// # Errors
312    ///
313    /// An `id` that would escape the conversations dir, serializing the
314    /// conversation, and the atomic 0600 write. Two cases that look like
315    /// failures are `Ok`: a message-less conversation is deliberately not
316    /// persisted, and a concurrent writer detected through the `(mtime, len)`
317    /// baseline diverts this copy to a `.conflict` sibling and warns instead
318    /// of overwriting. The `.meta` sidecar is best-effort and never fails the
319    /// save.
320    pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
321        // The id field is persisted and round-trips through (potentially
322        // tampered) on-disk state; validate it before it drives the write path,
323        // so a loaded conversation can't escape the conversations dir on save.
324        validate_conversation_id(&conversation.id)?;
325
326        // An untouched session — the user ran `mermaid` and closed it without
327        // sending anything — has no transcript. Never persist it, so it can't
328        // clutter the `--resume` picker or be reached by `--continue`; the first
329        // real message triggers the next save, which creates the file then.
330        if conversation.messages().is_empty() {
331            return Ok(());
332        }
333
334        let filename = format!("{}.json", conversation.id);
335        let path = self.conversations_dir.join(filename);
336
337        // Sanitize before persisting: strip tool-returned image bytes (#99)
338        // AND scrub credential-shaped strings, so a persisted `read_file` of
339        // `.env` or an API error echoing a key can't sit in cleartext (mirrors
340        // the --record redaction in recorder.rs). Only clones when scrubbing.
341        let mut value = match strip_persisted_tool_images(conversation.messages()) {
342            Some(sanitized) => {
343                let mut stripped = conversation.clone();
344                *stripped.messages_mut() = sanitized;
345                serde_json::to_value(&stripped)?
346            },
347            None => serde_json::to_value(conversation)?,
348        };
349        mermaid_model::utils::redact_json(&mut value);
350        // Stamp WHICH events this checkpoint already contains. Storage's
351        // business, not the domain's: a `ConversationHistory` describes a
352        // conversation, while the log offset a cache was materialized at
353        // describes the cache. Injected into the serialized object rather
354        // than added as a field, and unknown keys are ignored on the way
355        // back in, so the value round-trips through the reducer untouched
356        // and an older mermaid reads the file as a plain snapshot.
357        if let Some(seq) = self.events.checkpoint_seq(&conversation.id)
358            && let Some(object) = value.as_object_mut()
359        {
360            object.insert(CHECKPOINT_SEQ_KEY.to_string(), seq.into());
361        }
362        let json = serde_json::to_string_pretty(&value)?;
363
364        // F73's concurrent-writer guard is NOT here any more; it moved to the
365        // append (see `event_log::diverted_on_conflict`). Two reasons, both
366        // consequences of the log becoming the truth: this file is now a
367        // derived cache, so a clobbered checkpoint costs a longer replay
368        // rather than lost history — and by the time a save reaches here the
369        // append has already decided whether this process is still writing
370        // the shared session at all. Guarding the cache after the truth was
371        // written would only produce `.conflict` copies of a rebuildable file.
372
373        // Atomic write: a crash mid-save must not leave a half-written
374        // checkpoint that resume would then have to distrust.
375        // Owner-only (0o600): the transcript can carry secrets in cleartext.
376        mermaid_runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
377        // Refresh our baseline to the file we just wrote so the NEXT save by this
378        // process compares against our own write, not the pre-save state.
379
380        // Keep the picker's sidecar current for a checkpoint written without
381        // a preceding append (the QA paths do this). The append writes it
382        // too, which is what covers sessions with no checkpoint yet.
383        self.write_meta(conversation);
384
385        Ok(())
386    }
387
388    /// Load a specific conversation by ID
389    ///
390    /// # Errors
391    ///
392    /// An `id` that would escape the conversations dir, a file that is
393    /// missing, unreadable, or past the size cap, JSON that does not parse,
394    /// and a parsed `id` that would itself traverse — checked separately,
395    /// because that field is on-disk state that drives later saves.
396    pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
397        validate_conversation_id(id)?;
398        let path = self.conversations_dir.join(format!("{id}.json"));
399        let checkpoint = read_checkpoint(&path);
400
401        // A session with no log is one that predates it: the snapshot is
402        // all there is, so it is still the truth for that session. Every
403        // other case goes through the log.
404        if !self.events.exists(id) {
405            let (conversation, _) = checkpoint?;
406            validate_conversation_id(&conversation.id)?;
407            return Ok(conversation);
408        }
409
410        // Checkpoint plus the events after its watermark. Anything that
411        // makes the checkpoint untrustworthy — unreadable, unparseable, no
412        // watermark, or a watermark its log cannot account for — falls
413        // through to folding the log from zero. That fallback is the
414        // property this design is for: the checkpoint is never
415        // load-bearing, only faster.
416        if let Ok((checkpoint, Some(seq))) = checkpoint
417            && validate_conversation_id(&checkpoint.id).is_ok()
418            && let Some(resumed) = self.events.replay_onto(id, checkpoint, seq)?
419        {
420            return Ok(resumed);
421        }
422
423        let folded = self
424            .fold_conversation_from_log(id)?
425            .with_context(|| format!("session {id} has a log that could not be folded"))?;
426        Ok(folded)
427    }
428
429    /// Load the most recent *valid* conversation.
430    ///
431    /// Iterates files newest-first by mtime and returns the first that reads,
432    /// parses, and has a valid id — skipping (with a warning) any unreadable,
433    /// unparseable, or traversing-id file. Mirrors `list_conversations`'s
434    /// tolerance so one corrupt/partial file (e.g. a crash mid-write) can't make
435    /// `--continue` hard-fail; it falls back to the next-newest valid conversation.
436    ///
437    /// # Errors
438    ///
439    /// In practice none: an unreadable conversations dir and every unreadable,
440    /// oversized, unparseable, or traversing-id file are skipped with a
441    /// warning, and running out of candidates is `Ok(None)`. The `Result` is
442    /// kept for callers that already handle one and so this can grow a real
443    /// failure later.
444    pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
445        // Newest-first over LOGS, then checkpoints for sessions that predate
446        // the log. Ranking by log mtime matters: a session's checkpoint can
447        // be many events stale (it is written every ~200), so ordering by
448        // checkpoint mtime would answer with the wrong session.
449        for id in self.session_ids_newest_first() {
450            // One resume algorithm, whatever the entry point: `load_conversation`
451            // already picks checkpoint-plus-replay or a full fold.
452            let conv = match self.load_conversation(&id) {
453                Ok(conv) => conv,
454                Err(error) => {
455                    tracing::warn!(id, %error, "skipping session that would not load");
456                    continue;
457                },
458            };
459            // Skip untouched (message-less) sessions — `--continue` resumes the
460            // last chat with real history, not a blank one opened and closed.
461            if conv.messages().is_empty() {
462                continue;
463            }
464            return Ok(Some(conv));
465        }
466        Ok(None)
467    }
468
469    /// Every session id in this project, newest activity first.
470    ///
471    /// Ranked by the LOG's mtime where there is one, since that is what a
472    /// save touches every time; a checkpoint is written on a much coarser
473    /// cadence and would rank a busy session as stale. Sessions with only a
474    /// checkpoint (written before logs existed) rank by that instead.
475    fn session_ids_newest_first(&self) -> Vec<String> {
476        let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
477            return Vec::new();
478        };
479        // Per id, keep the log's mtime if there is a log, else the
480        // checkpoint's. `is_log` wins over recency, not the other way
481        // round: a checkpoint written after the last append is still the
482        // coarser clock.
483        let mut best: HashMap<String, (bool, SystemTime)> = HashMap::new();
484        for entry in entries.flatten() {
485            let path = entry.path();
486            let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
487                continue;
488            };
489            let is_log = match ext {
490                "jsonl" => true,
491                "json" => false,
492                _ => continue,
493            };
494            let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
495                continue;
496            };
497            if validate_conversation_id(id).is_err() {
498                continue;
499            }
500            let Ok(mtime) = entry.metadata().and_then(|meta| meta.modified()) else {
501                continue;
502            };
503            match best.get(id) {
504                Some((had_log, _)) if *had_log && !is_log => {},
505                _ => {
506                    best.insert(id.to_string(), (is_log, mtime));
507                },
508            }
509        }
510        let mut ranked: Vec<(SystemTime, String)> = best
511            .into_iter()
512            .map(|(id, (_, mtime))| (mtime, id))
513            .collect();
514        ranked.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
515        ranked.into_iter().map(|(_, id)| id).collect()
516    }
517
518    /// List all conversations in the project
519    ///
520    /// # Errors
521    ///
522    /// In practice none, and deliberately: an unreadable directory yields an
523    /// empty list, and any file that will not read or parse is skipped, so one
524    /// corrupt transcript cannot empty the picker.
525    pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
526        let mut conversations = Vec::new();
527
528        // Read all JSON files in the conversations directory
529        if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
530            for entry in entries.flatten() {
531                if let Some(ext) = entry.path().extension()
532                    && ext == "json"
533                    && let Ok(json) = read_conversation_capped(&entry.path())
534                    && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
535                    // Skip untouched (message-less) sessions — they carry no
536                    // history worth resuming, so they never appear in the picker.
537                    && !conv.messages().is_empty()
538                {
539                    conversations.push(conv);
540                }
541            }
542        }
543
544        // Sort by updated_at (newest first)
545        conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
546
547        Ok(conversations)
548    }
549
550    /// Fast session list: read each `<id>.meta` sidecar; for a session that
551    /// lacks a (valid) one — older, or written by a pre-sidecar build — fall
552    /// back to fully parsing its `<id>.json`. Message-less sessions are skipped.
553    /// Newest-first. Cheaper than [`Self::list_conversations`] for display-only paths.
554    ///
555    /// # Errors
556    ///
557    /// In practice none, matching [`Self::list_conversations`]: an unreadable
558    /// directory yields an empty list, and a sidecar that will not read or
559    /// parse falls through to parsing its `<id>.json`, which is itself skipped
560    /// if that fails too.
561    pub fn list_conversation_metas(&self) -> Result<Vec<ConversationMeta>> {
562        let mut metas = Vec::new();
563        let mut seen = std::collections::HashSet::new();
564        let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
565            return Ok(metas);
566        };
567        let paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
568        // Fast path: `<id>.meta` sidecars.
569        for path in &paths {
570            if path.extension().is_some_and(|e| e == "meta")
571                && let Ok(raw) = fs::read_to_string(path)
572                && let Ok(meta) = serde_json::from_str::<ConversationMeta>(&raw)
573                && meta.message_count > 0
574            {
575                seen.insert(meta.id.clone());
576                metas.push(meta);
577            }
578        }
579        // Fallback: any `<id>.json` without a valid sidecar.
580        for path in &paths {
581            if path.extension().is_some_and(|e| e == "json")
582                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
583                && !seen.contains(stem)
584                && let Ok(json) = read_conversation_capped(path)
585                && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
586                && !conv.messages().is_empty()
587            {
588                seen.insert(stem.to_string());
589                metas.push(ConversationMeta::from_history(&conv));
590            }
591        }
592        // Last resort: a session with a log but neither sidecar nor
593        // checkpoint. Rare (both are written on every save), but the log is
594        // the truth, so a session that has one must be listable — otherwise
595        // the picker would hide a resumable session because a cache is
596        // missing. Folding here is the expensive path, which is exactly why
597        // it runs only for what the two cheap passes missed.
598        for path in &paths {
599            if path.extension().is_some_and(|e| e == "jsonl")
600                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
601                && !seen.contains(stem)
602                && let Ok(Some(conv)) = self.fold_conversation_from_log(stem)
603                && !conv.messages().is_empty()
604            {
605                metas.push(ConversationMeta::from_history(&conv));
606            }
607        }
608        metas.sort_by_key(|m| std::cmp::Reverse(m.updated_at));
609        Ok(metas)
610    }
611
612    /// Delete a conversation (and its metadata sidecar).
613    ///
614    /// # Errors
615    ///
616    /// An `id` that would escape the conversations dir, and removing the
617    /// `<id>.json` itself. An id with no file is `Ok`, and the `.meta` sidecar
618    /// and the session scratch dir are cleaned up best-effort — neither can
619    /// fail the delete.
620    pub fn delete_conversation(&self, id: &str) -> Result<()> {
621        validate_conversation_id(id)?;
622        // The index row and what hangs off it in the runtime store. Best-effort
623        // like the sidecars: the files are the truth, and the daemon's next
624        // start prunes any row this misses.
625        let _ = mermaid_runtime::with_shared_store(|store| store.sessions().delete(id));
626        let path = self.conversations_dir.join(format!("{id}.json"));
627        if path.exists() {
628            fs::remove_file(path)?;
629        }
630        // Best-effort sidecar + event-log cleanup — their absence is harmless.
631        let _ = fs::remove_file(self.conversations_dir.join(format!("{id}.meta")));
632        let _ = fs::remove_file(self.conversations_dir.join(format!("{id}.jsonl")));
633        // Cascade to the session's scratch directory (skipped if another
634        // live mermaid still holds its pid lock). Best-effort: the sweep
635        // eventually reaps whatever this misses.
636        let _ = crate::session::scratchpad::remove(&self.project_dir, id);
637
638        Ok(())
639    }
640
641    /// Get the conversations directory path
642    #[must_use]
643    pub fn conversations_dir(&self) -> &Path {
644        &self.conversations_dir
645    }
646}
647
648/// Probe the session's provenance. Impure — spawns `git` twice — so it is a
649/// value the shell resolves once at startup and delivers as
650/// `Msg::SessionProvenanceResolved`.
651#[must_use]
652pub fn probe_session_provenance(cwd: &Path) -> mermaid_domain::SessionProvenance {
653    mermaid_domain::SessionProvenance {
654        git_branch: detect_git_branch(cwd),
655        git_sha: detect_git_sha(cwd),
656        cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    /// A conversation carrying one message, so it actually persists — empty
665    /// (message-less) sessions are intentionally not saved.
666    fn touched(project: &str) -> ConversationHistory {
667        let mut c = ConversationHistory::new(project.into(), "m".into(), Local::now());
668        c.add_messages(&[ChatMessage::user("hi")], Local::now());
669        c
670    }
671
672    #[test]
673    fn legacy_conversation_json_without_git_branch_deserializes() {
674        // Every session saved before the `--resume` picker existed lacks a
675        // `git_branch` key; `#[serde(default)]` must load it as `None` rather
676        // than failing the picker's `list_conversations`.
677        let json = r#"{
678            "id": "20260101_120000_001",
679            "title": "Legacy session",
680            "messages": [],
681            "model_name": "ollama/test",
682            "project_path": "/tmp/proj",
683            "created_at": "2026-01-01T12:00:00-05:00",
684            "updated_at": "2026-01-01T12:00:00-05:00",
685            "total_tokens": null
686        }"#;
687        let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
688        assert!(conv.git_branch.is_none());
689        assert_eq!(conv.title, "Legacy session");
690        // And a round-trip of a branch-bearing conversation preserves it.
691        let mut fresh =
692            ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
693        fresh.git_branch = Some("feature/x".to_string());
694        let round: ConversationHistory =
695            serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
696        assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
697    }
698
699    #[test]
700    fn legacy_json_defaults_session_state_fields() {
701        // A file written before the session-state fields existed lacks them;
702        // `#[serde(default)]` must load safety/meters as None/0 (safety then
703        // falls back to the config default on resume — see `seed_conversation`).
704        let json = r#"{
705            "id": "20260101_120000_002",
706            "title": "Old",
707            "messages": [],
708            "model_name": "m",
709            "project_path": "/tmp/proj",
710            "created_at": "2026-01-01T12:00:00-05:00",
711            "updated_at": "2026-01-01T12:00:00-05:00",
712            "total_tokens": null
713        }"#;
714        let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
715        assert_eq!(conv.safety_mode, None);
716        assert_eq!(
717            conv.cumulative_token_usage,
718            mermaid_domain::TokenUsageTotals::default()
719        );
720        assert!(conv.last_token_usage.is_none());
721        assert!(conv.context_usage.is_none());
722        assert!(conv.tasks.tasks.is_empty());
723        assert_eq!(conv.tasks.next_id, 0);
724        assert!(
725            conv.advertised_context.is_none(),
726            "pre-field saves load a None baseline (silent seed)"
727        );
728    }
729
730    #[test]
731    fn a_save_from_the_retired_plan_mode_still_loads() {
732        // Written by a build that still had plan mode, mid-plan: the live mode
733        // and the advertised baseline both name it, and the baseline carries
734        // the plan path. Each unknown piece degrades instead of failing the
735        // load: the mode falls back to the config default on resume, and the
736        // baseline restamps silently on the first dispatch.
737        let json = r#"{
738            "id": "20260101_120000_003",
739            "title": "Planning",
740            "messages": [],
741            "model_name": "m",
742            "project_path": "/tmp/proj",
743            "created_at": "2026-01-01T12:00:00-05:00",
744            "updated_at": "2026-01-01T12:00:00-05:00",
745            "total_tokens": null,
746            "safety_mode": "plan",
747            "advertised_context": {
748                "safety_mode": "plan",
749                "model_id": "ollama/test",
750                "plan_path": "/tmp/plans/p.md"
751            },
752            "plan": { "file": "/tmp/plans/p.md", "restore_mode": "auto" }
753        }"#;
754        let conv: ConversationHistory = serde_json::from_str(json).expect("plan-era json loads");
755        assert_eq!(conv.safety_mode, None);
756        assert!(conv.advertised_context.is_none());
757    }
758
759    #[test]
760    fn advertised_context_round_trips_through_conversation_json() {
761        let mut fresh = touched("/tmp/proj");
762        fresh.advertised_context = Some(mermaid_domain::AdvertisedContext {
763            safety_mode: mermaid_runtime::SafetyMode::Ask,
764            model_id: "ollama/test".to_string(),
765        });
766        let round: ConversationHistory =
767            serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
768        let ctx = round.advertised_context.expect("field survives");
769        assert_eq!(ctx.safety_mode, mermaid_runtime::SafetyMode::Ask);
770        assert_eq!(ctx.model_id, "ollama/test");
771    }
772
773    #[test]
774    fn tasks_round_trip_through_conversation_json() {
775        let mut fresh = touched("/tmp/proj");
776        fresh.tasks.create(
777            vec![mermaid_domain::ChecklistSpec {
778                subject: "wire broker".into(),
779                active_form: "wiring broker".into(),
780                description: Some("through ExecContext".into()),
781                in_progress: true,
782            }],
783            mermaid_domain::ChecklistOrigin::Model,
784            mermaid_domain::Stamp {
785                now_epoch: 42,
786                run_tokens: 7,
787            },
788        );
789        let round: ConversationHistory =
790            serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
791        assert_eq!(round.tasks, fresh.tasks);
792        assert_eq!(round.tasks.tasks[0].started_at, Some(42));
793    }
794
795    #[test]
796    fn session_state_round_trips_through_json() {
797        let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
798        conv.safety_mode = Some(mermaid_runtime::SafetyMode::FullAccess);
799        conv.cumulative_token_usage = mermaid_domain::TokenUsageTotals {
800            prompt_tokens: 777,
801            ..Default::default()
802        };
803        let round: ConversationHistory =
804            serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
805        assert_eq!(
806            round.safety_mode,
807            Some(mermaid_runtime::SafetyMode::FullAccess)
808        );
809        assert_eq!(round.cumulative_token_usage.total_tokens(), 777);
810    }
811
812    #[test]
813    fn validate_conversation_id_rejects_traversal() {
814        assert!(validate_conversation_id("20260101_120000_001").is_ok());
815        assert!(validate_conversation_id("../secret").is_err());
816        assert!(validate_conversation_id("..\\secret").is_err());
817        assert!(validate_conversation_id("/etc/passwd").is_err());
818        assert!(validate_conversation_id("20260101_120000").is_err()); // too short
819        assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); // non-digits
820    }
821
822    #[test]
823    fn strip_persisted_tool_images_drops_assistant_images_keeps_user_images() {
824        let messages = vec![
825            ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
826            ChatMessage::assistant("here is the screen")
827                .with_images(vec!["SCREENSHOT_B64".to_string()]),
828            ChatMessage::assistant("no image here"),
829        ];
830        let sanitized = strip_persisted_tool_images(&messages).expect("had a tool image to strip");
831        // User-supplied image preserved.
832        assert_eq!(
833            sanitized[0].images.as_deref(),
834            Some(["USER_PASTED_B64".to_string()].as_slice())
835        );
836        // Assistant image dropped + marker added.
837        assert!(sanitized[1].images.is_none());
838        assert!(sanitized[1].content.ends_with(TOOL_IMAGE_ELIDED_MARKER));
839        // Untouched assistant message is unchanged (no spurious marker).
840        assert!(!sanitized[2].content.ends_with(TOOL_IMAGE_ELIDED_MARKER));
841    }
842
843    #[test]
844    fn strip_persisted_tool_images_is_none_without_assistant_images() {
845        let messages = vec![
846            ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
847            ChatMessage::assistant("no images"),
848        ];
849        assert!(strip_persisted_tool_images(&messages).is_none());
850    }
851
852    #[test]
853    fn saved_conversation_json_has_no_tool_image_bytes() {
854        let dir = std::env::temp_dir().join("mermaid_strip_test");
855        let _ = fs::create_dir_all(&dir);
856        let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
857        *conv.messages_mut() = vec![
858            ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
859            ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
860        ];
861        let store = ConversationManager {
862            project_dir: dir.clone(),
863            events: Arc::new(crate::session::event_log::EventLog::new(dir.clone())),
864            conversations_dir: dir.clone(),
865        };
866        store.save_conversation(&conv).expect("save");
867        let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
868        assert!(
869            !raw.contains("SHOTBYTES"),
870            "tool image leaked to disk: {raw}"
871        );
872        assert!(raw.contains("USERIMG"), "user image should persist");
873        // Live conversation untouched — still carries the image in-session.
874        assert_eq!(
875            conv.messages()[1].images.as_deref(),
876            Some(["SHOTBYTES".to_string()].as_slice())
877        );
878        let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
879    }
880
881    #[test]
882    fn saved_conversation_redacts_secrets_and_is_owner_only() {
883        let dir = std::env::temp_dir().join(format!("mermaid_conv_redact_{}", std::process::id()));
884        let _ = fs::remove_dir_all(&dir);
885        let _ = fs::create_dir_all(&dir);
886        let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
887        // A read_file of .env lands in a tool-result message in cleartext today.
888        *conv.messages_mut() = vec![
889            ChatMessage::user("read .env"),
890            ChatMessage::assistant("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
891        ];
892        let store = ConversationManager {
893            project_dir: dir.clone(),
894            events: Arc::new(crate::session::event_log::EventLog::new(dir.clone())),
895            conversations_dir: dir.clone(),
896        };
897        store.save_conversation(&conv).expect("save");
898        let path = dir.join(format!("{}.json", conv.id));
899        let raw = fs::read_to_string(&path).expect("read");
900        assert!(
901            !raw.contains("sk-abcdefghijklmnop1234"),
902            "secret leaked to the conversation store: {raw}"
903        );
904        assert!(
905            raw.contains("[REDACTED]"),
906            "expected redaction marker: {raw}"
907        );
908        #[cfg(unix)]
909        {
910            use std::os::unix::fs::PermissionsExt;
911            let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
912            assert_eq!(
913                mode, 0o600,
914                "conversation file must be owner-only, got {mode:o}"
915            );
916        }
917        // Live conversation untouched — the model still sees the real content in-session.
918        assert!(
919            conv.messages()[1]
920                .content
921                .contains("sk-abcdefghijklmnop1234")
922        );
923        let _ = fs::remove_dir_all(&dir);
924    }
925
926    #[test]
927    fn test_new_conversation_has_session_title() {
928        let conv =
929            ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
930        assert!(conv.title.starts_with("Session "));
931        assert_eq!(conv.model_name, "test-model");
932        assert_eq!(conv.project_path, "/tmp/project");
933        assert!(conv.messages().is_empty());
934    }
935
936    #[test]
937    fn test_title_updates_from_first_user_message() {
938        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
939        conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
940        assert_eq!(conv.title, "Fix the login bug");
941    }
942
943    #[test]
944    fn test_title_truncated_at_60_chars() {
945        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
946        let long_msg = "a".repeat(100);
947        conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
948        assert!(conv.title.ends_with("..."));
949        assert!(conv.title.len() <= 64); // 60 chars + "..."
950    }
951
952    #[test]
953    fn test_title_set_only_once() {
954        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
955        conv.add_messages(&[ChatMessage::user("First message")], Local::now());
956        conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
957        assert_eq!(conv.title, "First message");
958    }
959
960    #[test]
961    fn test_input_history_deduplication() {
962        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
963        conv.add_to_input_history("hello".into());
964        conv.add_to_input_history("hello".into()); // duplicate
965        conv.add_to_input_history("world".into());
966        assert_eq!(conv.input_history.len(), 2);
967    }
968
969    #[test]
970    fn test_input_history_skips_empty() {
971        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
972        conv.add_to_input_history("".into());
973        conv.add_to_input_history("   ".into());
974        assert_eq!(conv.input_history.len(), 0);
975    }
976
977    #[test]
978    fn test_input_history_capped_at_100() {
979        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
980        for i in 0..110 {
981            conv.add_to_input_history(format!("msg{i}"));
982        }
983        assert_eq!(conv.input_history.len(), 100);
984        assert_eq!(conv.input_history.front().unwrap(), "msg10");
985    }
986
987    #[test]
988    fn sidecar_powers_metadata_listing() {
989        let dir = std::env::temp_dir().join("mermaid_test_meta_sidecar");
990        let _ = fs::remove_dir_all(&dir);
991        let manager = ConversationManager::new(&dir).unwrap();
992        let mut conv = ConversationHistory::new("/tmp/proj".into(), "model".into(), Local::now());
993        conv.title = "My session".into();
994        conv.add_messages(
995            &[ChatMessage::user("hi"), ChatMessage::user("there")],
996            Local::now(),
997        );
998        manager.save_conversation(&conv).unwrap();
999
1000        assert!(
1001            manager
1002                .conversations_dir()
1003                .join(format!("{}.meta", conv.id))
1004                .exists()
1005        );
1006        let metas = manager.list_conversation_metas().unwrap();
1007        assert_eq!(metas.len(), 1);
1008        assert_eq!(metas[0].id, conv.id);
1009        assert_eq!(metas[0].title, "My session");
1010        assert_eq!(metas[0].message_count, 2);
1011
1012        // Deleting the session removes its sidecar too.
1013        manager.delete_conversation(&conv.id).unwrap();
1014        assert!(
1015            !manager
1016                .conversations_dir()
1017                .join(format!("{}.meta", conv.id))
1018                .exists()
1019        );
1020        assert!(manager.list_conversation_metas().unwrap().is_empty());
1021        let _ = fs::remove_dir_all(&dir);
1022    }
1023
1024    #[test]
1025    fn metadata_listing_falls_back_to_full_parse_without_sidecar() {
1026        let dir = std::env::temp_dir().join("mermaid_test_meta_fallback");
1027        let _ = fs::remove_dir_all(&dir);
1028        let manager = ConversationManager::new(&dir).unwrap();
1029        let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1030        conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1031        manager.save_conversation(&conv).unwrap();
1032        // Simulate a pre-sidecar session by removing the sidecar.
1033        fs::remove_file(
1034            manager
1035                .conversations_dir()
1036                .join(format!("{}.meta", conv.id)),
1037        )
1038        .unwrap();
1039        let metas = manager.list_conversation_metas().unwrap();
1040        assert_eq!(metas.len(), 1, "falls back to parsing the .json");
1041        assert_eq!(metas[0].message_count, 1);
1042        let _ = fs::remove_dir_all(&dir);
1043    }
1044
1045    #[test]
1046    fn lineage_fields_default_on_old_sessions() {
1047        // A transcript persisted before the lineage fields existed still loads.
1048        let json = r#"{"id":"x","title":"t","messages":[],"model_name":"m","project_path":"/p","created_at":"2026-01-01T00:00:00+00:00","updated_at":"2026-01-01T00:00:00+00:00","total_tokens":null}"#;
1049        let conv: ConversationHistory = serde_json::from_str(json).unwrap();
1050        assert!(conv.git_sha.is_none());
1051        assert!(conv.cli_version.is_none());
1052        assert!(conv.forked_from.is_none());
1053        assert!(conv.parent_session.is_none());
1054    }
1055
1056    #[test]
1057    fn test_save_load_roundtrip() {
1058        let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
1059        let _ = fs::remove_dir_all(&dir);
1060        let manager = ConversationManager::new(&dir).unwrap();
1061
1062        let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1063        conv.add_messages(&[ChatMessage::user("test message")], Local::now());
1064        conv.add_to_input_history("test message".into());
1065
1066        manager.save_conversation(&conv).unwrap();
1067        let loaded = manager.load_conversation(&conv.id).unwrap();
1068
1069        assert_eq!(loaded.id, conv.id);
1070        assert_eq!(loaded.title, conv.title);
1071        assert_eq!(loaded.messages().len(), 1);
1072        assert_eq!(loaded.input_history.len(), 1);
1073
1074        let _ = fs::remove_dir_all(&dir);
1075    }
1076
1077    #[test]
1078    fn test_list_conversations_ordered_by_updated_at() {
1079        let dir = std::env::temp_dir().join("mermaid_test_conv_list");
1080        let _ = fs::remove_dir_all(&dir);
1081        let manager = ConversationManager::new(&dir).unwrap();
1082
1083        let conv1 = touched("/tmp");
1084        std::thread::sleep(std::time::Duration::from_millis(10));
1085        let conv2 = touched("/tmp");
1086
1087        manager.save_conversation(&conv1).unwrap();
1088        manager.save_conversation(&conv2).unwrap();
1089
1090        let list = manager.list_conversations().unwrap();
1091        assert_eq!(list.len(), 2);
1092        // Newest first
1093        assert_eq!(list[0].id, conv2.id);
1094        assert_eq!(list[1].id, conv1.id);
1095
1096        let _ = fs::remove_dir_all(&dir);
1097    }
1098
1099    #[test]
1100    fn test_load_last_conversation() {
1101        let dir = std::env::temp_dir().join("mermaid_test_conv_last");
1102        let _ = fs::remove_dir_all(&dir);
1103        let manager = ConversationManager::new(&dir).unwrap();
1104
1105        assert!(manager.load_last_conversation().unwrap().is_none());
1106
1107        let conv = touched("/tmp");
1108        manager.save_conversation(&conv).unwrap();
1109
1110        let last = manager.load_last_conversation().unwrap().unwrap();
1111        assert_eq!(last.id, conv.id);
1112
1113        let _ = fs::remove_dir_all(&dir);
1114    }
1115
1116    #[test]
1117    fn test_load_last_conversation_picks_newest_by_mtime() {
1118        // Writes three conversations with staggered mtimes (via sleeps
1119        // between saves) and asserts the mtime-based picker returns the
1120        // last one written — even though filename-alphabetical ordering
1121        // would pick a different file.
1122        let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
1123        let _ = fs::remove_dir_all(&dir);
1124        let manager = ConversationManager::new(&dir).unwrap();
1125
1126        let conv1 = touched("/tmp");
1127        manager.save_conversation(&conv1).unwrap();
1128        std::thread::sleep(std::time::Duration::from_millis(10));
1129
1130        let conv2 = touched("/tmp");
1131        manager.save_conversation(&conv2).unwrap();
1132        std::thread::sleep(std::time::Duration::from_millis(10));
1133
1134        let conv3 = touched("/tmp");
1135        manager.save_conversation(&conv3).unwrap();
1136
1137        let last = manager.load_last_conversation().unwrap().unwrap();
1138        assert_eq!(
1139            last.id, conv3.id,
1140            "should return the most-recently-written file"
1141        );
1142
1143        let _ = fs::remove_dir_all(&dir);
1144    }
1145
1146    #[test]
1147    fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
1148        let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
1149        let _ = fs::remove_dir_all(&dir);
1150        let manager = ConversationManager::new(&dir).unwrap();
1151
1152        let good = touched("/tmp");
1153        manager.save_conversation(&good).unwrap();
1154        std::thread::sleep(std::time::Duration::from_millis(10));
1155
1156        // Plant a NEWER, corrupt file (well-formed name, garbage contents): the
1157        // newest-by-mtime entry is unparseable, so #68 must skip it.
1158        let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
1159        fs::write(&corrupt, b"{ not valid json").unwrap();
1160
1161        let last = manager.load_last_conversation().unwrap().unwrap();
1162        assert_eq!(
1163            last.id, good.id,
1164            "must fall back to the newest VALID conversation"
1165        );
1166        let _ = fs::remove_dir_all(&dir);
1167    }
1168
1169    #[test]
1170    fn load_last_conversation_none_when_only_corrupt() {
1171        let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
1172        let _ = fs::remove_dir_all(&dir);
1173        let manager = ConversationManager::new(&dir).unwrap();
1174        fs::write(
1175            manager.conversations_dir().join("20991231_235959_998.json"),
1176            b"nope",
1177        )
1178        .unwrap();
1179        assert!(manager.load_last_conversation().unwrap().is_none());
1180        let _ = fs::remove_dir_all(&dir);
1181    }
1182
1183    #[test]
1184    fn load_conversation_tolerates_unknown_message_role() {
1185        // F74: a conversation written by a NEWER build may carry a MessageRole
1186        // this build doesn't model. It must still load — the unknown role maps to
1187        // a neutral System message — so `--continue` doesn't silently skip the
1188        // newest session (the prior behavior, when the whole parse hard-failed).
1189        let dir =
1190            std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
1191        let _ = fs::remove_dir_all(&dir);
1192        let manager = ConversationManager::new(&dir).unwrap();
1193
1194        let id = "20260101_120000_001";
1195        let json = format!(
1196            r#"{{
1197                "id": "{id}",
1198                "title": "skew",
1199                "messages": [
1200                    {{
1201                        "role": "Developer",
1202                        "content": "from a newer build",
1203                        "timestamp": "2026-01-01T12:00:00-04:00"
1204                    }}
1205                ],
1206                "model_name": "m",
1207                "project_path": "/tmp",
1208                "created_at": "2026-01-01T12:00:00-04:00",
1209                "updated_at": "2026-01-01T12:00:00-04:00",
1210                "total_tokens": null
1211            }}"#
1212        );
1213        fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
1214
1215        let loaded = manager
1216            .load_conversation(id)
1217            .expect("must load despite an unknown role");
1218        assert_eq!(loaded.messages().len(), 1);
1219        assert_eq!(
1220            loaded.messages()[0].role,
1221            MessageRole::System,
1222            "an unknown role becomes a neutral System message"
1223        );
1224
1225        // And `--continue`'s newest-valid picker returns it instead of skipping.
1226        let last = manager
1227            .load_last_conversation()
1228            .unwrap()
1229            .expect("the newest session must load");
1230        assert_eq!(last.id, id);
1231
1232        let _ = fs::remove_dir_all(&dir);
1233    }
1234
1235    #[test]
1236    fn test_delete_conversation() {
1237        let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
1238        let _ = fs::remove_dir_all(&dir);
1239        let manager = ConversationManager::new(&dir).unwrap();
1240
1241        let conv = touched("/tmp");
1242        manager.save_conversation(&conv).unwrap();
1243        assert_eq!(manager.list_conversations().unwrap().len(), 1);
1244
1245        manager.delete_conversation(&conv.id).unwrap();
1246        assert_eq!(manager.list_conversations().unwrap().len(), 0);
1247
1248        let _ = fs::remove_dir_all(&dir);
1249    }
1250
1251    #[test]
1252    fn empty_session_is_not_saved() {
1253        let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
1254        let _ = fs::remove_dir_all(&dir);
1255        let manager = ConversationManager::new(&dir).unwrap();
1256
1257        // An untouched (message-less) conversation must not create a file.
1258        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1259        manager.save_conversation(&conv).unwrap();
1260        assert!(
1261            manager.list_conversations().unwrap().is_empty(),
1262            "empty session must not be listed"
1263        );
1264        assert!(
1265            manager.load_last_conversation().unwrap().is_none(),
1266            "empty session must not be --continue-able"
1267        );
1268
1269        // The first real message makes it persist.
1270        conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1271        manager.save_conversation(&conv).unwrap();
1272        assert_eq!(manager.list_conversations().unwrap().len(), 1);
1273
1274        let _ = fs::remove_dir_all(&dir);
1275    }
1276
1277    #[test]
1278    fn resume_paths_skip_pre_existing_empty_files() {
1279        // An empty session file planted directly on disk (e.g. saved before this
1280        // guard existed) must be invisible to the picker and to `--continue`.
1281        let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
1282        let _ = fs::remove_dir_all(&dir);
1283        let manager = ConversationManager::new(&dir).unwrap();
1284
1285        let real = touched("/tmp");
1286        manager.save_conversation(&real).unwrap();
1287        // A NEWER empty file, written straight to disk to bypass the save guard.
1288        std::thread::sleep(std::time::Duration::from_millis(10));
1289        let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1290        let path = manager
1291            .conversations_dir()
1292            .join(format!("{}.json", empty.id));
1293        fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
1294
1295        let list = manager.list_conversations().unwrap();
1296        assert_eq!(list.len(), 1, "the empty file must not be listed");
1297        assert_eq!(list[0].id, real.id);
1298        assert_eq!(
1299            manager.load_last_conversation().unwrap().unwrap().id,
1300            real.id,
1301            "--continue must skip the newer empty file"
1302        );
1303
1304        let _ = fs::remove_dir_all(&dir);
1305    }
1306
1307    #[test]
1308    fn read_conversation_capped_refuses_oversized_file() {
1309        // #129: a file over the cap is refused before it's read into RAM. Use a
1310        // sparse file so the test stays fast and doesn't actually write 64 MiB.
1311        let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
1312        let _ = fs::remove_dir_all(&dir);
1313        fs::create_dir_all(&dir).unwrap();
1314
1315        let small = dir.join("small.json");
1316        fs::write(&small, b"{}").unwrap();
1317        assert!(read_conversation_capped(&small).is_ok());
1318
1319        let big = dir.join("big.json");
1320        let f = fs::File::create(&big).unwrap();
1321        f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
1322        assert!(
1323            read_conversation_capped(&big).is_err(),
1324            "a file over the cap must be refused, not slurped into memory"
1325        );
1326
1327        let _ = fs::remove_dir_all(&dir);
1328    }
1329
1330    // The two tests that lived here pinned the snapshot-side F73 guard.
1331    // That guard moved to the append, so its coverage moved with it:
1332    // event_log::tests::a_second_writer_diverts_this_process_to_a_conflict_sibling.
1333    // Keeping them here would assert that a derived cache defends itself
1334    // against a writer that no longer races for it.
1335}