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