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
26const SCREENSHOT_ELIDED_MARKER: &str = "\n[screenshot not persisted]";
28
29fn strip_persisted_screenshots(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
41 let needs = messages
42 .iter()
43 .any(|m| m.role != MessageRole::User && m.images.is_some());
44 if !needs {
45 return None;
46 }
47 let mut out = messages.to_vec();
48 for m in out.iter_mut() {
49 if m.role != MessageRole::User && m.images.is_some() {
50 m.images = None;
51 if !m.content.ends_with(SCREENSHOT_ELIDED_MARKER) {
52 m.content.push_str(SCREENSHOT_ELIDED_MARKER);
53 }
54 }
55 }
56 Some(out)
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ConversationHistory {
62 pub id: String,
63 pub title: String,
64 pub messages: Vec<ChatMessage>,
65 pub model_name: String,
66 pub project_path: String,
67 pub created_at: DateTime<Local>,
68 pub updated_at: DateTime<Local>,
69 pub total_tokens: Option<usize>,
70 #[serde(default)]
72 pub compactions: Vec<crate::domain::CompactionRecord>,
73 #[serde(default)]
75 pub input_history: VecDeque<String>,
76}
77
78impl ConversationHistory {
79 pub fn new(project_path: String, model_name: String) -> Self {
81 let now = Local::now();
82 let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
84 Self {
85 id: id.clone(),
86 title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
87 messages: Vec::new(),
88 model_name,
89 project_path,
90 created_at: now,
91 updated_at: now,
92 total_tokens: None,
93 compactions: Vec::new(),
94 input_history: VecDeque::new(),
95 }
96 }
97
98 pub fn add_messages(&mut self, messages: &[ChatMessage]) {
100 self.messages.extend_from_slice(messages);
101 self.updated_at = Local::now();
102 self.update_title();
103 }
104
105 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>) {
109 self.messages = messages;
110 self.updated_at = Local::now();
111 }
112
113 pub fn add_compaction(&mut self, record: crate::domain::CompactionRecord) {
115 self.compactions.push(record);
116 self.updated_at = Local::now();
117 }
118
119 pub fn add_to_input_history(&mut self, input: String) {
121 if input.trim().is_empty() {
123 return;
124 }
125
126 if let Some(last) = self.input_history.back()
128 && last == &input
129 {
130 return;
131 }
132
133 if self.input_history.len() >= 100 {
135 self.input_history.pop_front(); }
137
138 self.input_history.push_back(input);
139 }
140
141 fn update_title(&mut self) {
144 if !self.title.starts_with("Session ") {
146 return;
147 }
148 if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
149 let preview = if first_user_msg.content.len() > 60 {
150 let end = first_user_msg.content.floor_char_boundary(60);
151 format!("{}...", &first_user_msg.content[..end])
152 } else {
153 first_user_msg.content.clone()
154 };
155 self.title = preview;
156 }
157 }
158
159 pub fn summary(&self) -> String {
161 let message_count = self.messages.len();
162 let duration = self.updated_at.signed_duration_since(self.created_at);
163 let hours = duration.num_hours();
164 let minutes = duration.num_minutes() % 60;
165
166 format!(
167 "{} | {} messages | {}h {}m | {}",
168 self.updated_at.format("%Y-%m-%d %H:%M"),
169 message_count,
170 hours,
171 minutes,
172 self.title
173 )
174 }
175}
176
177#[derive(Clone)]
179pub struct ConversationManager {
180 conversations_dir: PathBuf,
181 compactions_dir: PathBuf,
182}
183
184impl ConversationManager {
185 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
187 let mermaid_dir = project_dir.as_ref().join(".mermaid");
188 let conversations_dir = mermaid_dir.join("conversations");
189 let compactions_dir = mermaid_dir.join("compactions");
190
191 fs::create_dir_all(&conversations_dir)?;
193 fs::create_dir_all(&compactions_dir)?;
194
195 Ok(Self {
196 conversations_dir,
197 compactions_dir,
198 })
199 }
200
201 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
203 validate_conversation_id(&conversation.id)?;
207 let filename = format!("{}.json", conversation.id);
208 let path = self.conversations_dir.join(filename);
209
210 let json = match strip_persisted_screenshots(&conversation.messages) {
213 Some(sanitized) => {
214 let mut redacted = conversation.clone();
215 redacted.messages = sanitized;
216 serde_json::to_string_pretty(&redacted)?
217 },
218 None => serde_json::to_string_pretty(conversation)?,
219 };
220 crate::runtime::write_atomic(&path, json.as_bytes())?;
223
224 Ok(())
225 }
226
227 pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
231 validate_conversation_id(&archive.conversation_id)?;
234 anyhow::ensure!(
235 !archive.id.is_empty()
236 && !archive.id.contains(['/', '\\'])
237 && !archive.id.contains(".."),
238 "invalid compaction archive id: {:?}",
239 archive.id
240 );
241 let dir = self.compactions_dir.join(&archive.conversation_id);
242 fs::create_dir_all(&dir)?;
243 let path = dir.join(format!("{}.json", archive.id));
244 let json = match strip_persisted_screenshots(&archive.messages) {
248 Some(sanitized) => {
249 let mut redacted = archive.clone();
250 redacted.messages = sanitized;
251 serde_json::to_string_pretty(&redacted)?
252 },
253 None => serde_json::to_string_pretty(archive)?,
254 };
255 crate::runtime::write_atomic(&path, json.as_bytes())?;
258 Ok(path)
259 }
260
261 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
263 validate_conversation_id(id)?;
264 let filename = format!("{}.json", id);
265 let path = self.conversations_dir.join(filename);
266
267 let json = fs::read_to_string(path)?;
268 let conversation: ConversationHistory = serde_json::from_str(&json)?;
269 validate_conversation_id(&conversation.id)?;
272
273 Ok(conversation)
274 }
275
276 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
284 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
285 return Ok(None);
286 };
287
288 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
289 .flatten()
290 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
291 .filter_map(|e| {
292 let mtime = e.metadata().ok()?.modified().ok()?;
293 Some((mtime, e.path()))
294 })
295 .collect();
296 candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
297
298 for (_, path) in candidates {
299 let Ok(json) = fs::read_to_string(&path) else {
300 tracing::warn!(path = %path.display(), "skipping unreadable conversation file");
301 continue;
302 };
303 let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
304 tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
305 continue;
306 };
307 if validate_conversation_id(&conv.id).is_err() {
310 tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
311 continue;
312 }
313 return Ok(Some(conv));
314 }
315 Ok(None)
316 }
317
318 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
320 let mut conversations = Vec::new();
321
322 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
324 for entry in entries.flatten() {
325 if let Some(ext) = entry.path().extension()
326 && ext == "json"
327 && let Ok(json) = fs::read_to_string(entry.path())
328 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
329 {
330 conversations.push(conv);
331 }
332 }
333 }
334
335 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
337
338 Ok(conversations)
339 }
340
341 pub fn delete_conversation(&self, id: &str) -> Result<()> {
343 validate_conversation_id(id)?;
344 let filename = format!("{}.json", id);
345 let path = self.conversations_dir.join(filename);
346
347 if path.exists() {
348 fs::remove_file(path)?;
349 }
350
351 Ok(())
352 }
353
354 pub fn conversations_dir(&self) -> &Path {
356 &self.conversations_dir
357 }
358
359 pub fn compactions_dir(&self) -> &Path {
360 &self.compactions_dir
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn validate_conversation_id_rejects_traversal() {
370 assert!(validate_conversation_id("20260101_120000_001").is_ok());
371 assert!(validate_conversation_id("../secret").is_err());
372 assert!(validate_conversation_id("..\\secret").is_err());
373 assert!(validate_conversation_id("/etc/passwd").is_err());
374 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
377
378 #[test]
379 fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
380 let messages = vec![
381 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
382 ChatMessage::assistant("here is the screen")
383 .with_images(vec!["SCREENSHOT_B64".to_string()]),
384 ChatMessage::assistant("no image here"),
385 ];
386 let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
387 assert_eq!(
389 sanitized[0].images.as_deref(),
390 Some(["USER_PASTED_B64".to_string()].as_slice())
391 );
392 assert!(sanitized[1].images.is_none());
394 assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
395 assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
397 }
398
399 #[test]
400 fn strip_persisted_screenshots_is_none_without_assistant_images() {
401 let messages = vec![
402 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
403 ChatMessage::assistant("no images"),
404 ];
405 assert!(strip_persisted_screenshots(&messages).is_none());
406 }
407
408 #[test]
409 fn saved_conversation_json_has_no_screenshot_bytes() {
410 let dir = std::env::temp_dir().join("mermaid_strip_test");
411 let _ = fs::create_dir_all(&dir);
412 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into());
413 conv.messages = vec![
414 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
415 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
416 ];
417 let store = ConversationManager {
418 conversations_dir: dir.clone(),
419 compactions_dir: dir.clone(),
420 };
421 store.save_conversation(&conv).expect("save");
422 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
423 assert!(
424 !raw.contains("SHOTBYTES"),
425 "screenshot leaked to disk: {raw}"
426 );
427 assert!(raw.contains("USERIMG"), "user image should persist");
428 assert_eq!(
430 conv.messages[1].images.as_deref(),
431 Some(["SHOTBYTES".to_string()].as_slice())
432 );
433 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
434 }
435
436 #[test]
437 fn test_new_conversation_has_session_title() {
438 let conv = ConversationHistory::new("/tmp/project".into(), "test-model".into());
439 assert!(conv.title.starts_with("Session "));
440 assert_eq!(conv.model_name, "test-model");
441 assert_eq!(conv.project_path, "/tmp/project");
442 assert!(conv.messages.is_empty());
443 }
444
445 #[test]
446 fn test_title_updates_from_first_user_message() {
447 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
448 conv.add_messages(&[ChatMessage::user("Fix the login bug")]);
449 assert_eq!(conv.title, "Fix the login bug");
450 }
451
452 #[test]
453 fn test_title_truncated_at_60_chars() {
454 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
455 let long_msg = "a".repeat(100);
456 conv.add_messages(&[ChatMessage::user(long_msg)]);
457 assert!(conv.title.ends_with("..."));
458 assert!(conv.title.len() <= 64); }
460
461 #[test]
462 fn test_title_set_only_once() {
463 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
464 conv.add_messages(&[ChatMessage::user("First message")]);
465 conv.add_messages(&[ChatMessage::user("Second message")]);
466 assert_eq!(conv.title, "First message");
467 }
468
469 #[test]
470 fn test_input_history_deduplication() {
471 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
472 conv.add_to_input_history("hello".into());
473 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
475 assert_eq!(conv.input_history.len(), 2);
476 }
477
478 #[test]
479 fn test_input_history_skips_empty() {
480 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
481 conv.add_to_input_history("".into());
482 conv.add_to_input_history(" ".into());
483 assert_eq!(conv.input_history.len(), 0);
484 }
485
486 #[test]
487 fn test_input_history_capped_at_100() {
488 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
489 for i in 0..110 {
490 conv.add_to_input_history(format!("msg{}", i));
491 }
492 assert_eq!(conv.input_history.len(), 100);
493 assert_eq!(conv.input_history.front().unwrap(), "msg10");
494 }
495
496 #[test]
497 fn test_save_load_roundtrip() {
498 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
499 let _ = fs::remove_dir_all(&dir);
500 let manager = ConversationManager::new(&dir).unwrap();
501
502 let mut conv = ConversationHistory::new("/tmp".into(), "model".into());
503 conv.add_messages(&[ChatMessage::user("test message")]);
504 conv.add_to_input_history("test message".into());
505
506 manager.save_conversation(&conv).unwrap();
507 let loaded = manager.load_conversation(&conv.id).unwrap();
508
509 assert_eq!(loaded.id, conv.id);
510 assert_eq!(loaded.title, conv.title);
511 assert_eq!(loaded.messages.len(), 1);
512 assert_eq!(loaded.input_history.len(), 1);
513
514 let _ = fs::remove_dir_all(&dir);
515 }
516
517 #[test]
518 fn test_list_conversations_ordered_by_updated_at() {
519 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
520 let _ = fs::remove_dir_all(&dir);
521 let manager = ConversationManager::new(&dir).unwrap();
522
523 let conv1 = ConversationHistory::new("/tmp".into(), "m".into());
524 std::thread::sleep(std::time::Duration::from_millis(10));
525 let conv2 = ConversationHistory::new("/tmp".into(), "m".into());
526
527 manager.save_conversation(&conv1).unwrap();
528 manager.save_conversation(&conv2).unwrap();
529
530 let list = manager.list_conversations().unwrap();
531 assert_eq!(list.len(), 2);
532 assert_eq!(list[0].id, conv2.id);
534 assert_eq!(list[1].id, conv1.id);
535
536 let _ = fs::remove_dir_all(&dir);
537 }
538
539 #[test]
540 fn test_load_last_conversation() {
541 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
542 let _ = fs::remove_dir_all(&dir);
543 let manager = ConversationManager::new(&dir).unwrap();
544
545 assert!(manager.load_last_conversation().unwrap().is_none());
546
547 let conv = ConversationHistory::new("/tmp".into(), "m".into());
548 manager.save_conversation(&conv).unwrap();
549
550 let last = manager.load_last_conversation().unwrap().unwrap();
551 assert_eq!(last.id, conv.id);
552
553 let _ = fs::remove_dir_all(&dir);
554 }
555
556 #[test]
557 fn test_load_last_conversation_picks_newest_by_mtime() {
558 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
563 let _ = fs::remove_dir_all(&dir);
564 let manager = ConversationManager::new(&dir).unwrap();
565
566 let conv1 = ConversationHistory::new("/tmp".into(), "m".into());
567 manager.save_conversation(&conv1).unwrap();
568 std::thread::sleep(std::time::Duration::from_millis(10));
569
570 let conv2 = ConversationHistory::new("/tmp".into(), "m".into());
571 manager.save_conversation(&conv2).unwrap();
572 std::thread::sleep(std::time::Duration::from_millis(10));
573
574 let conv3 = ConversationHistory::new("/tmp".into(), "m".into());
575 manager.save_conversation(&conv3).unwrap();
576
577 let last = manager.load_last_conversation().unwrap().unwrap();
578 assert_eq!(
579 last.id, conv3.id,
580 "should return the most-recently-written file"
581 );
582
583 let _ = fs::remove_dir_all(&dir);
584 }
585
586 #[test]
587 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
588 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
589 let _ = fs::remove_dir_all(&dir);
590 let manager = ConversationManager::new(&dir).unwrap();
591
592 let good = ConversationHistory::new("/tmp".into(), "m".into());
593 manager.save_conversation(&good).unwrap();
594 std::thread::sleep(std::time::Duration::from_millis(10));
595
596 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
599 fs::write(&corrupt, b"{ not valid json").unwrap();
600
601 let last = manager.load_last_conversation().unwrap().unwrap();
602 assert_eq!(
603 last.id, good.id,
604 "must fall back to the newest VALID conversation"
605 );
606 let _ = fs::remove_dir_all(&dir);
607 }
608
609 #[test]
610 fn load_last_conversation_none_when_only_corrupt() {
611 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
612 let _ = fs::remove_dir_all(&dir);
613 let manager = ConversationManager::new(&dir).unwrap();
614 fs::write(
615 manager.conversations_dir().join("20991231_235959_998.json"),
616 b"nope",
617 )
618 .unwrap();
619 assert!(manager.load_last_conversation().unwrap().is_none());
620 let _ = fs::remove_dir_all(&dir);
621 }
622
623 #[test]
624 fn test_delete_conversation() {
625 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
626 let _ = fs::remove_dir_all(&dir);
627 let manager = ConversationManager::new(&dir).unwrap();
628
629 let conv = ConversationHistory::new("/tmp".into(), "m".into());
630 manager.save_conversation(&conv).unwrap();
631 assert_eq!(manager.list_conversations().unwrap().len(), 1);
632
633 manager.delete_conversation(&conv.id).unwrap();
634 assert_eq!(manager.list_conversations().unwrap().len(), 0);
635
636 let _ = fs::remove_dir_all(&dir);
637 }
638}