Skip to main content

mermaid_cli/session/
conversation.rs

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