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::VecDeque;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Reject a conversation id that doesn't match the generated shape
11/// (`%Y%m%d_%H%M%S_%3f` => `YYYYMMDD_HHMMSS_mmm`). Without this, a
12/// user-typed `/load <id>` (or `delete`) joins arbitrary text into a
13/// filesystem path — `../../secret` would read/delete files outside the
14/// project. Digits-and-underscores can't contain `/`, `\`, `..`, or a drive
15/// prefix, so the format check alone closes the traversal.
16fn validate_conversation_id(id: &str) -> Result<()> {
17    let valid = id.len() == 19
18        && id.as_bytes().iter().enumerate().all(|(i, b)| match i {
19            8 | 15 => *b == b'_',
20            _ => b.is_ascii_digit(),
21        });
22    anyhow::ensure!(valid, "invalid conversation id: {id:?}");
23    Ok(())
24}
25
26/// A complete conversation history
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ConversationHistory {
29    pub id: String,
30    pub title: String,
31    pub messages: Vec<ChatMessage>,
32    pub model_name: String,
33    pub project_path: String,
34    pub created_at: DateTime<Local>,
35    pub updated_at: DateTime<Local>,
36    pub total_tokens: Option<usize>,
37    /// Metadata for context compactions performed in this conversation.
38    #[serde(default)]
39    pub compactions: Vec<crate::domain::CompactionRecord>,
40    /// History of user input prompts for navigation (up/down arrows)
41    #[serde(default)]
42    pub input_history: VecDeque<String>,
43}
44
45impl ConversationHistory {
46    /// Create a new conversation history
47    pub fn new(project_path: String, model_name: String) -> Self {
48        let now = Local::now();
49        // Include subsecond precision to avoid ID collisions within the same second
50        let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
51        Self {
52            id: id.clone(),
53            title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
54            messages: Vec::new(),
55            model_name,
56            project_path,
57            created_at: now,
58            updated_at: now,
59            total_tokens: None,
60            compactions: Vec::new(),
61            input_history: VecDeque::new(),
62        }
63    }
64
65    /// Add messages to the conversation
66    pub fn add_messages(&mut self, messages: &[ChatMessage]) {
67        self.messages.extend_from_slice(messages);
68        self.updated_at = Local::now();
69        self.update_title();
70    }
71
72    /// Replace the model-visible message log without deriving a new title.
73    /// Used by context compaction: the original title still describes the
74    /// session better than the generated checkpoint.
75    pub fn replace_messages(&mut self, messages: Vec<ChatMessage>) {
76        self.messages = messages;
77        self.updated_at = Local::now();
78    }
79
80    /// Record a completed context compaction.
81    pub fn add_compaction(&mut self, record: crate::domain::CompactionRecord) {
82        self.compactions.push(record);
83        self.updated_at = Local::now();
84    }
85
86    /// Add input to history (with deduplication of consecutive identical inputs)
87    pub fn add_to_input_history(&mut self, input: String) {
88        // Skip empty inputs
89        if input.trim().is_empty() {
90            return;
91        }
92
93        // Don't add if it's identical to the last entry
94        if let Some(last) = self.input_history.back()
95            && last == &input
96        {
97            return;
98        }
99
100        // Cap history at 100 entries to prevent unbounded growth
101        if self.input_history.len() >= 100 {
102            self.input_history.pop_front(); // O(1) instead of O(n)
103        }
104
105        self.input_history.push_back(input);
106    }
107
108    /// Update the title based on the first user message.
109    /// Short-circuits if the title was already derived from a user message.
110    fn update_title(&mut self) {
111        // Only set title once — it comes from the first user message
112        if !self.title.starts_with("Session ") {
113            return;
114        }
115        if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
116            let preview = if first_user_msg.content.len() > 60 {
117                let end = first_user_msg.content.floor_char_boundary(60);
118                format!("{}...", &first_user_msg.content[..end])
119            } else {
120                first_user_msg.content.clone()
121            };
122            self.title = preview;
123        }
124    }
125
126    /// Get a summary for display
127    pub fn summary(&self) -> String {
128        let message_count = self.messages.len();
129        let duration = self.updated_at.signed_duration_since(self.created_at);
130        let hours = duration.num_hours();
131        let minutes = duration.num_minutes() % 60;
132
133        format!(
134            "{} | {} messages | {}h {}m | {}",
135            self.updated_at.format("%Y-%m-%d %H:%M"),
136            message_count,
137            hours,
138            minutes,
139            self.title
140        )
141    }
142}
143
144/// Manages conversation persistence for a project
145#[derive(Clone)]
146pub struct ConversationManager {
147    conversations_dir: PathBuf,
148    compactions_dir: PathBuf,
149}
150
151impl ConversationManager {
152    /// Create a new conversation manager for a project directory
153    pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
154        let mermaid_dir = project_dir.as_ref().join(".mermaid");
155        let conversations_dir = mermaid_dir.join("conversations");
156        let compactions_dir = mermaid_dir.join("compactions");
157
158        // Create conversations directory if it doesn't exist
159        fs::create_dir_all(&conversations_dir)?;
160        fs::create_dir_all(&compactions_dir)?;
161
162        Ok(Self {
163            conversations_dir,
164            compactions_dir,
165        })
166    }
167
168    /// Save a conversation to disk
169    pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
170        let filename = format!("{}.json", conversation.id);
171        let path = self.conversations_dir.join(filename);
172
173        let json = serde_json::to_string_pretty(conversation)?;
174        // Atomic write: a crash mid-save must not empty/corrupt the session
175        // file (this is the hot path, rewritten after nearly every message).
176        crate::utils::write_atomic(&path, json.as_bytes())?;
177
178        Ok(())
179    }
180
181    /// Save the raw messages removed by a compaction. Archives live
182    /// outside the hot conversation JSON so `/load` and `/list` don't
183    /// parse old transcripts on every startup.
184    pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
185        let dir = self.compactions_dir.join(&archive.conversation_id);
186        fs::create_dir_all(&dir)?;
187        let path = dir.join(format!("{}.json", archive.id));
188        let json = serde_json::to_string_pretty(archive)?;
189        // Atomic write: the archive is the ONLY durable copy of messages
190        // dropped by a compaction — a partial write would lose them.
191        crate::utils::write_atomic(&path, json.as_bytes())?;
192        Ok(path)
193    }
194
195    /// Load a specific conversation by ID
196    pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
197        validate_conversation_id(id)?;
198        let filename = format!("{}.json", id);
199        let path = self.conversations_dir.join(filename);
200
201        let json = fs::read_to_string(path)?;
202        let conversation: ConversationHistory = serde_json::from_str(&json)?;
203
204        Ok(conversation)
205    }
206
207    /// Load the most recent conversation.
208    ///
209    /// Picks the newest file by filesystem mtime, then deserializes only
210    /// that one file. Much cheaper than `list_conversations()` (which
211    /// reads and parses every file) in directories with many sessions.
212    pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
213        let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
214            return Ok(None);
215        };
216
217        let newest = entries
218            .flatten()
219            .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
220            .filter_map(|e| {
221                let mtime = e.metadata().ok()?.modified().ok()?;
222                Some((mtime, e.path()))
223            })
224            .max_by_key(|(mtime, _)| *mtime);
225
226        let Some((_, path)) = newest else {
227            return Ok(None);
228        };
229
230        let json = fs::read_to_string(&path)?;
231        let conv: ConversationHistory = serde_json::from_str(&json)?;
232        Ok(Some(conv))
233    }
234
235    /// List all conversations in the project
236    pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
237        let mut conversations = Vec::new();
238
239        // Read all JSON files in the conversations directory
240        if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
241            for entry in entries.flatten() {
242                if let Some(ext) = entry.path().extension()
243                    && ext == "json"
244                    && let Ok(json) = fs::read_to_string(entry.path())
245                    && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
246                {
247                    conversations.push(conv);
248                }
249            }
250        }
251
252        // Sort by updated_at (newest first)
253        conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
254
255        Ok(conversations)
256    }
257
258    /// Delete a conversation
259    pub fn delete_conversation(&self, id: &str) -> Result<()> {
260        validate_conversation_id(id)?;
261        let filename = format!("{}.json", id);
262        let path = self.conversations_dir.join(filename);
263
264        if path.exists() {
265            fs::remove_file(path)?;
266        }
267
268        Ok(())
269    }
270
271    /// Get the conversations directory path
272    pub fn conversations_dir(&self) -> &Path {
273        &self.conversations_dir
274    }
275
276    pub fn compactions_dir(&self) -> &Path {
277        &self.compactions_dir
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn validate_conversation_id_rejects_traversal() {
287        assert!(validate_conversation_id("20260101_120000_001").is_ok());
288        assert!(validate_conversation_id("../secret").is_err());
289        assert!(validate_conversation_id("..\\secret").is_err());
290        assert!(validate_conversation_id("/etc/passwd").is_err());
291        assert!(validate_conversation_id("20260101_120000").is_err()); // too short
292        assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); // non-digits
293    }
294
295    #[test]
296    fn test_new_conversation_has_session_title() {
297        let conv = ConversationHistory::new("/tmp/project".into(), "test-model".into());
298        assert!(conv.title.starts_with("Session "));
299        assert_eq!(conv.model_name, "test-model");
300        assert_eq!(conv.project_path, "/tmp/project");
301        assert!(conv.messages.is_empty());
302    }
303
304    #[test]
305    fn test_title_updates_from_first_user_message() {
306        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
307        conv.add_messages(&[ChatMessage::user("Fix the login bug")]);
308        assert_eq!(conv.title, "Fix the login bug");
309    }
310
311    #[test]
312    fn test_title_truncated_at_60_chars() {
313        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
314        let long_msg = "a".repeat(100);
315        conv.add_messages(&[ChatMessage::user(long_msg)]);
316        assert!(conv.title.ends_with("..."));
317        assert!(conv.title.len() <= 64); // 60 chars + "..."
318    }
319
320    #[test]
321    fn test_title_set_only_once() {
322        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
323        conv.add_messages(&[ChatMessage::user("First message")]);
324        conv.add_messages(&[ChatMessage::user("Second message")]);
325        assert_eq!(conv.title, "First message");
326    }
327
328    #[test]
329    fn test_input_history_deduplication() {
330        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
331        conv.add_to_input_history("hello".into());
332        conv.add_to_input_history("hello".into()); // duplicate
333        conv.add_to_input_history("world".into());
334        assert_eq!(conv.input_history.len(), 2);
335    }
336
337    #[test]
338    fn test_input_history_skips_empty() {
339        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
340        conv.add_to_input_history("".into());
341        conv.add_to_input_history("   ".into());
342        assert_eq!(conv.input_history.len(), 0);
343    }
344
345    #[test]
346    fn test_input_history_capped_at_100() {
347        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
348        for i in 0..110 {
349            conv.add_to_input_history(format!("msg{}", i));
350        }
351        assert_eq!(conv.input_history.len(), 100);
352        assert_eq!(conv.input_history.front().unwrap(), "msg10");
353    }
354
355    #[test]
356    fn test_save_load_roundtrip() {
357        let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
358        let _ = fs::remove_dir_all(&dir);
359        let manager = ConversationManager::new(&dir).unwrap();
360
361        let mut conv = ConversationHistory::new("/tmp".into(), "model".into());
362        conv.add_messages(&[ChatMessage::user("test message")]);
363        conv.add_to_input_history("test message".into());
364
365        manager.save_conversation(&conv).unwrap();
366        let loaded = manager.load_conversation(&conv.id).unwrap();
367
368        assert_eq!(loaded.id, conv.id);
369        assert_eq!(loaded.title, conv.title);
370        assert_eq!(loaded.messages.len(), 1);
371        assert_eq!(loaded.input_history.len(), 1);
372
373        let _ = fs::remove_dir_all(&dir);
374    }
375
376    #[test]
377    fn test_list_conversations_ordered_by_updated_at() {
378        let dir = std::env::temp_dir().join("mermaid_test_conv_list");
379        let _ = fs::remove_dir_all(&dir);
380        let manager = ConversationManager::new(&dir).unwrap();
381
382        let conv1 = ConversationHistory::new("/tmp".into(), "m".into());
383        std::thread::sleep(std::time::Duration::from_millis(10));
384        let conv2 = ConversationHistory::new("/tmp".into(), "m".into());
385
386        manager.save_conversation(&conv1).unwrap();
387        manager.save_conversation(&conv2).unwrap();
388
389        let list = manager.list_conversations().unwrap();
390        assert_eq!(list.len(), 2);
391        // Newest first
392        assert_eq!(list[0].id, conv2.id);
393        assert_eq!(list[1].id, conv1.id);
394
395        let _ = fs::remove_dir_all(&dir);
396    }
397
398    #[test]
399    fn test_load_last_conversation() {
400        let dir = std::env::temp_dir().join("mermaid_test_conv_last");
401        let _ = fs::remove_dir_all(&dir);
402        let manager = ConversationManager::new(&dir).unwrap();
403
404        assert!(manager.load_last_conversation().unwrap().is_none());
405
406        let conv = ConversationHistory::new("/tmp".into(), "m".into());
407        manager.save_conversation(&conv).unwrap();
408
409        let last = manager.load_last_conversation().unwrap().unwrap();
410        assert_eq!(last.id, conv.id);
411
412        let _ = fs::remove_dir_all(&dir);
413    }
414
415    #[test]
416    fn test_load_last_conversation_picks_newest_by_mtime() {
417        // Writes three conversations with staggered mtimes (via sleeps
418        // between saves) and asserts the mtime-based picker returns the
419        // last one written — even though filename-alphabetical ordering
420        // would pick a different file.
421        let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
422        let _ = fs::remove_dir_all(&dir);
423        let manager = ConversationManager::new(&dir).unwrap();
424
425        let conv1 = ConversationHistory::new("/tmp".into(), "m".into());
426        manager.save_conversation(&conv1).unwrap();
427        std::thread::sleep(std::time::Duration::from_millis(10));
428
429        let conv2 = ConversationHistory::new("/tmp".into(), "m".into());
430        manager.save_conversation(&conv2).unwrap();
431        std::thread::sleep(std::time::Duration::from_millis(10));
432
433        let conv3 = ConversationHistory::new("/tmp".into(), "m".into());
434        manager.save_conversation(&conv3).unwrap();
435
436        let last = manager.load_last_conversation().unwrap().unwrap();
437        assert_eq!(
438            last.id, conv3.id,
439            "should return the most-recently-written file"
440        );
441
442        let _ = fs::remove_dir_all(&dir);
443    }
444
445    #[test]
446    fn test_delete_conversation() {
447        let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
448        let _ = fs::remove_dir_all(&dir);
449        let manager = ConversationManager::new(&dir).unwrap();
450
451        let conv = ConversationHistory::new("/tmp".into(), "m".into());
452        manager.save_conversation(&conv).unwrap();
453        assert_eq!(manager.list_conversations().unwrap().len(), 1);
454
455        manager.delete_conversation(&conv.id).unwrap();
456        assert_eq!(manager.list_conversations().unwrap().len(), 0);
457
458        let _ = fs::remove_dir_all(&dir);
459    }
460}