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
10fn 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#[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 #[serde(default)]
39 pub compactions: Vec<crate::domain::CompactionRecord>,
40 #[serde(default)]
42 pub input_history: VecDeque<String>,
43}
44
45impl ConversationHistory {
46 pub fn new(project_path: String, model_name: String) -> Self {
48 let now = Local::now();
49 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 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 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>) {
76 self.messages = messages;
77 self.updated_at = Local::now();
78 }
79
80 pub fn add_compaction(&mut self, record: crate::domain::CompactionRecord) {
82 self.compactions.push(record);
83 self.updated_at = Local::now();
84 }
85
86 pub fn add_to_input_history(&mut self, input: String) {
88 if input.trim().is_empty() {
90 return;
91 }
92
93 if let Some(last) = self.input_history.back()
95 && last == &input
96 {
97 return;
98 }
99
100 if self.input_history.len() >= 100 {
102 self.input_history.pop_front(); }
104
105 self.input_history.push_back(input);
106 }
107
108 fn update_title(&mut self) {
111 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 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#[derive(Clone)]
146pub struct ConversationManager {
147 conversations_dir: PathBuf,
148 compactions_dir: PathBuf,
149}
150
151impl ConversationManager {
152 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 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 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 crate::utils::write_atomic(&path, json.as_bytes())?;
177
178 Ok(())
179 }
180
181 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 crate::utils::write_atomic(&path, json.as_bytes())?;
192 Ok(path)
193 }
194
195 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 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 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
237 let mut conversations = Vec::new();
238
239 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 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
254
255 Ok(conversations)
256 }
257
258 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 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()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
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); }
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()); 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 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 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}