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