1use crate::domain::CompactionArchive;
2use crate::models::{ChatMessage, MessageRole};
3use anyhow::Result;
4use chrono::{DateTime, Local};
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, VecDeque};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11use std::time::SystemTime;
12
13fn validate_conversation_id(id: &str) -> Result<()> {
20 let valid = id.len() == 19
21 && id.as_bytes().iter().enumerate().all(|(i, b)| match i {
22 8 | 15 => *b == b'_',
23 _ => b.is_ascii_digit(),
24 });
25 anyhow::ensure!(valid, "invalid conversation id: {id:?}");
26 Ok(())
27}
28
29const MAX_CONVERSATION_BYTES: u64 = 64 * 1024 * 1024;
34
35fn read_conversation_capped(path: &Path) -> std::io::Result<String> {
38 let len = fs::metadata(path)?.len();
39 if len > MAX_CONVERSATION_BYTES {
40 return Err(std::io::Error::new(
41 std::io::ErrorKind::InvalidData,
42 format!(
43 "conversation file {} is {len} bytes, over the {} MiB cap",
44 path.display(),
45 MAX_CONVERSATION_BYTES / (1024 * 1024)
46 ),
47 ));
48 }
49 fs::read_to_string(path)
50}
51
52const SCREENSHOT_ELIDED_MARKER: &str = "\n[screenshot not persisted]";
54
55fn strip_persisted_screenshots(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
67 let needs = messages
68 .iter()
69 .any(|m| m.role != MessageRole::User && m.images.is_some());
70 if !needs {
71 return None;
72 }
73 let mut out = messages.to_vec();
74 for m in out.iter_mut() {
75 if m.role != MessageRole::User && m.images.is_some() {
76 m.images = None;
77 if !m.content.ends_with(SCREENSHOT_ELIDED_MARKER) {
78 m.content.push_str(SCREENSHOT_ELIDED_MARKER);
79 }
80 }
81 }
82 Some(out)
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ConversationHistory {
88 pub id: String,
89 pub title: String,
90 pub messages: Vec<ChatMessage>,
91 pub model_name: String,
92 pub project_path: String,
93 pub created_at: DateTime<Local>,
94 pub updated_at: DateTime<Local>,
95 pub total_tokens: Option<usize>,
96 #[serde(default)]
98 pub compactions: Vec<crate::domain::CompactionRecord>,
99 #[serde(default)]
101 pub input_history: VecDeque<String>,
102}
103
104impl ConversationHistory {
105 pub fn new(project_path: String, model_name: String, now: DateTime<Local>) -> Self {
113 let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
115 Self {
116 id: id.clone(),
117 title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
118 messages: Vec::new(),
119 model_name,
120 project_path,
121 created_at: now,
122 updated_at: now,
123 total_tokens: None,
124 compactions: Vec::new(),
125 input_history: VecDeque::new(),
126 }
127 }
128
129 pub fn add_messages(&mut self, messages: &[ChatMessage], now: DateTime<Local>) {
133 self.messages.extend_from_slice(messages);
134 self.updated_at = now;
135 self.update_title();
136 }
137
138 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>, now: DateTime<Local>) {
144 self.messages = messages;
145 self.updated_at = now;
146 }
147
148 pub fn add_compaction(
151 &mut self,
152 record: crate::domain::CompactionRecord,
153 now: DateTime<Local>,
154 ) {
155 self.compactions.push(record);
156 self.updated_at = now;
157 }
158
159 pub fn add_to_input_history(&mut self, input: String) {
161 if input.trim().is_empty() {
163 return;
164 }
165
166 if let Some(last) = self.input_history.back()
168 && last == &input
169 {
170 return;
171 }
172
173 if self.input_history.len() >= 100 {
175 self.input_history.pop_front(); }
177
178 self.input_history.push_back(input);
179 }
180
181 fn update_title(&mut self) {
184 if !self.title.starts_with("Session ") {
186 return;
187 }
188 if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
189 let preview = if first_user_msg.content.len() > 60 {
190 let end = first_user_msg.content.floor_char_boundary(60);
191 format!("{}...", &first_user_msg.content[..end])
192 } else {
193 first_user_msg.content.clone()
194 };
195 self.title = preview;
196 }
197 }
198
199 pub fn summary(&self) -> String {
201 let message_count = self.messages.len();
202 let duration = self.updated_at.signed_duration_since(self.created_at);
203 let hours = duration.num_hours();
204 let minutes = duration.num_minutes() % 60;
205
206 format!(
207 "{} | {} messages | {}h {}m | {}",
208 self.updated_at.format("%Y-%m-%d %H:%M"),
209 message_count,
210 hours,
211 minutes,
212 self.title
213 )
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223struct FileStamp {
224 mtime: SystemTime,
225 len: u64,
226}
227
228fn file_stamp(path: &Path) -> Option<FileStamp> {
230 let meta = fs::metadata(path).ok()?;
231 let mtime = meta.modified().ok()?;
232 Some(FileStamp {
233 mtime,
234 len: meta.len(),
235 })
236}
237
238static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
241
242#[derive(Clone)]
244pub struct ConversationManager {
245 conversations_dir: PathBuf,
246 compactions_dir: PathBuf,
247 seen: Arc<Mutex<HashMap<String, FileStamp>>>,
254}
255
256impl ConversationManager {
257 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
259 let mermaid_dir = project_dir.as_ref().join(".mermaid");
260 let conversations_dir = mermaid_dir.join("conversations");
261 let compactions_dir = mermaid_dir.join("compactions");
262
263 fs::create_dir_all(&conversations_dir)?;
265 fs::create_dir_all(&compactions_dir)?;
266
267 Ok(Self {
268 conversations_dir,
269 compactions_dir,
270 seen: Arc::new(Mutex::new(HashMap::new())),
271 })
272 }
273
274 fn record_stamp(&self, id: &str, path: &Path) {
279 if let Some(stamp) = file_stamp(path) {
280 self.seen
281 .lock()
282 .unwrap_or_else(|e| e.into_inner())
283 .insert(id.to_string(), stamp);
284 }
285 }
286
287 fn conflict_sibling_path(&self, id: &str) -> PathBuf {
292 let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
293 self.conversations_dir
294 .join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
295 }
296
297 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
299 validate_conversation_id(&conversation.id)?;
303 let filename = format!("{}.json", conversation.id);
304 let path = self.conversations_dir.join(filename);
305
306 let json = match strip_persisted_screenshots(&conversation.messages) {
309 Some(sanitized) => {
310 let mut redacted = conversation.clone();
311 redacted.messages = sanitized;
312 serde_json::to_string_pretty(&redacted)?
313 },
314 None => serde_json::to_string_pretty(conversation)?,
315 };
316
317 let baseline = self
325 .seen
326 .lock()
327 .unwrap_or_else(|e| e.into_inner())
328 .get(&conversation.id)
329 .copied();
330 if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
331 && current != base
332 {
333 let sibling = self.conflict_sibling_path(&conversation.id);
334 crate::runtime::write_atomic(&sibling, json.as_bytes())?;
336 tracing::warn!(
337 id = %conversation.id,
338 main = %path.display(),
339 conflict = %sibling.display(),
340 "conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
341 );
342 return Ok(());
343 }
344
345 crate::runtime::write_atomic(&path, json.as_bytes())?;
348 self.record_stamp(&conversation.id, &path);
351
352 Ok(())
353 }
354
355 pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
359 validate_conversation_id(&archive.conversation_id)?;
362 anyhow::ensure!(
363 !archive.id.is_empty()
364 && !archive.id.contains(['/', '\\'])
365 && !archive.id.contains(".."),
366 "invalid compaction archive id: {:?}",
367 archive.id
368 );
369 let dir = self.compactions_dir.join(&archive.conversation_id);
370 fs::create_dir_all(&dir)?;
371 let path = dir.join(format!("{}.json", archive.id));
372 let json = match strip_persisted_screenshots(&archive.messages) {
376 Some(sanitized) => {
377 let mut redacted = archive.clone();
378 redacted.messages = sanitized;
379 serde_json::to_string_pretty(&redacted)?
380 },
381 None => serde_json::to_string_pretty(archive)?,
382 };
383 crate::runtime::write_atomic(&path, json.as_bytes())?;
386 Ok(path)
387 }
388
389 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
391 validate_conversation_id(id)?;
392 let filename = format!("{}.json", id);
393 let path = self.conversations_dir.join(filename);
394
395 let json = read_conversation_capped(&path)?;
396 let conversation: ConversationHistory = serde_json::from_str(&json)?;
397 validate_conversation_id(&conversation.id)?;
400
401 self.record_stamp(&conversation.id, &path);
404
405 Ok(conversation)
406 }
407
408 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
416 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
417 return Ok(None);
418 };
419
420 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
421 .flatten()
422 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
423 .filter_map(|e| {
424 let mtime = e.metadata().ok()?.modified().ok()?;
425 Some((mtime, e.path()))
426 })
427 .collect();
428 candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
429
430 for (_, path) in candidates {
431 let Ok(json) = read_conversation_capped(&path) else {
432 tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
433 continue;
434 };
435 let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
436 tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
437 continue;
438 };
439 if validate_conversation_id(&conv.id).is_err() {
442 tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
443 continue;
444 }
445 self.record_stamp(&conv.id, &path);
448 return Ok(Some(conv));
449 }
450 Ok(None)
451 }
452
453 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
455 let mut conversations = Vec::new();
456
457 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
459 for entry in entries.flatten() {
460 if let Some(ext) = entry.path().extension()
461 && ext == "json"
462 && let Ok(json) = read_conversation_capped(&entry.path())
463 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
464 {
465 conversations.push(conv);
466 }
467 }
468 }
469
470 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
472
473 Ok(conversations)
474 }
475
476 pub fn delete_conversation(&self, id: &str) -> Result<()> {
478 validate_conversation_id(id)?;
479 let filename = format!("{}.json", id);
480 let path = self.conversations_dir.join(filename);
481
482 if path.exists() {
483 fs::remove_file(path)?;
484 }
485
486 Ok(())
487 }
488
489 pub fn conversations_dir(&self) -> &Path {
491 &self.conversations_dir
492 }
493
494 pub fn compactions_dir(&self) -> &Path {
495 &self.compactions_dir
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn validate_conversation_id_rejects_traversal() {
505 assert!(validate_conversation_id("20260101_120000_001").is_ok());
506 assert!(validate_conversation_id("../secret").is_err());
507 assert!(validate_conversation_id("..\\secret").is_err());
508 assert!(validate_conversation_id("/etc/passwd").is_err());
509 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
512
513 #[test]
514 fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
515 let messages = vec![
516 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
517 ChatMessage::assistant("here is the screen")
518 .with_images(vec!["SCREENSHOT_B64".to_string()]),
519 ChatMessage::assistant("no image here"),
520 ];
521 let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
522 assert_eq!(
524 sanitized[0].images.as_deref(),
525 Some(["USER_PASTED_B64".to_string()].as_slice())
526 );
527 assert!(sanitized[1].images.is_none());
529 assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
530 assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
532 }
533
534 #[test]
535 fn strip_persisted_screenshots_is_none_without_assistant_images() {
536 let messages = vec![
537 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
538 ChatMessage::assistant("no images"),
539 ];
540 assert!(strip_persisted_screenshots(&messages).is_none());
541 }
542
543 #[test]
544 fn saved_conversation_json_has_no_screenshot_bytes() {
545 let dir = std::env::temp_dir().join("mermaid_strip_test");
546 let _ = fs::create_dir_all(&dir);
547 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
548 conv.messages = vec![
549 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
550 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
551 ];
552 let store = ConversationManager {
553 conversations_dir: dir.clone(),
554 compactions_dir: dir.clone(),
555 seen: Arc::new(Mutex::new(HashMap::new())),
556 };
557 store.save_conversation(&conv).expect("save");
558 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
559 assert!(
560 !raw.contains("SHOTBYTES"),
561 "screenshot leaked to disk: {raw}"
562 );
563 assert!(raw.contains("USERIMG"), "user image should persist");
564 assert_eq!(
566 conv.messages[1].images.as_deref(),
567 Some(["SHOTBYTES".to_string()].as_slice())
568 );
569 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
570 }
571
572 #[test]
573 fn test_new_conversation_has_session_title() {
574 let conv =
575 ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
576 assert!(conv.title.starts_with("Session "));
577 assert_eq!(conv.model_name, "test-model");
578 assert_eq!(conv.project_path, "/tmp/project");
579 assert!(conv.messages.is_empty());
580 }
581
582 #[test]
583 fn test_title_updates_from_first_user_message() {
584 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
585 conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
586 assert_eq!(conv.title, "Fix the login bug");
587 }
588
589 #[test]
590 fn test_title_truncated_at_60_chars() {
591 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
592 let long_msg = "a".repeat(100);
593 conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
594 assert!(conv.title.ends_with("..."));
595 assert!(conv.title.len() <= 64); }
597
598 #[test]
599 fn test_title_set_only_once() {
600 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
601 conv.add_messages(&[ChatMessage::user("First message")], Local::now());
602 conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
603 assert_eq!(conv.title, "First message");
604 }
605
606 #[test]
607 fn test_input_history_deduplication() {
608 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
609 conv.add_to_input_history("hello".into());
610 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
612 assert_eq!(conv.input_history.len(), 2);
613 }
614
615 #[test]
616 fn test_input_history_skips_empty() {
617 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
618 conv.add_to_input_history("".into());
619 conv.add_to_input_history(" ".into());
620 assert_eq!(conv.input_history.len(), 0);
621 }
622
623 #[test]
624 fn test_input_history_capped_at_100() {
625 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
626 for i in 0..110 {
627 conv.add_to_input_history(format!("msg{}", i));
628 }
629 assert_eq!(conv.input_history.len(), 100);
630 assert_eq!(conv.input_history.front().unwrap(), "msg10");
631 }
632
633 #[test]
634 fn test_save_load_roundtrip() {
635 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
636 let _ = fs::remove_dir_all(&dir);
637 let manager = ConversationManager::new(&dir).unwrap();
638
639 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
640 conv.add_messages(&[ChatMessage::user("test message")], Local::now());
641 conv.add_to_input_history("test message".into());
642
643 manager.save_conversation(&conv).unwrap();
644 let loaded = manager.load_conversation(&conv.id).unwrap();
645
646 assert_eq!(loaded.id, conv.id);
647 assert_eq!(loaded.title, conv.title);
648 assert_eq!(loaded.messages.len(), 1);
649 assert_eq!(loaded.input_history.len(), 1);
650
651 let _ = fs::remove_dir_all(&dir);
652 }
653
654 #[test]
655 fn test_list_conversations_ordered_by_updated_at() {
656 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
657 let _ = fs::remove_dir_all(&dir);
658 let manager = ConversationManager::new(&dir).unwrap();
659
660 let conv1 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
661 std::thread::sleep(std::time::Duration::from_millis(10));
662 let conv2 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
663
664 manager.save_conversation(&conv1).unwrap();
665 manager.save_conversation(&conv2).unwrap();
666
667 let list = manager.list_conversations().unwrap();
668 assert_eq!(list.len(), 2);
669 assert_eq!(list[0].id, conv2.id);
671 assert_eq!(list[1].id, conv1.id);
672
673 let _ = fs::remove_dir_all(&dir);
674 }
675
676 #[test]
677 fn test_load_last_conversation() {
678 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
679 let _ = fs::remove_dir_all(&dir);
680 let manager = ConversationManager::new(&dir).unwrap();
681
682 assert!(manager.load_last_conversation().unwrap().is_none());
683
684 let conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
685 manager.save_conversation(&conv).unwrap();
686
687 let last = manager.load_last_conversation().unwrap().unwrap();
688 assert_eq!(last.id, conv.id);
689
690 let _ = fs::remove_dir_all(&dir);
691 }
692
693 #[test]
694 fn test_load_last_conversation_picks_newest_by_mtime() {
695 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
700 let _ = fs::remove_dir_all(&dir);
701 let manager = ConversationManager::new(&dir).unwrap();
702
703 let conv1 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
704 manager.save_conversation(&conv1).unwrap();
705 std::thread::sleep(std::time::Duration::from_millis(10));
706
707 let conv2 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
708 manager.save_conversation(&conv2).unwrap();
709 std::thread::sleep(std::time::Duration::from_millis(10));
710
711 let conv3 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
712 manager.save_conversation(&conv3).unwrap();
713
714 let last = manager.load_last_conversation().unwrap().unwrap();
715 assert_eq!(
716 last.id, conv3.id,
717 "should return the most-recently-written file"
718 );
719
720 let _ = fs::remove_dir_all(&dir);
721 }
722
723 #[test]
724 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
725 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
726 let _ = fs::remove_dir_all(&dir);
727 let manager = ConversationManager::new(&dir).unwrap();
728
729 let good = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
730 manager.save_conversation(&good).unwrap();
731 std::thread::sleep(std::time::Duration::from_millis(10));
732
733 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
736 fs::write(&corrupt, b"{ not valid json").unwrap();
737
738 let last = manager.load_last_conversation().unwrap().unwrap();
739 assert_eq!(
740 last.id, good.id,
741 "must fall back to the newest VALID conversation"
742 );
743 let _ = fs::remove_dir_all(&dir);
744 }
745
746 #[test]
747 fn load_last_conversation_none_when_only_corrupt() {
748 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
749 let _ = fs::remove_dir_all(&dir);
750 let manager = ConversationManager::new(&dir).unwrap();
751 fs::write(
752 manager.conversations_dir().join("20991231_235959_998.json"),
753 b"nope",
754 )
755 .unwrap();
756 assert!(manager.load_last_conversation().unwrap().is_none());
757 let _ = fs::remove_dir_all(&dir);
758 }
759
760 #[test]
761 fn load_conversation_tolerates_unknown_message_role() {
762 let dir =
767 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
768 let _ = fs::remove_dir_all(&dir);
769 let manager = ConversationManager::new(&dir).unwrap();
770
771 let id = "20260101_120000_001";
772 let json = format!(
773 r#"{{
774 "id": "{id}",
775 "title": "skew",
776 "messages": [
777 {{
778 "role": "Developer",
779 "content": "from a newer build",
780 "timestamp": "2026-01-01T12:00:00-04:00"
781 }}
782 ],
783 "model_name": "m",
784 "project_path": "/tmp",
785 "created_at": "2026-01-01T12:00:00-04:00",
786 "updated_at": "2026-01-01T12:00:00-04:00",
787 "total_tokens": null
788 }}"#
789 );
790 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
791
792 let loaded = manager
793 .load_conversation(id)
794 .expect("must load despite an unknown role");
795 assert_eq!(loaded.messages.len(), 1);
796 assert_eq!(
797 loaded.messages[0].role,
798 MessageRole::System,
799 "an unknown role becomes a neutral System message"
800 );
801
802 let last = manager
804 .load_last_conversation()
805 .unwrap()
806 .expect("the newest session must load");
807 assert_eq!(last.id, id);
808
809 let _ = fs::remove_dir_all(&dir);
810 }
811
812 #[test]
813 fn test_delete_conversation() {
814 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
815 let _ = fs::remove_dir_all(&dir);
816 let manager = ConversationManager::new(&dir).unwrap();
817
818 let conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
819 manager.save_conversation(&conv).unwrap();
820 assert_eq!(manager.list_conversations().unwrap().len(), 1);
821
822 manager.delete_conversation(&conv.id).unwrap();
823 assert_eq!(manager.list_conversations().unwrap().len(), 0);
824
825 let _ = fs::remove_dir_all(&dir);
826 }
827
828 #[test]
829 fn read_conversation_capped_refuses_oversized_file() {
830 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
833 let _ = fs::remove_dir_all(&dir);
834 fs::create_dir_all(&dir).unwrap();
835
836 let small = dir.join("small.json");
837 fs::write(&small, b"{}").unwrap();
838 assert!(read_conversation_capped(&small).is_ok());
839
840 let big = dir.join("big.json");
841 let f = fs::File::create(&big).unwrap();
842 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
843 assert!(
844 read_conversation_capped(&big).is_err(),
845 "a file over the cap must be refused, not slurped into memory"
846 );
847
848 let _ = fs::remove_dir_all(&dir);
849 }
850
851 #[test]
852 fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
853 let dir =
859 std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
860 let _ = fs::remove_dir_all(&dir);
861 let manager = ConversationManager::new(&dir).unwrap();
862
863 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
864 conv.add_messages(&[ChatMessage::user("ours")], Local::now());
865 manager.save_conversation(&conv).unwrap();
866 let main = manager
867 .conversations_dir()
868 .join(format!("{}.json", conv.id));
869
870 let other = ConversationManager::new(&dir).unwrap();
873 let mut their_conv = other.load_conversation(&conv.id).unwrap();
874 their_conv.add_messages(
875 &[ChatMessage::user("theirs - extra content here")],
876 Local::now(),
877 );
878 other.save_conversation(&their_conv).unwrap();
879
880 manager.save_conversation(&conv).unwrap();
883 let on_disk: ConversationHistory =
884 serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
885 assert_eq!(
886 on_disk.messages.len(),
887 2,
888 "the concurrent writer's file must be left intact"
889 );
890
891 let mut conflicts = fs::read_dir(manager.conversations_dir())
893 .unwrap()
894 .flatten()
895 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
896 .map(|e| e.path())
897 .collect::<Vec<_>>();
898 assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
899 let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
900 assert!(
901 sibling.contains("ours") && !sibling.contains("theirs"),
902 "the .conflict sibling holds OUR copy, not the concurrent writer's"
903 );
904
905 let listed = manager.list_conversations().unwrap();
908 assert_eq!(
909 listed.len(),
910 1,
911 ".conflict sibling must not appear as a conversation"
912 );
913 assert_eq!(listed[0].id, conv.id);
914
915 let _ = fs::remove_dir_all(&dir);
916 }
917
918 #[test]
919 fn save_conversation_repeated_self_saves_do_not_conflict() {
920 let dir =
923 std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
924 let _ = fs::remove_dir_all(&dir);
925 let manager = ConversationManager::new(&dir).unwrap();
926
927 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
928 conv.add_messages(&[ChatMessage::user("first")], Local::now());
929 manager.save_conversation(&conv).unwrap();
930 conv.add_messages(&[ChatMessage::user("second")], Local::now());
931 manager.save_conversation(&conv).unwrap();
932
933 let conflicts = fs::read_dir(manager.conversations_dir())
934 .unwrap()
935 .flatten()
936 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
937 .count();
938 assert_eq!(
939 conflicts, 0,
940 "our own repeated saves must not be flagged as conflicts"
941 );
942 let loaded = manager.load_conversation(&conv.id).unwrap();
943 assert_eq!(loaded.messages.len(), 2, "latest save must win for us");
944
945 let _ = fs::remove_dir_all(&dir);
946 }
947}