Skip to main content

mermaid_cli/session/
conversation.rs

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