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 #[serde(default)]
107 pub git_branch: Option<String>,
108}
109
110pub fn detect_git_branch(dir: &Path) -> Option<String> {
115 let output = std::process::Command::new("git")
116 .args(["rev-parse", "--abbrev-ref", "HEAD"])
117 .current_dir(dir)
118 .output()
119 .ok()?;
120 if !output.status.success() {
121 return None;
122 }
123 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
124 (!branch.is_empty() && branch != "HEAD").then_some(branch)
126}
127
128impl ConversationHistory {
129 pub fn new(project_path: String, model_name: String, now: DateTime<Local>) -> Self {
137 let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
139 Self {
140 id: id.clone(),
141 title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
142 messages: Vec::new(),
143 model_name,
144 project_path,
145 created_at: now,
146 updated_at: now,
147 total_tokens: None,
148 compactions: Vec::new(),
149 input_history: VecDeque::new(),
150 git_branch: None,
153 }
154 }
155
156 pub fn add_messages(&mut self, messages: &[ChatMessage], now: DateTime<Local>) {
160 self.messages.extend_from_slice(messages);
161 self.updated_at = now;
162 self.update_title();
163 }
164
165 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>, now: DateTime<Local>) {
171 self.messages = messages;
172 self.updated_at = now;
173 }
174
175 pub fn add_compaction(
178 &mut self,
179 record: crate::domain::CompactionRecord,
180 now: DateTime<Local>,
181 ) {
182 self.compactions.push(record);
183 self.updated_at = now;
184 }
185
186 pub fn add_to_input_history(&mut self, input: String) {
188 if input.trim().is_empty() {
190 return;
191 }
192
193 if let Some(last) = self.input_history.back()
195 && last == &input
196 {
197 return;
198 }
199
200 if self.input_history.len() >= 100 {
202 self.input_history.pop_front(); }
204
205 self.input_history.push_back(input);
206 }
207
208 fn update_title(&mut self) {
211 if !self.title.starts_with("Session ") {
213 return;
214 }
215 if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
216 let preview = if first_user_msg.content.len() > 60 {
217 let end = first_user_msg.content.floor_char_boundary(60);
218 format!("{}...", &first_user_msg.content[..end])
219 } else {
220 first_user_msg.content.clone()
221 };
222 self.title = preview;
223 }
224 }
225
226 pub fn summary(&self) -> String {
228 let message_count = self.messages.len();
229 let duration = self.updated_at.signed_duration_since(self.created_at);
230 let hours = duration.num_hours();
231 let minutes = duration.num_minutes() % 60;
232
233 format!(
234 "{} | {} messages | {}h {}m | {}",
235 self.updated_at.format("%Y-%m-%d %H:%M"),
236 message_count,
237 hours,
238 minutes,
239 self.title
240 )
241 }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250struct FileStamp {
251 mtime: SystemTime,
252 len: u64,
253}
254
255fn file_stamp(path: &Path) -> Option<FileStamp> {
257 let meta = fs::metadata(path).ok()?;
258 let mtime = meta.modified().ok()?;
259 Some(FileStamp {
260 mtime,
261 len: meta.len(),
262 })
263}
264
265static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
268
269#[derive(Clone)]
271pub struct ConversationManager {
272 conversations_dir: PathBuf,
273 compactions_dir: PathBuf,
274 seen: Arc<Mutex<HashMap<String, FileStamp>>>,
281}
282
283impl ConversationManager {
284 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
286 let mermaid_dir = project_dir.as_ref().join(".mermaid");
287 let conversations_dir = mermaid_dir.join("conversations");
288 let compactions_dir = mermaid_dir.join("compactions");
289
290 fs::create_dir_all(&conversations_dir)?;
292 fs::create_dir_all(&compactions_dir)?;
293
294 Ok(Self {
295 conversations_dir,
296 compactions_dir,
297 seen: Arc::new(Mutex::new(HashMap::new())),
298 })
299 }
300
301 fn record_stamp(&self, id: &str, path: &Path) {
306 if let Some(stamp) = file_stamp(path) {
307 self.seen
308 .lock()
309 .unwrap_or_else(|e| e.into_inner())
310 .insert(id.to_string(), stamp);
311 }
312 }
313
314 fn conflict_sibling_path(&self, id: &str) -> PathBuf {
319 let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
320 self.conversations_dir
321 .join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
322 }
323
324 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
326 validate_conversation_id(&conversation.id)?;
330 let filename = format!("{}.json", conversation.id);
331 let path = self.conversations_dir.join(filename);
332
333 let json = match strip_persisted_screenshots(&conversation.messages) {
336 Some(sanitized) => {
337 let mut redacted = conversation.clone();
338 redacted.messages = sanitized;
339 serde_json::to_string_pretty(&redacted)?
340 },
341 None => serde_json::to_string_pretty(conversation)?,
342 };
343
344 let baseline = self
352 .seen
353 .lock()
354 .unwrap_or_else(|e| e.into_inner())
355 .get(&conversation.id)
356 .copied();
357 if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
358 && current != base
359 {
360 let sibling = self.conflict_sibling_path(&conversation.id);
361 crate::runtime::write_atomic(&sibling, json.as_bytes())?;
363 tracing::warn!(
364 id = %conversation.id,
365 main = %path.display(),
366 conflict = %sibling.display(),
367 "conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
368 );
369 return Ok(());
370 }
371
372 crate::runtime::write_atomic(&path, json.as_bytes())?;
375 self.record_stamp(&conversation.id, &path);
378
379 Ok(())
380 }
381
382 pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
386 validate_conversation_id(&archive.conversation_id)?;
389 anyhow::ensure!(
390 !archive.id.is_empty()
391 && !archive.id.contains(['/', '\\'])
392 && !archive.id.contains(".."),
393 "invalid compaction archive id: {:?}",
394 archive.id
395 );
396 let dir = self.compactions_dir.join(&archive.conversation_id);
397 fs::create_dir_all(&dir)?;
398 let path = dir.join(format!("{}.json", archive.id));
399 let json = match strip_persisted_screenshots(&archive.messages) {
403 Some(sanitized) => {
404 let mut redacted = archive.clone();
405 redacted.messages = sanitized;
406 serde_json::to_string_pretty(&redacted)?
407 },
408 None => serde_json::to_string_pretty(archive)?,
409 };
410 crate::runtime::write_atomic(&path, json.as_bytes())?;
413 Ok(path)
414 }
415
416 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
418 validate_conversation_id(id)?;
419 let filename = format!("{}.json", id);
420 let path = self.conversations_dir.join(filename);
421
422 let json = read_conversation_capped(&path)?;
423 let conversation: ConversationHistory = serde_json::from_str(&json)?;
424 validate_conversation_id(&conversation.id)?;
427
428 self.record_stamp(&conversation.id, &path);
431
432 Ok(conversation)
433 }
434
435 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
443 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
444 return Ok(None);
445 };
446
447 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
448 .flatten()
449 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
450 .filter_map(|e| {
451 let mtime = e.metadata().ok()?.modified().ok()?;
452 Some((mtime, e.path()))
453 })
454 .collect();
455 candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
456
457 for (_, path) in candidates {
458 let Ok(json) = read_conversation_capped(&path) else {
459 tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
460 continue;
461 };
462 let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
463 tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
464 continue;
465 };
466 if validate_conversation_id(&conv.id).is_err() {
469 tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
470 continue;
471 }
472 self.record_stamp(&conv.id, &path);
475 return Ok(Some(conv));
476 }
477 Ok(None)
478 }
479
480 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
482 let mut conversations = Vec::new();
483
484 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
486 for entry in entries.flatten() {
487 if let Some(ext) = entry.path().extension()
488 && ext == "json"
489 && let Ok(json) = read_conversation_capped(&entry.path())
490 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
491 {
492 conversations.push(conv);
493 }
494 }
495 }
496
497 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
499
500 Ok(conversations)
501 }
502
503 pub fn delete_conversation(&self, id: &str) -> Result<()> {
505 validate_conversation_id(id)?;
506 let filename = format!("{}.json", id);
507 let path = self.conversations_dir.join(filename);
508
509 if path.exists() {
510 fs::remove_file(path)?;
511 }
512
513 Ok(())
514 }
515
516 pub fn conversations_dir(&self) -> &Path {
518 &self.conversations_dir
519 }
520
521 pub fn compactions_dir(&self) -> &Path {
522 &self.compactions_dir
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn legacy_conversation_json_without_git_branch_deserializes() {
532 let json = r#"{
536 "id": "20260101_120000_001",
537 "title": "Legacy session",
538 "messages": [],
539 "model_name": "ollama/test",
540 "project_path": "/tmp/proj",
541 "created_at": "2026-01-01T12:00:00-05:00",
542 "updated_at": "2026-01-01T12:00:00-05:00",
543 "total_tokens": null
544 }"#;
545 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
546 assert!(conv.git_branch.is_none());
547 assert_eq!(conv.title, "Legacy session");
548 let mut fresh =
550 ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
551 fresh.git_branch = Some("feature/x".to_string());
552 let round: ConversationHistory =
553 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
554 assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
555 }
556
557 #[test]
558 fn validate_conversation_id_rejects_traversal() {
559 assert!(validate_conversation_id("20260101_120000_001").is_ok());
560 assert!(validate_conversation_id("../secret").is_err());
561 assert!(validate_conversation_id("..\\secret").is_err());
562 assert!(validate_conversation_id("/etc/passwd").is_err());
563 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
566
567 #[test]
568 fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
569 let messages = vec![
570 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
571 ChatMessage::assistant("here is the screen")
572 .with_images(vec!["SCREENSHOT_B64".to_string()]),
573 ChatMessage::assistant("no image here"),
574 ];
575 let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
576 assert_eq!(
578 sanitized[0].images.as_deref(),
579 Some(["USER_PASTED_B64".to_string()].as_slice())
580 );
581 assert!(sanitized[1].images.is_none());
583 assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
584 assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
586 }
587
588 #[test]
589 fn strip_persisted_screenshots_is_none_without_assistant_images() {
590 let messages = vec![
591 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
592 ChatMessage::assistant("no images"),
593 ];
594 assert!(strip_persisted_screenshots(&messages).is_none());
595 }
596
597 #[test]
598 fn saved_conversation_json_has_no_screenshot_bytes() {
599 let dir = std::env::temp_dir().join("mermaid_strip_test");
600 let _ = fs::create_dir_all(&dir);
601 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
602 conv.messages = vec![
603 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
604 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
605 ];
606 let store = ConversationManager {
607 conversations_dir: dir.clone(),
608 compactions_dir: dir.clone(),
609 seen: Arc::new(Mutex::new(HashMap::new())),
610 };
611 store.save_conversation(&conv).expect("save");
612 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
613 assert!(
614 !raw.contains("SHOTBYTES"),
615 "screenshot leaked to disk: {raw}"
616 );
617 assert!(raw.contains("USERIMG"), "user image should persist");
618 assert_eq!(
620 conv.messages[1].images.as_deref(),
621 Some(["SHOTBYTES".to_string()].as_slice())
622 );
623 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
624 }
625
626 #[test]
627 fn test_new_conversation_has_session_title() {
628 let conv =
629 ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
630 assert!(conv.title.starts_with("Session "));
631 assert_eq!(conv.model_name, "test-model");
632 assert_eq!(conv.project_path, "/tmp/project");
633 assert!(conv.messages.is_empty());
634 }
635
636 #[test]
637 fn test_title_updates_from_first_user_message() {
638 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
639 conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
640 assert_eq!(conv.title, "Fix the login bug");
641 }
642
643 #[test]
644 fn test_title_truncated_at_60_chars() {
645 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
646 let long_msg = "a".repeat(100);
647 conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
648 assert!(conv.title.ends_with("..."));
649 assert!(conv.title.len() <= 64); }
651
652 #[test]
653 fn test_title_set_only_once() {
654 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
655 conv.add_messages(&[ChatMessage::user("First message")], Local::now());
656 conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
657 assert_eq!(conv.title, "First message");
658 }
659
660 #[test]
661 fn test_input_history_deduplication() {
662 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
663 conv.add_to_input_history("hello".into());
664 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
666 assert_eq!(conv.input_history.len(), 2);
667 }
668
669 #[test]
670 fn test_input_history_skips_empty() {
671 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
672 conv.add_to_input_history("".into());
673 conv.add_to_input_history(" ".into());
674 assert_eq!(conv.input_history.len(), 0);
675 }
676
677 #[test]
678 fn test_input_history_capped_at_100() {
679 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
680 for i in 0..110 {
681 conv.add_to_input_history(format!("msg{}", i));
682 }
683 assert_eq!(conv.input_history.len(), 100);
684 assert_eq!(conv.input_history.front().unwrap(), "msg10");
685 }
686
687 #[test]
688 fn test_save_load_roundtrip() {
689 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
690 let _ = fs::remove_dir_all(&dir);
691 let manager = ConversationManager::new(&dir).unwrap();
692
693 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
694 conv.add_messages(&[ChatMessage::user("test message")], Local::now());
695 conv.add_to_input_history("test message".into());
696
697 manager.save_conversation(&conv).unwrap();
698 let loaded = manager.load_conversation(&conv.id).unwrap();
699
700 assert_eq!(loaded.id, conv.id);
701 assert_eq!(loaded.title, conv.title);
702 assert_eq!(loaded.messages.len(), 1);
703 assert_eq!(loaded.input_history.len(), 1);
704
705 let _ = fs::remove_dir_all(&dir);
706 }
707
708 #[test]
709 fn test_list_conversations_ordered_by_updated_at() {
710 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
711 let _ = fs::remove_dir_all(&dir);
712 let manager = ConversationManager::new(&dir).unwrap();
713
714 let conv1 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
715 std::thread::sleep(std::time::Duration::from_millis(10));
716 let conv2 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
717
718 manager.save_conversation(&conv1).unwrap();
719 manager.save_conversation(&conv2).unwrap();
720
721 let list = manager.list_conversations().unwrap();
722 assert_eq!(list.len(), 2);
723 assert_eq!(list[0].id, conv2.id);
725 assert_eq!(list[1].id, conv1.id);
726
727 let _ = fs::remove_dir_all(&dir);
728 }
729
730 #[test]
731 fn test_load_last_conversation() {
732 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
733 let _ = fs::remove_dir_all(&dir);
734 let manager = ConversationManager::new(&dir).unwrap();
735
736 assert!(manager.load_last_conversation().unwrap().is_none());
737
738 let conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
739 manager.save_conversation(&conv).unwrap();
740
741 let last = manager.load_last_conversation().unwrap().unwrap();
742 assert_eq!(last.id, conv.id);
743
744 let _ = fs::remove_dir_all(&dir);
745 }
746
747 #[test]
748 fn test_load_last_conversation_picks_newest_by_mtime() {
749 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
754 let _ = fs::remove_dir_all(&dir);
755 let manager = ConversationManager::new(&dir).unwrap();
756
757 let conv1 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
758 manager.save_conversation(&conv1).unwrap();
759 std::thread::sleep(std::time::Duration::from_millis(10));
760
761 let conv2 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
762 manager.save_conversation(&conv2).unwrap();
763 std::thread::sleep(std::time::Duration::from_millis(10));
764
765 let conv3 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
766 manager.save_conversation(&conv3).unwrap();
767
768 let last = manager.load_last_conversation().unwrap().unwrap();
769 assert_eq!(
770 last.id, conv3.id,
771 "should return the most-recently-written file"
772 );
773
774 let _ = fs::remove_dir_all(&dir);
775 }
776
777 #[test]
778 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
779 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
780 let _ = fs::remove_dir_all(&dir);
781 let manager = ConversationManager::new(&dir).unwrap();
782
783 let good = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
784 manager.save_conversation(&good).unwrap();
785 std::thread::sleep(std::time::Duration::from_millis(10));
786
787 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
790 fs::write(&corrupt, b"{ not valid json").unwrap();
791
792 let last = manager.load_last_conversation().unwrap().unwrap();
793 assert_eq!(
794 last.id, good.id,
795 "must fall back to the newest VALID conversation"
796 );
797 let _ = fs::remove_dir_all(&dir);
798 }
799
800 #[test]
801 fn load_last_conversation_none_when_only_corrupt() {
802 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
803 let _ = fs::remove_dir_all(&dir);
804 let manager = ConversationManager::new(&dir).unwrap();
805 fs::write(
806 manager.conversations_dir().join("20991231_235959_998.json"),
807 b"nope",
808 )
809 .unwrap();
810 assert!(manager.load_last_conversation().unwrap().is_none());
811 let _ = fs::remove_dir_all(&dir);
812 }
813
814 #[test]
815 fn load_conversation_tolerates_unknown_message_role() {
816 let dir =
821 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
822 let _ = fs::remove_dir_all(&dir);
823 let manager = ConversationManager::new(&dir).unwrap();
824
825 let id = "20260101_120000_001";
826 let json = format!(
827 r#"{{
828 "id": "{id}",
829 "title": "skew",
830 "messages": [
831 {{
832 "role": "Developer",
833 "content": "from a newer build",
834 "timestamp": "2026-01-01T12:00:00-04:00"
835 }}
836 ],
837 "model_name": "m",
838 "project_path": "/tmp",
839 "created_at": "2026-01-01T12:00:00-04:00",
840 "updated_at": "2026-01-01T12:00:00-04:00",
841 "total_tokens": null
842 }}"#
843 );
844 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
845
846 let loaded = manager
847 .load_conversation(id)
848 .expect("must load despite an unknown role");
849 assert_eq!(loaded.messages.len(), 1);
850 assert_eq!(
851 loaded.messages[0].role,
852 MessageRole::System,
853 "an unknown role becomes a neutral System message"
854 );
855
856 let last = manager
858 .load_last_conversation()
859 .unwrap()
860 .expect("the newest session must load");
861 assert_eq!(last.id, id);
862
863 let _ = fs::remove_dir_all(&dir);
864 }
865
866 #[test]
867 fn test_delete_conversation() {
868 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
869 let _ = fs::remove_dir_all(&dir);
870 let manager = ConversationManager::new(&dir).unwrap();
871
872 let conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
873 manager.save_conversation(&conv).unwrap();
874 assert_eq!(manager.list_conversations().unwrap().len(), 1);
875
876 manager.delete_conversation(&conv.id).unwrap();
877 assert_eq!(manager.list_conversations().unwrap().len(), 0);
878
879 let _ = fs::remove_dir_all(&dir);
880 }
881
882 #[test]
883 fn read_conversation_capped_refuses_oversized_file() {
884 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
887 let _ = fs::remove_dir_all(&dir);
888 fs::create_dir_all(&dir).unwrap();
889
890 let small = dir.join("small.json");
891 fs::write(&small, b"{}").unwrap();
892 assert!(read_conversation_capped(&small).is_ok());
893
894 let big = dir.join("big.json");
895 let f = fs::File::create(&big).unwrap();
896 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
897 assert!(
898 read_conversation_capped(&big).is_err(),
899 "a file over the cap must be refused, not slurped into memory"
900 );
901
902 let _ = fs::remove_dir_all(&dir);
903 }
904
905 #[test]
906 fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
907 let dir =
913 std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
914 let _ = fs::remove_dir_all(&dir);
915 let manager = ConversationManager::new(&dir).unwrap();
916
917 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
918 conv.add_messages(&[ChatMessage::user("ours")], Local::now());
919 manager.save_conversation(&conv).unwrap();
920 let main = manager
921 .conversations_dir()
922 .join(format!("{}.json", conv.id));
923
924 let other = ConversationManager::new(&dir).unwrap();
927 let mut their_conv = other.load_conversation(&conv.id).unwrap();
928 their_conv.add_messages(
929 &[ChatMessage::user("theirs - extra content here")],
930 Local::now(),
931 );
932 other.save_conversation(&their_conv).unwrap();
933
934 manager.save_conversation(&conv).unwrap();
937 let on_disk: ConversationHistory =
938 serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
939 assert_eq!(
940 on_disk.messages.len(),
941 2,
942 "the concurrent writer's file must be left intact"
943 );
944
945 let mut conflicts = fs::read_dir(manager.conversations_dir())
947 .unwrap()
948 .flatten()
949 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
950 .map(|e| e.path())
951 .collect::<Vec<_>>();
952 assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
953 let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
954 assert!(
955 sibling.contains("ours") && !sibling.contains("theirs"),
956 "the .conflict sibling holds OUR copy, not the concurrent writer's"
957 );
958
959 let listed = manager.list_conversations().unwrap();
962 assert_eq!(
963 listed.len(),
964 1,
965 ".conflict sibling must not appear as a conversation"
966 );
967 assert_eq!(listed[0].id, conv.id);
968
969 let _ = fs::remove_dir_all(&dir);
970 }
971
972 #[test]
973 fn save_conversation_repeated_self_saves_do_not_conflict() {
974 let dir =
977 std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
978 let _ = fs::remove_dir_all(&dir);
979 let manager = ConversationManager::new(&dir).unwrap();
980
981 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
982 conv.add_messages(&[ChatMessage::user("first")], Local::now());
983 manager.save_conversation(&conv).unwrap();
984 conv.add_messages(&[ChatMessage::user("second")], Local::now());
985 manager.save_conversation(&conv).unwrap();
986
987 let conflicts = fs::read_dir(manager.conversations_dir())
988 .unwrap()
989 .flatten()
990 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
991 .count();
992 assert_eq!(
993 conflicts, 0,
994 "our own repeated saves must not be flagged as conflicts"
995 );
996 let loaded = manager.load_conversation(&conv.id).unwrap();
997 assert_eq!(loaded.messages.len(), 2, "latest save must win for us");
998
999 let _ = fs::remove_dir_all(&dir);
1000 }
1001}