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