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