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#[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 #[serde(default)]
23 pub compactions: Vec<crate::domain::CompactionRecord>,
24 #[serde(default)]
26 pub input_history: VecDeque<String>,
27}
28
29impl ConversationHistory {
30 pub fn new(project_path: String, model_name: String) -> Self {
32 let now = Local::now();
33 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 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 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>) {
60 self.messages = messages;
61 self.updated_at = Local::now();
62 }
63
64 pub fn add_compaction(&mut self, record: crate::domain::CompactionRecord) {
66 self.compactions.push(record);
67 self.updated_at = Local::now();
68 }
69
70 pub fn add_to_input_history(&mut self, input: String) {
72 if input.trim().is_empty() {
74 return;
75 }
76
77 if let Some(last) = self.input_history.back()
79 && last == &input
80 {
81 return;
82 }
83
84 if self.input_history.len() >= 100 {
86 self.input_history.pop_front(); }
88
89 self.input_history.push_back(input);
90 }
91
92 fn update_title(&mut self) {
95 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 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#[derive(Clone)]
130pub struct ConversationManager {
131 conversations_dir: PathBuf,
132 compactions_dir: PathBuf,
133}
134
135impl ConversationManager {
136 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 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 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 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 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 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 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
216 let mut conversations = Vec::new();
217
218 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 conversations.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
233
234 Ok(conversations)
235 }
236
237 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 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); }
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()); 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 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 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}