Skip to main content

mermaid_cli/session/
conversation.rs

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