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    pub total_tokens: Option<usize>,
96    /// Metadata for context compactions performed in this conversation.
97    #[serde(default)]
98    pub compactions: Vec<crate::domain::CompactionRecord>,
99    /// History of user input prompts for navigation (up/down arrows)
100    #[serde(default)]
101    pub input_history: VecDeque<String>,
102    /// Git branch this session was worked on, for the `--resume` picker.
103    /// Captured at startup (impure) — `ConversationHistory::new` leaves it
104    /// `None` so the pure reducer / `--replay` stay deterministic. Empty for
105    /// non-git dirs and for sessions saved before this field existed.
106    #[serde(default)]
107    pub git_branch: Option<String>,
108}
109
110/// Best-effort current git branch of `dir`, for labelling `--resume` rows.
111/// `None` when `dir` isn't a git work tree, git is absent, or HEAD is
112/// detached. Kept out of the pure reducer — callers invoke it in the impure
113/// startup path and stamp the result onto the conversation.
114pub fn detect_git_branch(dir: &Path) -> Option<String> {
115    let output = std::process::Command::new("git")
116        .args(["rev-parse", "--abbrev-ref", "HEAD"])
117        .current_dir(dir)
118        .output()
119        .ok()?;
120    if !output.status.success() {
121        return None;
122    }
123    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
124    // A detached HEAD reports the literal "HEAD"; treat that as no branch.
125    (!branch.is_empty() && branch != "HEAD").then_some(branch)
126}
127
128impl ConversationHistory {
129    /// Create a new conversation history.
130    ///
131    /// `now` is injected rather than read from the wall clock because this
132    /// runs inside the pure reducer (`/clear` mints a fresh conversation, and
133    /// `State::new` mints the initial one): the id and title are a
134    /// deterministic function of the caller's clock, so `--replay` reproduces
135    /// them exactly.
136    pub fn new(project_path: String, model_name: String, now: DateTime<Local>) -> Self {
137        // Include subsecond precision to avoid ID collisions within the same second
138        let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
139        Self {
140            id: id.clone(),
141            title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
142            messages: Vec::new(),
143            model_name,
144            project_path,
145            created_at: now,
146            updated_at: now,
147            total_tokens: None,
148            compactions: Vec::new(),
149            input_history: VecDeque::new(),
150            // Populated by the impure startup path (`detect_git_branch`), not
151            // here — the reducer stays pure/deterministic for `--replay`.
152            git_branch: None,
153        }
154    }
155
156    /// Add messages to the conversation. `now` is the caller's injected
157    /// clock (`state.now` inside the reducer) — mutation methods never read
158    /// the wall clock themselves so `update()` stays a pure function.
159    pub fn add_messages(&mut self, messages: &[ChatMessage], now: DateTime<Local>) {
160        self.messages.extend_from_slice(messages);
161        self.updated_at = now;
162        self.update_title();
163    }
164
165    /// Replace the model-visible message log without deriving a new title.
166    /// Used by context compaction: the original title still describes the
167    /// session better than the generated checkpoint. The messages keep their
168    /// own timestamps (they rode in on the `Msg` payload); only `updated_at`
169    /// is stamped, from the injected clock.
170    pub fn replace_messages(&mut self, messages: Vec<ChatMessage>, now: DateTime<Local>) {
171        self.messages = messages;
172        self.updated_at = now;
173    }
174
175    /// Record a completed context compaction. `now` injected — see
176    /// [`Self::add_messages`].
177    pub fn add_compaction(
178        &mut self,
179        record: crate::domain::CompactionRecord,
180        now: DateTime<Local>,
181    ) {
182        self.compactions.push(record);
183        self.updated_at = now;
184    }
185
186    /// Add input to history (with deduplication of consecutive identical inputs)
187    pub fn add_to_input_history(&mut self, input: String) {
188        // Skip empty inputs
189        if input.trim().is_empty() {
190            return;
191        }
192
193        // Don't add if it's identical to the last entry
194        if let Some(last) = self.input_history.back()
195            && last == &input
196        {
197            return;
198        }
199
200        // Cap history at 100 entries to prevent unbounded growth
201        if self.input_history.len() >= 100 {
202            self.input_history.pop_front(); // O(1) instead of O(n)
203        }
204
205        self.input_history.push_back(input);
206    }
207
208    /// Update the title based on the first user message.
209    /// Short-circuits if the title was already derived from a user message.
210    fn update_title(&mut self) {
211        // Only set title once — it comes from the first user message
212        if !self.title.starts_with("Session ") {
213            return;
214        }
215        if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
216            let preview = if first_user_msg.content.len() > 60 {
217                let end = first_user_msg.content.floor_char_boundary(60);
218                format!("{}...", &first_user_msg.content[..end])
219            } else {
220                first_user_msg.content.clone()
221            };
222            self.title = preview;
223        }
224    }
225
226    /// Get a summary for display
227    pub fn summary(&self) -> String {
228        let message_count = self.messages.len();
229        let duration = self.updated_at.signed_duration_since(self.created_at);
230        let hours = duration.num_hours();
231        let minutes = duration.num_minutes() % 60;
232
233        format!(
234            "{} | {} messages | {}h {}m | {}",
235            self.updated_at.format("%Y-%m-%d %H:%M"),
236            message_count,
237            hours,
238            minutes,
239            self.title
240        )
241    }
242}
243
244/// Cheap fingerprint of a file on disk used for optimistic-concurrency
245/// detection (F73): an `(mtime, len)` pair. A concurrent writer that rewrites a
246/// conversation almost always changes the length (different message count) and
247/// the mtime, so a mismatch against the value captured at load/last-save flags
248/// the clobber without parsing the file.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250struct FileStamp {
251    mtime: SystemTime,
252    len: u64,
253}
254
255/// Stat `path` into a [`FileStamp`]. `None` when the file is absent/unreadable.
256fn file_stamp(path: &Path) -> Option<FileStamp> {
257    let meta = fs::metadata(path).ok()?;
258    let mtime = meta.modified().ok()?;
259    Some(FileStamp {
260        mtime,
261        len: meta.len(),
262    })
263}
264
265/// Process-unique counter for `.conflict` sibling filenames so two conflicts on
266/// the same id within one process don't collide.
267static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
268
269/// Manages conversation persistence for a project
270#[derive(Clone)]
271pub struct ConversationManager {
272    conversations_dir: PathBuf,
273    compactions_dir: PathBuf,
274    /// Per-id `(mtime, len)` of the conversation file as THIS process last
275    /// observed it — recorded at load and after each of our own saves. Used to
276    /// detect a concurrent writer before `save_conversation` overwrites (F73).
277    /// Shared across clones of the manager (same process) via the `Arc`, so a
278    /// cloned manager sees the same baselines; separate processes have separate
279    /// maps, which is exactly the cross-process clobber we want to catch.
280    seen: Arc<Mutex<HashMap<String, FileStamp>>>,
281}
282
283impl ConversationManager {
284    /// Create a new conversation manager for a project directory
285    pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
286        let mermaid_dir = project_dir.as_ref().join(".mermaid");
287        let conversations_dir = mermaid_dir.join("conversations");
288        let compactions_dir = mermaid_dir.join("compactions");
289
290        // Create conversations directory if it doesn't exist
291        fs::create_dir_all(&conversations_dir)?;
292        fs::create_dir_all(&compactions_dir)?;
293
294        Ok(Self {
295            conversations_dir,
296            compactions_dir,
297            seen: Arc::new(Mutex::new(HashMap::new())),
298        })
299    }
300
301    /// Record the on-disk `(mtime, len)` of `path` as the baseline for `id`, so a
302    /// later `save_conversation` can tell whether a concurrent writer touched the
303    /// file since we read or wrote it (F73). Called on load and after our own
304    /// saves. Best-effort: an unreadable file simply leaves no baseline.
305    fn record_stamp(&self, id: &str, path: &Path) {
306        if let Some(stamp) = file_stamp(path) {
307            self.seen
308                .lock()
309                .unwrap_or_else(|e| e.into_inner())
310                .insert(id.to_string(), stamp);
311        }
312    }
313
314    /// Path of a `.conflict` sibling for `id`. Deliberately ends in `.conflict`
315    /// (not `.json`) so it never shows up in `list_conversations` /
316    /// `load_last_conversation`, which only consider `*.json`. `id` is validated
317    /// by the caller before this runs, so the filename can't traverse.
318    fn conflict_sibling_path(&self, id: &str) -> PathBuf {
319        let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
320        self.conversations_dir
321            .join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
322    }
323
324    /// Save a conversation to disk
325    pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
326        // The id field is persisted and round-trips through (potentially
327        // tampered) on-disk state; validate it before it drives the write path,
328        // so a loaded conversation can't escape the conversations dir on save.
329        validate_conversation_id(&conversation.id)?;
330        let filename = format!("{}.json", conversation.id);
331        let path = self.conversations_dir.join(filename);
332
333        // Strip computer-use screenshot bytes before they hit disk (#99). Only
334        // clones the conversation when there is actually something to scrub.
335        let json = match strip_persisted_screenshots(&conversation.messages) {
336            Some(sanitized) => {
337                let mut redacted = conversation.clone();
338                redacted.messages = sanitized;
339                serde_json::to_string_pretty(&redacted)?
340            },
341            None => serde_json::to_string_pretty(conversation)?,
342        };
343
344        // Optimistic-concurrency guard (F73). Without this, two processes (e.g. a
345        // daemon `run` and an interactive session) saving the same id do blind
346        // last-writer-wins and silently clobber each other. If the file on disk
347        // changed since we last read or wrote it — a concurrent writer — don't
348        // overwrite: preserve our copy in a `.conflict` sibling and warn. The
349        // baseline is per-process (`seen`), recorded at load and after our own
350        // saves, so our OWN repeated saves don't false-positive.
351        let baseline = self
352            .seen
353            .lock()
354            .unwrap_or_else(|e| e.into_inner())
355            .get(&conversation.id)
356            .copied();
357        if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
358            && current != base
359        {
360            let sibling = self.conflict_sibling_path(&conversation.id);
361            // Keep the existing atomic-write for the preserved copy too.
362            crate::runtime::write_atomic(&sibling, json.as_bytes())?;
363            tracing::warn!(
364                id = %conversation.id,
365                main = %path.display(),
366                conflict = %sibling.display(),
367                "conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
368            );
369            return Ok(());
370        }
371
372        // Atomic write: a crash mid-save must not empty/corrupt the session
373        // file (this is the hot path, rewritten after nearly every message).
374        crate::runtime::write_atomic(&path, json.as_bytes())?;
375        // Refresh our baseline to the file we just wrote so the NEXT save by this
376        // process compares against our own write, not the pre-save state.
377        self.record_stamp(&conversation.id, &path);
378
379        Ok(())
380    }
381
382    /// Save the raw messages removed by a compaction. Archives live
383    /// outside the hot conversation JSON so `/load` and `/list` don't
384    /// parse old transcripts on every startup.
385    pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
386        // Both the conversation id (a directory component) and the archive id
387        // (a file component) come from persisted state and must not traverse.
388        validate_conversation_id(&archive.conversation_id)?;
389        anyhow::ensure!(
390            !archive.id.is_empty()
391                && !archive.id.contains(['/', '\\'])
392                && !archive.id.contains(".."),
393            "invalid compaction archive id: {:?}",
394            archive.id
395        );
396        let dir = self.compactions_dir.join(&archive.conversation_id);
397        fs::create_dir_all(&dir)?;
398        let path = dir.join(format!("{}.json", archive.id));
399        // The archive is the only durable copy of compacted-out messages; scrub
400        // screenshot bytes here too so they don't survive in compaction archives
401        // (#99). Clones only when a screenshot is actually present.
402        let json = match strip_persisted_screenshots(&archive.messages) {
403            Some(sanitized) => {
404                let mut redacted = archive.clone();
405                redacted.messages = sanitized;
406                serde_json::to_string_pretty(&redacted)?
407            },
408            None => serde_json::to_string_pretty(archive)?,
409        };
410        // Atomic write: the archive is the ONLY durable copy of messages
411        // dropped by a compaction — a partial write would lose them.
412        crate::runtime::write_atomic(&path, json.as_bytes())?;
413        Ok(path)
414    }
415
416    /// Load a specific conversation by ID
417    pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
418        validate_conversation_id(id)?;
419        let filename = format!("{}.json", id);
420        let path = self.conversations_dir.join(filename);
421
422        let json = read_conversation_capped(&path)?;
423        let conversation: ConversationHistory = serde_json::from_str(&json)?;
424        // The file name was validated, but the deserialized `id` (which drives
425        // later saves) is independent on-disk state — validate it too.
426        validate_conversation_id(&conversation.id)?;
427
428        // Capture the load-time baseline so a later save can detect a concurrent
429        // writer that touched this file in between (F73).
430        self.record_stamp(&conversation.id, &path);
431
432        Ok(conversation)
433    }
434
435    /// Load the most recent *valid* conversation.
436    ///
437    /// Iterates files newest-first by mtime and returns the first that reads,
438    /// parses, and has a valid id — skipping (with a warning) any unreadable,
439    /// unparseable, or traversing-id file. Mirrors `list_conversations`'s
440    /// tolerance so one corrupt/partial file (e.g. a crash mid-write) can't make
441    /// `--continue` hard-fail; it falls back to the next-newest valid conversation.
442    pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
443        let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
444            return Ok(None);
445        };
446
447        let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
448            .flatten()
449            .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
450            .filter_map(|e| {
451                let mtime = e.metadata().ok()?.modified().ok()?;
452                Some((mtime, e.path()))
453            })
454            .collect();
455        candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
456
457        for (_, path) in candidates {
458            let Ok(json) = read_conversation_capped(&path) else {
459                tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
460                continue;
461            };
462            let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
463                tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
464                continue;
465            };
466            // A planted session file with a traversing `id` must not become the
467            // resumed conversation (its id would later drive an out-of-dir save).
468            if validate_conversation_id(&conv.id).is_err() {
469                tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
470                continue;
471            }
472            // Capture the load-time baseline for the optimistic-concurrency
473            // guard so a later save can detect a concurrent writer (F73).
474            self.record_stamp(&conv.id, &path);
475            return Ok(Some(conv));
476        }
477        Ok(None)
478    }
479
480    /// List all conversations in the project
481    pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
482        let mut conversations = Vec::new();
483
484        // Read all JSON files in the conversations directory
485        if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
486            for entry in entries.flatten() {
487                if let Some(ext) = entry.path().extension()
488                    && ext == "json"
489                    && let Ok(json) = read_conversation_capped(&entry.path())
490                    && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
491                {
492                    conversations.push(conv);
493                }
494            }
495        }
496
497        // Sort by updated_at (newest first)
498        conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
499
500        Ok(conversations)
501    }
502
503    /// Delete a conversation
504    pub fn delete_conversation(&self, id: &str) -> Result<()> {
505        validate_conversation_id(id)?;
506        let filename = format!("{}.json", id);
507        let path = self.conversations_dir.join(filename);
508
509        if path.exists() {
510            fs::remove_file(path)?;
511        }
512
513        Ok(())
514    }
515
516    /// Get the conversations directory path
517    pub fn conversations_dir(&self) -> &Path {
518        &self.conversations_dir
519    }
520
521    pub fn compactions_dir(&self) -> &Path {
522        &self.compactions_dir
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn legacy_conversation_json_without_git_branch_deserializes() {
532        // Every session saved before the `--resume` picker existed lacks a
533        // `git_branch` key; `#[serde(default)]` must load it as `None` rather
534        // than failing the picker's `list_conversations`.
535        let json = r#"{
536            "id": "20260101_120000_001",
537            "title": "Legacy session",
538            "messages": [],
539            "model_name": "ollama/test",
540            "project_path": "/tmp/proj",
541            "created_at": "2026-01-01T12:00:00-05:00",
542            "updated_at": "2026-01-01T12:00:00-05:00",
543            "total_tokens": null
544        }"#;
545        let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
546        assert!(conv.git_branch.is_none());
547        assert_eq!(conv.title, "Legacy session");
548        // And a round-trip of a branch-bearing conversation preserves it.
549        let mut fresh =
550            ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
551        fresh.git_branch = Some("feature/x".to_string());
552        let round: ConversationHistory =
553            serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
554        assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
555    }
556
557    #[test]
558    fn validate_conversation_id_rejects_traversal() {
559        assert!(validate_conversation_id("20260101_120000_001").is_ok());
560        assert!(validate_conversation_id("../secret").is_err());
561        assert!(validate_conversation_id("..\\secret").is_err());
562        assert!(validate_conversation_id("/etc/passwd").is_err());
563        assert!(validate_conversation_id("20260101_120000").is_err()); // too short
564        assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); // non-digits
565    }
566
567    #[test]
568    fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
569        let messages = vec![
570            ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
571            ChatMessage::assistant("here is the screen")
572                .with_images(vec!["SCREENSHOT_B64".to_string()]),
573            ChatMessage::assistant("no image here"),
574        ];
575        let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
576        // User-supplied image preserved.
577        assert_eq!(
578            sanitized[0].images.as_deref(),
579            Some(["USER_PASTED_B64".to_string()].as_slice())
580        );
581        // Assistant screenshot dropped + marker added.
582        assert!(sanitized[1].images.is_none());
583        assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
584        // Untouched assistant message is unchanged (no spurious marker).
585        assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
586    }
587
588    #[test]
589    fn strip_persisted_screenshots_is_none_without_assistant_images() {
590        let messages = vec![
591            ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
592            ChatMessage::assistant("no images"),
593        ];
594        assert!(strip_persisted_screenshots(&messages).is_none());
595    }
596
597    #[test]
598    fn saved_conversation_json_has_no_screenshot_bytes() {
599        let dir = std::env::temp_dir().join("mermaid_strip_test");
600        let _ = fs::create_dir_all(&dir);
601        let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
602        conv.messages = vec![
603            ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
604            ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
605        ];
606        let store = ConversationManager {
607            conversations_dir: dir.clone(),
608            compactions_dir: dir.clone(),
609            seen: Arc::new(Mutex::new(HashMap::new())),
610        };
611        store.save_conversation(&conv).expect("save");
612        let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
613        assert!(
614            !raw.contains("SHOTBYTES"),
615            "screenshot leaked to disk: {raw}"
616        );
617        assert!(raw.contains("USERIMG"), "user image should persist");
618        // Live conversation untouched — still carries the screenshot in-session.
619        assert_eq!(
620            conv.messages[1].images.as_deref(),
621            Some(["SHOTBYTES".to_string()].as_slice())
622        );
623        let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
624    }
625
626    #[test]
627    fn test_new_conversation_has_session_title() {
628        let conv =
629            ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
630        assert!(conv.title.starts_with("Session "));
631        assert_eq!(conv.model_name, "test-model");
632        assert_eq!(conv.project_path, "/tmp/project");
633        assert!(conv.messages.is_empty());
634    }
635
636    #[test]
637    fn test_title_updates_from_first_user_message() {
638        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
639        conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
640        assert_eq!(conv.title, "Fix the login bug");
641    }
642
643    #[test]
644    fn test_title_truncated_at_60_chars() {
645        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
646        let long_msg = "a".repeat(100);
647        conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
648        assert!(conv.title.ends_with("..."));
649        assert!(conv.title.len() <= 64); // 60 chars + "..."
650    }
651
652    #[test]
653    fn test_title_set_only_once() {
654        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
655        conv.add_messages(&[ChatMessage::user("First message")], Local::now());
656        conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
657        assert_eq!(conv.title, "First message");
658    }
659
660    #[test]
661    fn test_input_history_deduplication() {
662        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
663        conv.add_to_input_history("hello".into());
664        conv.add_to_input_history("hello".into()); // duplicate
665        conv.add_to_input_history("world".into());
666        assert_eq!(conv.input_history.len(), 2);
667    }
668
669    #[test]
670    fn test_input_history_skips_empty() {
671        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
672        conv.add_to_input_history("".into());
673        conv.add_to_input_history("   ".into());
674        assert_eq!(conv.input_history.len(), 0);
675    }
676
677    #[test]
678    fn test_input_history_capped_at_100() {
679        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
680        for i in 0..110 {
681            conv.add_to_input_history(format!("msg{}", i));
682        }
683        assert_eq!(conv.input_history.len(), 100);
684        assert_eq!(conv.input_history.front().unwrap(), "msg10");
685    }
686
687    #[test]
688    fn test_save_load_roundtrip() {
689        let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
690        let _ = fs::remove_dir_all(&dir);
691        let manager = ConversationManager::new(&dir).unwrap();
692
693        let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
694        conv.add_messages(&[ChatMessage::user("test message")], Local::now());
695        conv.add_to_input_history("test message".into());
696
697        manager.save_conversation(&conv).unwrap();
698        let loaded = manager.load_conversation(&conv.id).unwrap();
699
700        assert_eq!(loaded.id, conv.id);
701        assert_eq!(loaded.title, conv.title);
702        assert_eq!(loaded.messages.len(), 1);
703        assert_eq!(loaded.input_history.len(), 1);
704
705        let _ = fs::remove_dir_all(&dir);
706    }
707
708    #[test]
709    fn test_list_conversations_ordered_by_updated_at() {
710        let dir = std::env::temp_dir().join("mermaid_test_conv_list");
711        let _ = fs::remove_dir_all(&dir);
712        let manager = ConversationManager::new(&dir).unwrap();
713
714        let conv1 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
715        std::thread::sleep(std::time::Duration::from_millis(10));
716        let conv2 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
717
718        manager.save_conversation(&conv1).unwrap();
719        manager.save_conversation(&conv2).unwrap();
720
721        let list = manager.list_conversations().unwrap();
722        assert_eq!(list.len(), 2);
723        // Newest first
724        assert_eq!(list[0].id, conv2.id);
725        assert_eq!(list[1].id, conv1.id);
726
727        let _ = fs::remove_dir_all(&dir);
728    }
729
730    #[test]
731    fn test_load_last_conversation() {
732        let dir = std::env::temp_dir().join("mermaid_test_conv_last");
733        let _ = fs::remove_dir_all(&dir);
734        let manager = ConversationManager::new(&dir).unwrap();
735
736        assert!(manager.load_last_conversation().unwrap().is_none());
737
738        let conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
739        manager.save_conversation(&conv).unwrap();
740
741        let last = manager.load_last_conversation().unwrap().unwrap();
742        assert_eq!(last.id, conv.id);
743
744        let _ = fs::remove_dir_all(&dir);
745    }
746
747    #[test]
748    fn test_load_last_conversation_picks_newest_by_mtime() {
749        // Writes three conversations with staggered mtimes (via sleeps
750        // between saves) and asserts the mtime-based picker returns the
751        // last one written — even though filename-alphabetical ordering
752        // would pick a different file.
753        let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
754        let _ = fs::remove_dir_all(&dir);
755        let manager = ConversationManager::new(&dir).unwrap();
756
757        let conv1 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
758        manager.save_conversation(&conv1).unwrap();
759        std::thread::sleep(std::time::Duration::from_millis(10));
760
761        let conv2 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
762        manager.save_conversation(&conv2).unwrap();
763        std::thread::sleep(std::time::Duration::from_millis(10));
764
765        let conv3 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
766        manager.save_conversation(&conv3).unwrap();
767
768        let last = manager.load_last_conversation().unwrap().unwrap();
769        assert_eq!(
770            last.id, conv3.id,
771            "should return the most-recently-written file"
772        );
773
774        let _ = fs::remove_dir_all(&dir);
775    }
776
777    #[test]
778    fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
779        let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
780        let _ = fs::remove_dir_all(&dir);
781        let manager = ConversationManager::new(&dir).unwrap();
782
783        let good = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
784        manager.save_conversation(&good).unwrap();
785        std::thread::sleep(std::time::Duration::from_millis(10));
786
787        // Plant a NEWER, corrupt file (well-formed name, garbage contents): the
788        // newest-by-mtime entry is unparseable, so #68 must skip it.
789        let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
790        fs::write(&corrupt, b"{ not valid json").unwrap();
791
792        let last = manager.load_last_conversation().unwrap().unwrap();
793        assert_eq!(
794            last.id, good.id,
795            "must fall back to the newest VALID conversation"
796        );
797        let _ = fs::remove_dir_all(&dir);
798    }
799
800    #[test]
801    fn load_last_conversation_none_when_only_corrupt() {
802        let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
803        let _ = fs::remove_dir_all(&dir);
804        let manager = ConversationManager::new(&dir).unwrap();
805        fs::write(
806            manager.conversations_dir().join("20991231_235959_998.json"),
807            b"nope",
808        )
809        .unwrap();
810        assert!(manager.load_last_conversation().unwrap().is_none());
811        let _ = fs::remove_dir_all(&dir);
812    }
813
814    #[test]
815    fn load_conversation_tolerates_unknown_message_role() {
816        // F74: a conversation written by a NEWER build may carry a MessageRole
817        // this build doesn't model. It must still load — the unknown role maps to
818        // a neutral System message — so `--continue` doesn't silently skip the
819        // newest session (the prior behavior, when the whole parse hard-failed).
820        let dir =
821            std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
822        let _ = fs::remove_dir_all(&dir);
823        let manager = ConversationManager::new(&dir).unwrap();
824
825        let id = "20260101_120000_001";
826        let json = format!(
827            r#"{{
828                "id": "{id}",
829                "title": "skew",
830                "messages": [
831                    {{
832                        "role": "Developer",
833                        "content": "from a newer build",
834                        "timestamp": "2026-01-01T12:00:00-04:00"
835                    }}
836                ],
837                "model_name": "m",
838                "project_path": "/tmp",
839                "created_at": "2026-01-01T12:00:00-04:00",
840                "updated_at": "2026-01-01T12:00:00-04:00",
841                "total_tokens": null
842            }}"#
843        );
844        fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
845
846        let loaded = manager
847            .load_conversation(id)
848            .expect("must load despite an unknown role");
849        assert_eq!(loaded.messages.len(), 1);
850        assert_eq!(
851            loaded.messages[0].role,
852            MessageRole::System,
853            "an unknown role becomes a neutral System message"
854        );
855
856        // And `--continue`'s newest-valid picker returns it instead of skipping.
857        let last = manager
858            .load_last_conversation()
859            .unwrap()
860            .expect("the newest session must load");
861        assert_eq!(last.id, id);
862
863        let _ = fs::remove_dir_all(&dir);
864    }
865
866    #[test]
867    fn test_delete_conversation() {
868        let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
869        let _ = fs::remove_dir_all(&dir);
870        let manager = ConversationManager::new(&dir).unwrap();
871
872        let conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
873        manager.save_conversation(&conv).unwrap();
874        assert_eq!(manager.list_conversations().unwrap().len(), 1);
875
876        manager.delete_conversation(&conv.id).unwrap();
877        assert_eq!(manager.list_conversations().unwrap().len(), 0);
878
879        let _ = fs::remove_dir_all(&dir);
880    }
881
882    #[test]
883    fn read_conversation_capped_refuses_oversized_file() {
884        // #129: a file over the cap is refused before it's read into RAM. Use a
885        // sparse file so the test stays fast and doesn't actually write 64 MiB.
886        let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
887        let _ = fs::remove_dir_all(&dir);
888        fs::create_dir_all(&dir).unwrap();
889
890        let small = dir.join("small.json");
891        fs::write(&small, b"{}").unwrap();
892        assert!(read_conversation_capped(&small).is_ok());
893
894        let big = dir.join("big.json");
895        let f = fs::File::create(&big).unwrap();
896        f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
897        assert!(
898            read_conversation_capped(&big).is_err(),
899            "a file over the cap must be refused, not slurped into memory"
900        );
901
902        let _ = fs::remove_dir_all(&dir);
903    }
904
905    #[test]
906    fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
907        // F73: a daemon `run` and an interactive session can both hold the same
908        // conversation id. Blind last-writer-wins silently drops one side's
909        // edits. The optimistic-concurrency guard must detect the concurrent
910        // write and preserve our copy in a `.conflict` sibling instead of
911        // clobbering the other writer's file.
912        let dir =
913            std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
914        let _ = fs::remove_dir_all(&dir);
915        let manager = ConversationManager::new(&dir).unwrap();
916
917        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
918        conv.add_messages(&[ChatMessage::user("ours")], Local::now());
919        manager.save_conversation(&conv).unwrap();
920        let main = manager
921            .conversations_dir()
922            .join(format!("{}.json", conv.id));
923
924        // A SEPARATE process (its own baseline map) loads the same conversation,
925        // appends, and saves — growing the file. This is the concurrent writer.
926        let other = ConversationManager::new(&dir).unwrap();
927        let mut their_conv = other.load_conversation(&conv.id).unwrap();
928        their_conv.add_messages(
929            &[ChatMessage::user("theirs - extra content here")],
930            Local::now(),
931        );
932        other.save_conversation(&their_conv).unwrap();
933
934        // Our next save still holds the pre-concurrent baseline, so it must NOT
935        // overwrite the other writer's file.
936        manager.save_conversation(&conv).unwrap();
937        let on_disk: ConversationHistory =
938            serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
939        assert_eq!(
940            on_disk.messages.len(),
941            2,
942            "the concurrent writer's file must be left intact"
943        );
944
945        // Our copy is preserved in exactly one `.conflict` sibling.
946        let mut conflicts = fs::read_dir(manager.conversations_dir())
947            .unwrap()
948            .flatten()
949            .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
950            .map(|e| e.path())
951            .collect::<Vec<_>>();
952        assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
953        let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
954        assert!(
955            sibling.contains("ours") && !sibling.contains("theirs"),
956            "the .conflict sibling holds OUR copy, not the concurrent writer's"
957        );
958
959        // The `.conflict` sibling must not pollute the conversation listing
960        // (it isn't a `*.json` file).
961        let listed = manager.list_conversations().unwrap();
962        assert_eq!(
963            listed.len(),
964            1,
965            ".conflict sibling must not appear as a conversation"
966        );
967        assert_eq!(listed[0].id, conv.id);
968
969        let _ = fs::remove_dir_all(&dir);
970    }
971
972    #[test]
973    fn save_conversation_repeated_self_saves_do_not_conflict() {
974        // The guard must not false-positive on a single process's OWN repeated
975        // saves (the hot path rewrites the file after nearly every message).
976        let dir =
977            std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
978        let _ = fs::remove_dir_all(&dir);
979        let manager = ConversationManager::new(&dir).unwrap();
980
981        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
982        conv.add_messages(&[ChatMessage::user("first")], Local::now());
983        manager.save_conversation(&conv).unwrap();
984        conv.add_messages(&[ChatMessage::user("second")], Local::now());
985        manager.save_conversation(&conv).unwrap();
986
987        let conflicts = fs::read_dir(manager.conversations_dir())
988            .unwrap()
989            .flatten()
990            .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
991            .count();
992        assert_eq!(
993            conflicts, 0,
994            "our own repeated saves must not be flagged as conflicts"
995        );
996        let loaded = manager.load_conversation(&conv.id).unwrap();
997        assert_eq!(loaded.messages.len(), 2, "latest save must win for us");
998
999        let _ = fs::remove_dir_all(&dir);
1000    }
1001}