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