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) -> Self {
107 let now = Local::now();
108 let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
110 Self {
111 id: id.clone(),
112 title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
113 messages: Vec::new(),
114 model_name,
115 project_path,
116 created_at: now,
117 updated_at: now,
118 total_tokens: None,
119 compactions: Vec::new(),
120 input_history: VecDeque::new(),
121 }
122 }
123
124 pub fn add_messages(&mut self, messages: &[ChatMessage]) {
126 self.messages.extend_from_slice(messages);
127 self.updated_at = Local::now();
128 self.update_title();
129 }
130
131 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>) {
135 self.messages = messages;
136 self.updated_at = Local::now();
137 }
138
139 pub fn add_compaction(&mut self, record: crate::domain::CompactionRecord) {
141 self.compactions.push(record);
142 self.updated_at = Local::now();
143 }
144
145 pub fn add_to_input_history(&mut self, input: String) {
147 if input.trim().is_empty() {
149 return;
150 }
151
152 if let Some(last) = self.input_history.back()
154 && last == &input
155 {
156 return;
157 }
158
159 if self.input_history.len() >= 100 {
161 self.input_history.pop_front(); }
163
164 self.input_history.push_back(input);
165 }
166
167 fn update_title(&mut self) {
170 if !self.title.starts_with("Session ") {
172 return;
173 }
174 if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
175 let preview = if first_user_msg.content.len() > 60 {
176 let end = first_user_msg.content.floor_char_boundary(60);
177 format!("{}...", &first_user_msg.content[..end])
178 } else {
179 first_user_msg.content.clone()
180 };
181 self.title = preview;
182 }
183 }
184
185 pub fn summary(&self) -> String {
187 let message_count = self.messages.len();
188 let duration = self.updated_at.signed_duration_since(self.created_at);
189 let hours = duration.num_hours();
190 let minutes = duration.num_minutes() % 60;
191
192 format!(
193 "{} | {} messages | {}h {}m | {}",
194 self.updated_at.format("%Y-%m-%d %H:%M"),
195 message_count,
196 hours,
197 minutes,
198 self.title
199 )
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209struct FileStamp {
210 mtime: SystemTime,
211 len: u64,
212}
213
214fn file_stamp(path: &Path) -> Option<FileStamp> {
216 let meta = fs::metadata(path).ok()?;
217 let mtime = meta.modified().ok()?;
218 Some(FileStamp {
219 mtime,
220 len: meta.len(),
221 })
222}
223
224static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
227
228#[derive(Clone)]
230pub struct ConversationManager {
231 conversations_dir: PathBuf,
232 compactions_dir: PathBuf,
233 seen: Arc<Mutex<HashMap<String, FileStamp>>>,
240}
241
242impl ConversationManager {
243 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
245 let mermaid_dir = project_dir.as_ref().join(".mermaid");
246 let conversations_dir = mermaid_dir.join("conversations");
247 let compactions_dir = mermaid_dir.join("compactions");
248
249 fs::create_dir_all(&conversations_dir)?;
251 fs::create_dir_all(&compactions_dir)?;
252
253 Ok(Self {
254 conversations_dir,
255 compactions_dir,
256 seen: Arc::new(Mutex::new(HashMap::new())),
257 })
258 }
259
260 fn record_stamp(&self, id: &str, path: &Path) {
265 if let Some(stamp) = file_stamp(path) {
266 self.seen
267 .lock()
268 .unwrap_or_else(|e| e.into_inner())
269 .insert(id.to_string(), stamp);
270 }
271 }
272
273 fn conflict_sibling_path(&self, id: &str) -> PathBuf {
278 let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
279 self.conversations_dir
280 .join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
281 }
282
283 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
285 validate_conversation_id(&conversation.id)?;
289 let filename = format!("{}.json", conversation.id);
290 let path = self.conversations_dir.join(filename);
291
292 let json = match strip_persisted_screenshots(&conversation.messages) {
295 Some(sanitized) => {
296 let mut redacted = conversation.clone();
297 redacted.messages = sanitized;
298 serde_json::to_string_pretty(&redacted)?
299 },
300 None => serde_json::to_string_pretty(conversation)?,
301 };
302
303 let baseline = self
311 .seen
312 .lock()
313 .unwrap_or_else(|e| e.into_inner())
314 .get(&conversation.id)
315 .copied();
316 if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
317 && current != base
318 {
319 let sibling = self.conflict_sibling_path(&conversation.id);
320 crate::runtime::write_atomic(&sibling, json.as_bytes())?;
322 tracing::warn!(
323 id = %conversation.id,
324 main = %path.display(),
325 conflict = %sibling.display(),
326 "conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
327 );
328 return Ok(());
329 }
330
331 crate::runtime::write_atomic(&path, json.as_bytes())?;
334 self.record_stamp(&conversation.id, &path);
337
338 Ok(())
339 }
340
341 pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
345 validate_conversation_id(&archive.conversation_id)?;
348 anyhow::ensure!(
349 !archive.id.is_empty()
350 && !archive.id.contains(['/', '\\'])
351 && !archive.id.contains(".."),
352 "invalid compaction archive id: {:?}",
353 archive.id
354 );
355 let dir = self.compactions_dir.join(&archive.conversation_id);
356 fs::create_dir_all(&dir)?;
357 let path = dir.join(format!("{}.json", archive.id));
358 let json = match strip_persisted_screenshots(&archive.messages) {
362 Some(sanitized) => {
363 let mut redacted = archive.clone();
364 redacted.messages = sanitized;
365 serde_json::to_string_pretty(&redacted)?
366 },
367 None => serde_json::to_string_pretty(archive)?,
368 };
369 crate::runtime::write_atomic(&path, json.as_bytes())?;
372 Ok(path)
373 }
374
375 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
377 validate_conversation_id(id)?;
378 let filename = format!("{}.json", id);
379 let path = self.conversations_dir.join(filename);
380
381 let json = read_conversation_capped(&path)?;
382 let conversation: ConversationHistory = serde_json::from_str(&json)?;
383 validate_conversation_id(&conversation.id)?;
386
387 self.record_stamp(&conversation.id, &path);
390
391 Ok(conversation)
392 }
393
394 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
402 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
403 return Ok(None);
404 };
405
406 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
407 .flatten()
408 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
409 .filter_map(|e| {
410 let mtime = e.metadata().ok()?.modified().ok()?;
411 Some((mtime, e.path()))
412 })
413 .collect();
414 candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
415
416 for (_, path) in candidates {
417 let Ok(json) = read_conversation_capped(&path) else {
418 tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
419 continue;
420 };
421 let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
422 tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
423 continue;
424 };
425 if validate_conversation_id(&conv.id).is_err() {
428 tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
429 continue;
430 }
431 self.record_stamp(&conv.id, &path);
434 return Ok(Some(conv));
435 }
436 Ok(None)
437 }
438
439 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
441 let mut conversations = Vec::new();
442
443 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
445 for entry in entries.flatten() {
446 if let Some(ext) = entry.path().extension()
447 && ext == "json"
448 && let Ok(json) = read_conversation_capped(&entry.path())
449 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
450 {
451 conversations.push(conv);
452 }
453 }
454 }
455
456 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
458
459 Ok(conversations)
460 }
461
462 pub fn delete_conversation(&self, id: &str) -> Result<()> {
464 validate_conversation_id(id)?;
465 let filename = format!("{}.json", id);
466 let path = self.conversations_dir.join(filename);
467
468 if path.exists() {
469 fs::remove_file(path)?;
470 }
471
472 Ok(())
473 }
474
475 pub fn conversations_dir(&self) -> &Path {
477 &self.conversations_dir
478 }
479
480 pub fn compactions_dir(&self) -> &Path {
481 &self.compactions_dir
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn validate_conversation_id_rejects_traversal() {
491 assert!(validate_conversation_id("20260101_120000_001").is_ok());
492 assert!(validate_conversation_id("../secret").is_err());
493 assert!(validate_conversation_id("..\\secret").is_err());
494 assert!(validate_conversation_id("/etc/passwd").is_err());
495 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
498
499 #[test]
500 fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
501 let messages = vec![
502 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
503 ChatMessage::assistant("here is the screen")
504 .with_images(vec!["SCREENSHOT_B64".to_string()]),
505 ChatMessage::assistant("no image here"),
506 ];
507 let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
508 assert_eq!(
510 sanitized[0].images.as_deref(),
511 Some(["USER_PASTED_B64".to_string()].as_slice())
512 );
513 assert!(sanitized[1].images.is_none());
515 assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
516 assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
518 }
519
520 #[test]
521 fn strip_persisted_screenshots_is_none_without_assistant_images() {
522 let messages = vec![
523 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
524 ChatMessage::assistant("no images"),
525 ];
526 assert!(strip_persisted_screenshots(&messages).is_none());
527 }
528
529 #[test]
530 fn saved_conversation_json_has_no_screenshot_bytes() {
531 let dir = std::env::temp_dir().join("mermaid_strip_test");
532 let _ = fs::create_dir_all(&dir);
533 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into());
534 conv.messages = vec![
535 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
536 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
537 ];
538 let store = ConversationManager {
539 conversations_dir: dir.clone(),
540 compactions_dir: dir.clone(),
541 seen: Arc::new(Mutex::new(HashMap::new())),
542 };
543 store.save_conversation(&conv).expect("save");
544 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
545 assert!(
546 !raw.contains("SHOTBYTES"),
547 "screenshot leaked to disk: {raw}"
548 );
549 assert!(raw.contains("USERIMG"), "user image should persist");
550 assert_eq!(
552 conv.messages[1].images.as_deref(),
553 Some(["SHOTBYTES".to_string()].as_slice())
554 );
555 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
556 }
557
558 #[test]
559 fn test_new_conversation_has_session_title() {
560 let conv = ConversationHistory::new("/tmp/project".into(), "test-model".into());
561 assert!(conv.title.starts_with("Session "));
562 assert_eq!(conv.model_name, "test-model");
563 assert_eq!(conv.project_path, "/tmp/project");
564 assert!(conv.messages.is_empty());
565 }
566
567 #[test]
568 fn test_title_updates_from_first_user_message() {
569 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
570 conv.add_messages(&[ChatMessage::user("Fix the login bug")]);
571 assert_eq!(conv.title, "Fix the login bug");
572 }
573
574 #[test]
575 fn test_title_truncated_at_60_chars() {
576 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
577 let long_msg = "a".repeat(100);
578 conv.add_messages(&[ChatMessage::user(long_msg)]);
579 assert!(conv.title.ends_with("..."));
580 assert!(conv.title.len() <= 64); }
582
583 #[test]
584 fn test_title_set_only_once() {
585 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
586 conv.add_messages(&[ChatMessage::user("First message")]);
587 conv.add_messages(&[ChatMessage::user("Second message")]);
588 assert_eq!(conv.title, "First message");
589 }
590
591 #[test]
592 fn test_input_history_deduplication() {
593 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
594 conv.add_to_input_history("hello".into());
595 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
597 assert_eq!(conv.input_history.len(), 2);
598 }
599
600 #[test]
601 fn test_input_history_skips_empty() {
602 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
603 conv.add_to_input_history("".into());
604 conv.add_to_input_history(" ".into());
605 assert_eq!(conv.input_history.len(), 0);
606 }
607
608 #[test]
609 fn test_input_history_capped_at_100() {
610 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
611 for i in 0..110 {
612 conv.add_to_input_history(format!("msg{}", i));
613 }
614 assert_eq!(conv.input_history.len(), 100);
615 assert_eq!(conv.input_history.front().unwrap(), "msg10");
616 }
617
618 #[test]
619 fn test_save_load_roundtrip() {
620 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
621 let _ = fs::remove_dir_all(&dir);
622 let manager = ConversationManager::new(&dir).unwrap();
623
624 let mut conv = ConversationHistory::new("/tmp".into(), "model".into());
625 conv.add_messages(&[ChatMessage::user("test message")]);
626 conv.add_to_input_history("test message".into());
627
628 manager.save_conversation(&conv).unwrap();
629 let loaded = manager.load_conversation(&conv.id).unwrap();
630
631 assert_eq!(loaded.id, conv.id);
632 assert_eq!(loaded.title, conv.title);
633 assert_eq!(loaded.messages.len(), 1);
634 assert_eq!(loaded.input_history.len(), 1);
635
636 let _ = fs::remove_dir_all(&dir);
637 }
638
639 #[test]
640 fn test_list_conversations_ordered_by_updated_at() {
641 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
642 let _ = fs::remove_dir_all(&dir);
643 let manager = ConversationManager::new(&dir).unwrap();
644
645 let conv1 = ConversationHistory::new("/tmp".into(), "m".into());
646 std::thread::sleep(std::time::Duration::from_millis(10));
647 let conv2 = ConversationHistory::new("/tmp".into(), "m".into());
648
649 manager.save_conversation(&conv1).unwrap();
650 manager.save_conversation(&conv2).unwrap();
651
652 let list = manager.list_conversations().unwrap();
653 assert_eq!(list.len(), 2);
654 assert_eq!(list[0].id, conv2.id);
656 assert_eq!(list[1].id, conv1.id);
657
658 let _ = fs::remove_dir_all(&dir);
659 }
660
661 #[test]
662 fn test_load_last_conversation() {
663 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
664 let _ = fs::remove_dir_all(&dir);
665 let manager = ConversationManager::new(&dir).unwrap();
666
667 assert!(manager.load_last_conversation().unwrap().is_none());
668
669 let conv = ConversationHistory::new("/tmp".into(), "m".into());
670 manager.save_conversation(&conv).unwrap();
671
672 let last = manager.load_last_conversation().unwrap().unwrap();
673 assert_eq!(last.id, conv.id);
674
675 let _ = fs::remove_dir_all(&dir);
676 }
677
678 #[test]
679 fn test_load_last_conversation_picks_newest_by_mtime() {
680 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
685 let _ = fs::remove_dir_all(&dir);
686 let manager = ConversationManager::new(&dir).unwrap();
687
688 let conv1 = ConversationHistory::new("/tmp".into(), "m".into());
689 manager.save_conversation(&conv1).unwrap();
690 std::thread::sleep(std::time::Duration::from_millis(10));
691
692 let conv2 = ConversationHistory::new("/tmp".into(), "m".into());
693 manager.save_conversation(&conv2).unwrap();
694 std::thread::sleep(std::time::Duration::from_millis(10));
695
696 let conv3 = ConversationHistory::new("/tmp".into(), "m".into());
697 manager.save_conversation(&conv3).unwrap();
698
699 let last = manager.load_last_conversation().unwrap().unwrap();
700 assert_eq!(
701 last.id, conv3.id,
702 "should return the most-recently-written file"
703 );
704
705 let _ = fs::remove_dir_all(&dir);
706 }
707
708 #[test]
709 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
710 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
711 let _ = fs::remove_dir_all(&dir);
712 let manager = ConversationManager::new(&dir).unwrap();
713
714 let good = ConversationHistory::new("/tmp".into(), "m".into());
715 manager.save_conversation(&good).unwrap();
716 std::thread::sleep(std::time::Duration::from_millis(10));
717
718 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
721 fs::write(&corrupt, b"{ not valid json").unwrap();
722
723 let last = manager.load_last_conversation().unwrap().unwrap();
724 assert_eq!(
725 last.id, good.id,
726 "must fall back to the newest VALID conversation"
727 );
728 let _ = fs::remove_dir_all(&dir);
729 }
730
731 #[test]
732 fn load_last_conversation_none_when_only_corrupt() {
733 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
734 let _ = fs::remove_dir_all(&dir);
735 let manager = ConversationManager::new(&dir).unwrap();
736 fs::write(
737 manager.conversations_dir().join("20991231_235959_998.json"),
738 b"nope",
739 )
740 .unwrap();
741 assert!(manager.load_last_conversation().unwrap().is_none());
742 let _ = fs::remove_dir_all(&dir);
743 }
744
745 #[test]
746 fn load_conversation_tolerates_unknown_message_role() {
747 let dir =
752 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
753 let _ = fs::remove_dir_all(&dir);
754 let manager = ConversationManager::new(&dir).unwrap();
755
756 let id = "20260101_120000_001";
757 let json = format!(
758 r#"{{
759 "id": "{id}",
760 "title": "skew",
761 "messages": [
762 {{
763 "role": "Developer",
764 "content": "from a newer build",
765 "timestamp": "2026-01-01T12:00:00-04:00"
766 }}
767 ],
768 "model_name": "m",
769 "project_path": "/tmp",
770 "created_at": "2026-01-01T12:00:00-04:00",
771 "updated_at": "2026-01-01T12:00:00-04:00",
772 "total_tokens": null
773 }}"#
774 );
775 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
776
777 let loaded = manager
778 .load_conversation(id)
779 .expect("must load despite an unknown role");
780 assert_eq!(loaded.messages.len(), 1);
781 assert_eq!(
782 loaded.messages[0].role,
783 MessageRole::System,
784 "an unknown role becomes a neutral System message"
785 );
786
787 let last = manager
789 .load_last_conversation()
790 .unwrap()
791 .expect("the newest session must load");
792 assert_eq!(last.id, id);
793
794 let _ = fs::remove_dir_all(&dir);
795 }
796
797 #[test]
798 fn test_delete_conversation() {
799 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
800 let _ = fs::remove_dir_all(&dir);
801 let manager = ConversationManager::new(&dir).unwrap();
802
803 let conv = ConversationHistory::new("/tmp".into(), "m".into());
804 manager.save_conversation(&conv).unwrap();
805 assert_eq!(manager.list_conversations().unwrap().len(), 1);
806
807 manager.delete_conversation(&conv.id).unwrap();
808 assert_eq!(manager.list_conversations().unwrap().len(), 0);
809
810 let _ = fs::remove_dir_all(&dir);
811 }
812
813 #[test]
814 fn read_conversation_capped_refuses_oversized_file() {
815 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
818 let _ = fs::remove_dir_all(&dir);
819 fs::create_dir_all(&dir).unwrap();
820
821 let small = dir.join("small.json");
822 fs::write(&small, b"{}").unwrap();
823 assert!(read_conversation_capped(&small).is_ok());
824
825 let big = dir.join("big.json");
826 let f = fs::File::create(&big).unwrap();
827 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
828 assert!(
829 read_conversation_capped(&big).is_err(),
830 "a file over the cap must be refused, not slurped into memory"
831 );
832
833 let _ = fs::remove_dir_all(&dir);
834 }
835
836 #[test]
837 fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
838 let dir =
844 std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
845 let _ = fs::remove_dir_all(&dir);
846 let manager = ConversationManager::new(&dir).unwrap();
847
848 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
849 conv.add_messages(&[ChatMessage::user("ours")]);
850 manager.save_conversation(&conv).unwrap();
851 let main = manager
852 .conversations_dir()
853 .join(format!("{}.json", conv.id));
854
855 let other = ConversationManager::new(&dir).unwrap();
858 let mut their_conv = other.load_conversation(&conv.id).unwrap();
859 their_conv.add_messages(&[ChatMessage::user("theirs - extra content here")]);
860 other.save_conversation(&their_conv).unwrap();
861
862 manager.save_conversation(&conv).unwrap();
865 let on_disk: ConversationHistory =
866 serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
867 assert_eq!(
868 on_disk.messages.len(),
869 2,
870 "the concurrent writer's file must be left intact"
871 );
872
873 let mut conflicts = fs::read_dir(manager.conversations_dir())
875 .unwrap()
876 .flatten()
877 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
878 .map(|e| e.path())
879 .collect::<Vec<_>>();
880 assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
881 let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
882 assert!(
883 sibling.contains("ours") && !sibling.contains("theirs"),
884 "the .conflict sibling holds OUR copy, not the concurrent writer's"
885 );
886
887 let listed = manager.list_conversations().unwrap();
890 assert_eq!(
891 listed.len(),
892 1,
893 ".conflict sibling must not appear as a conversation"
894 );
895 assert_eq!(listed[0].id, conv.id);
896
897 let _ = fs::remove_dir_all(&dir);
898 }
899
900 #[test]
901 fn save_conversation_repeated_self_saves_do_not_conflict() {
902 let dir =
905 std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
906 let _ = fs::remove_dir_all(&dir);
907 let manager = ConversationManager::new(&dir).unwrap();
908
909 let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
910 conv.add_messages(&[ChatMessage::user("first")]);
911 manager.save_conversation(&conv).unwrap();
912 conv.add_messages(&[ChatMessage::user("second")]);
913 manager.save_conversation(&conv).unwrap();
914
915 let conflicts = fs::read_dir(manager.conversations_dir())
916 .unwrap()
917 .flatten()
918 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
919 .count();
920 assert_eq!(
921 conflicts, 0,
922 "our own repeated saves must not be flagged as conflicts"
923 );
924 let loaded = manager.load_conversation(&conv.id).unwrap();
925 assert_eq!(loaded.messages.len(), 2, "latest save must win for us");
926
927 let _ = fs::remove_dir_all(&dir);
928 }
929}