mermaid_cli/session/
conversation.rs1use 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#[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 #[serde(default)]
22 pub input_history: VecDeque<String>,
23}
24
25impl ConversationHistory {
26 pub fn new(project_path: String, model_name: String) -> Self {
28 let now = Local::now();
29 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 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 pub fn add_to_input_history(&mut self, input: String) {
53 if input.trim().is_empty() {
55 return;
56 }
57
58 if let Some(last) = self.input_history.back()
60 && last == &input
61 {
62 return;
63 }
64
65 if self.input_history.len() >= 100 {
67 self.input_history.pop_front(); }
69
70 self.input_history.push_back(input);
71 }
72
73 fn update_title(&mut self) {
76 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 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#[derive(Clone)]
111pub struct ConversationManager {
112 conversations_dir: PathBuf,
113}
114
115impl ConversationManager {
116 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
118 let conversations_dir = project_dir.as_ref().join(".mermaid").join("conversations");
119
120 fs::create_dir_all(&conversations_dir)?;
122
123 Ok(Self { conversations_dir })
124 }
125
126 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 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 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 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
178 let mut conversations = Vec::new();
179
180 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 conversations.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
195
196 Ok(conversations)
197 }
198
199 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 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); }
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()); 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 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 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}