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