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 #[serde(default)]
115 pub safety_mode: Option<crate::runtime::SafetyMode>,
116 #[serde(default)]
117 pub cumulative_tokens: usize,
118 #[serde(default)]
119 pub last_token_usage: Option<crate::domain::TokenUsageTotals>,
120 #[serde(default)]
121 pub cumulative_token_usage: crate::domain::TokenUsageTotals,
122 #[serde(default)]
123 pub context_usage: Option<crate::domain::ContextUsageSnapshot>,
124}
125
126pub fn detect_git_branch(dir: &Path) -> Option<String> {
131 let output = std::process::Command::new("git")
132 .args(["rev-parse", "--abbrev-ref", "HEAD"])
133 .current_dir(dir)
134 .output()
135 .ok()?;
136 if !output.status.success() {
137 return None;
138 }
139 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
140 (!branch.is_empty() && branch != "HEAD").then_some(branch)
142}
143
144impl ConversationHistory {
145 pub fn new(project_path: String, model_name: String, now: DateTime<Local>) -> Self {
153 let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
155 Self {
156 id: id.clone(),
157 title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
158 messages: Vec::new(),
159 model_name,
160 project_path,
161 created_at: now,
162 updated_at: now,
163 total_tokens: None,
164 compactions: Vec::new(),
165 input_history: VecDeque::new(),
166 git_branch: None,
169 safety_mode: None,
171 cumulative_tokens: 0,
172 last_token_usage: None,
173 cumulative_token_usage: crate::domain::TokenUsageTotals::default(),
174 context_usage: None,
175 }
176 }
177
178 pub fn add_messages(&mut self, messages: &[ChatMessage], now: DateTime<Local>) {
182 self.messages.extend_from_slice(messages);
183 self.updated_at = now;
184 self.update_title();
185 }
186
187 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>, now: DateTime<Local>) {
193 self.messages = messages;
194 self.updated_at = now;
195 }
196
197 pub fn add_compaction(
200 &mut self,
201 record: crate::domain::CompactionRecord,
202 now: DateTime<Local>,
203 ) {
204 self.compactions.push(record);
205 self.updated_at = now;
206 }
207
208 pub fn add_to_input_history(&mut self, input: String) {
210 if input.trim().is_empty() {
212 return;
213 }
214
215 if let Some(last) = self.input_history.back()
217 && last == &input
218 {
219 return;
220 }
221
222 if self.input_history.len() >= 100 {
224 self.input_history.pop_front(); }
226
227 self.input_history.push_back(input);
228 }
229
230 fn update_title(&mut self) {
233 if !self.title.starts_with("Session ") {
235 return;
236 }
237 if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
238 let preview = if first_user_msg.content.len() > 60 {
239 let end = first_user_msg.content.floor_char_boundary(60);
240 format!("{}...", &first_user_msg.content[..end])
241 } else {
242 first_user_msg.content.clone()
243 };
244 self.title = preview;
245 }
246 }
247
248 pub fn summary(&self) -> String {
250 let message_count = self.messages.len();
251 let duration = self.updated_at.signed_duration_since(self.created_at);
252 let hours = duration.num_hours();
253 let minutes = duration.num_minutes() % 60;
254
255 format!(
256 "{} | {} messages | {}h {}m | {}",
257 self.updated_at.format("%Y-%m-%d %H:%M"),
258 message_count,
259 hours,
260 minutes,
261 self.title
262 )
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272struct FileStamp {
273 mtime: SystemTime,
274 len: u64,
275}
276
277fn file_stamp(path: &Path) -> Option<FileStamp> {
279 let meta = fs::metadata(path).ok()?;
280 let mtime = meta.modified().ok()?;
281 Some(FileStamp {
282 mtime,
283 len: meta.len(),
284 })
285}
286
287static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
290
291#[derive(Clone)]
293pub struct ConversationManager {
294 conversations_dir: PathBuf,
295 compactions_dir: PathBuf,
296 seen: Arc<Mutex<HashMap<String, FileStamp>>>,
303}
304
305impl ConversationManager {
306 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
308 let mermaid_dir = project_dir.as_ref().join(".mermaid");
309 let conversations_dir = mermaid_dir.join("conversations");
310 let compactions_dir = mermaid_dir.join("compactions");
311
312 fs::create_dir_all(&conversations_dir)?;
314 fs::create_dir_all(&compactions_dir)?;
315
316 Ok(Self {
317 conversations_dir,
318 compactions_dir,
319 seen: Arc::new(Mutex::new(HashMap::new())),
320 })
321 }
322
323 fn record_stamp(&self, id: &str, path: &Path) {
328 if let Some(stamp) = file_stamp(path) {
329 self.seen
330 .lock()
331 .unwrap_or_else(|e| e.into_inner())
332 .insert(id.to_string(), stamp);
333 }
334 }
335
336 fn conflict_sibling_path(&self, id: &str) -> PathBuf {
341 let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
342 self.conversations_dir
343 .join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
344 }
345
346 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
348 validate_conversation_id(&conversation.id)?;
352
353 if conversation.messages.is_empty() {
358 return Ok(());
359 }
360
361 let filename = format!("{}.json", conversation.id);
362 let path = self.conversations_dir.join(filename);
363
364 let json = match strip_persisted_screenshots(&conversation.messages) {
367 Some(sanitized) => {
368 let mut redacted = conversation.clone();
369 redacted.messages = sanitized;
370 serde_json::to_string_pretty(&redacted)?
371 },
372 None => serde_json::to_string_pretty(conversation)?,
373 };
374
375 let baseline = self
383 .seen
384 .lock()
385 .unwrap_or_else(|e| e.into_inner())
386 .get(&conversation.id)
387 .copied();
388 if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
389 && current != base
390 {
391 let sibling = self.conflict_sibling_path(&conversation.id);
392 crate::runtime::write_atomic(&sibling, json.as_bytes())?;
394 tracing::warn!(
395 id = %conversation.id,
396 main = %path.display(),
397 conflict = %sibling.display(),
398 "conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
399 );
400 return Ok(());
401 }
402
403 crate::runtime::write_atomic(&path, json.as_bytes())?;
406 self.record_stamp(&conversation.id, &path);
409
410 Ok(())
411 }
412
413 pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
417 validate_conversation_id(&archive.conversation_id)?;
420 anyhow::ensure!(
421 !archive.id.is_empty()
422 && !archive.id.contains(['/', '\\'])
423 && !archive.id.contains(".."),
424 "invalid compaction archive id: {:?}",
425 archive.id
426 );
427 let dir = self.compactions_dir.join(&archive.conversation_id);
428 fs::create_dir_all(&dir)?;
429 let path = dir.join(format!("{}.json", archive.id));
430 let json = match strip_persisted_screenshots(&archive.messages) {
434 Some(sanitized) => {
435 let mut redacted = archive.clone();
436 redacted.messages = sanitized;
437 serde_json::to_string_pretty(&redacted)?
438 },
439 None => serde_json::to_string_pretty(archive)?,
440 };
441 crate::runtime::write_atomic(&path, json.as_bytes())?;
444 Ok(path)
445 }
446
447 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
449 validate_conversation_id(id)?;
450 let filename = format!("{}.json", id);
451 let path = self.conversations_dir.join(filename);
452
453 let json = read_conversation_capped(&path)?;
454 let conversation: ConversationHistory = serde_json::from_str(&json)?;
455 validate_conversation_id(&conversation.id)?;
458
459 self.record_stamp(&conversation.id, &path);
462
463 Ok(conversation)
464 }
465
466 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
474 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
475 return Ok(None);
476 };
477
478 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
479 .flatten()
480 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
481 .filter_map(|e| {
482 let mtime = e.metadata().ok()?.modified().ok()?;
483 Some((mtime, e.path()))
484 })
485 .collect();
486 candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
487
488 for (_, path) in candidates {
489 let Ok(json) = read_conversation_capped(&path) else {
490 tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
491 continue;
492 };
493 let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
494 tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
495 continue;
496 };
497 if validate_conversation_id(&conv.id).is_err() {
500 tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
501 continue;
502 }
503 if conv.messages.is_empty() {
506 continue;
507 }
508 self.record_stamp(&conv.id, &path);
511 return Ok(Some(conv));
512 }
513 Ok(None)
514 }
515
516 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
518 let mut conversations = Vec::new();
519
520 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
522 for entry in entries.flatten() {
523 if let Some(ext) = entry.path().extension()
524 && ext == "json"
525 && let Ok(json) = read_conversation_capped(&entry.path())
526 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
527 && !conv.messages.is_empty()
530 {
531 conversations.push(conv);
532 }
533 }
534 }
535
536 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
538
539 Ok(conversations)
540 }
541
542 pub fn delete_conversation(&self, id: &str) -> Result<()> {
544 validate_conversation_id(id)?;
545 let filename = format!("{}.json", id);
546 let path = self.conversations_dir.join(filename);
547
548 if path.exists() {
549 fs::remove_file(path)?;
550 }
551
552 Ok(())
553 }
554
555 pub fn conversations_dir(&self) -> &Path {
557 &self.conversations_dir
558 }
559
560 pub fn compactions_dir(&self) -> &Path {
561 &self.compactions_dir
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 fn touched(project: &str) -> ConversationHistory {
572 let mut c = ConversationHistory::new(project.into(), "m".into(), Local::now());
573 c.add_messages(&[ChatMessage::user("hi")], Local::now());
574 c
575 }
576
577 #[test]
578 fn legacy_conversation_json_without_git_branch_deserializes() {
579 let json = r#"{
583 "id": "20260101_120000_001",
584 "title": "Legacy session",
585 "messages": [],
586 "model_name": "ollama/test",
587 "project_path": "/tmp/proj",
588 "created_at": "2026-01-01T12:00:00-05:00",
589 "updated_at": "2026-01-01T12:00:00-05:00",
590 "total_tokens": null
591 }"#;
592 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
593 assert!(conv.git_branch.is_none());
594 assert_eq!(conv.title, "Legacy session");
595 let mut fresh =
597 ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
598 fresh.git_branch = Some("feature/x".to_string());
599 let round: ConversationHistory =
600 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
601 assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
602 }
603
604 #[test]
605 fn legacy_json_defaults_session_state_fields() {
606 let json = r#"{
610 "id": "20260101_120000_002",
611 "title": "Old",
612 "messages": [],
613 "model_name": "m",
614 "project_path": "/tmp/proj",
615 "created_at": "2026-01-01T12:00:00-05:00",
616 "updated_at": "2026-01-01T12:00:00-05:00",
617 "total_tokens": null
618 }"#;
619 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
620 assert_eq!(conv.safety_mode, None);
621 assert_eq!(conv.cumulative_tokens, 0);
622 assert_eq!(
623 conv.cumulative_token_usage,
624 crate::domain::TokenUsageTotals::default()
625 );
626 assert!(conv.last_token_usage.is_none());
627 assert!(conv.context_usage.is_none());
628 }
629
630 #[test]
631 fn session_state_round_trips_through_json() {
632 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
633 conv.safety_mode = Some(crate::runtime::SafetyMode::FullAccess);
634 conv.cumulative_tokens = 777;
635 conv.cumulative_token_usage = crate::domain::TokenUsageTotals {
636 total_tokens: 777,
637 ..Default::default()
638 };
639 let round: ConversationHistory =
640 serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
641 assert_eq!(
642 round.safety_mode,
643 Some(crate::runtime::SafetyMode::FullAccess)
644 );
645 assert_eq!(round.cumulative_tokens, 777);
646 assert_eq!(round.cumulative_token_usage.total_tokens, 777);
647 }
648
649 #[test]
650 fn validate_conversation_id_rejects_traversal() {
651 assert!(validate_conversation_id("20260101_120000_001").is_ok());
652 assert!(validate_conversation_id("../secret").is_err());
653 assert!(validate_conversation_id("..\\secret").is_err());
654 assert!(validate_conversation_id("/etc/passwd").is_err());
655 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
658
659 #[test]
660 fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
661 let messages = vec![
662 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
663 ChatMessage::assistant("here is the screen")
664 .with_images(vec!["SCREENSHOT_B64".to_string()]),
665 ChatMessage::assistant("no image here"),
666 ];
667 let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
668 assert_eq!(
670 sanitized[0].images.as_deref(),
671 Some(["USER_PASTED_B64".to_string()].as_slice())
672 );
673 assert!(sanitized[1].images.is_none());
675 assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
676 assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
678 }
679
680 #[test]
681 fn strip_persisted_screenshots_is_none_without_assistant_images() {
682 let messages = vec![
683 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
684 ChatMessage::assistant("no images"),
685 ];
686 assert!(strip_persisted_screenshots(&messages).is_none());
687 }
688
689 #[test]
690 fn saved_conversation_json_has_no_screenshot_bytes() {
691 let dir = std::env::temp_dir().join("mermaid_strip_test");
692 let _ = fs::create_dir_all(&dir);
693 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
694 conv.messages = vec![
695 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
696 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
697 ];
698 let store = ConversationManager {
699 conversations_dir: dir.clone(),
700 compactions_dir: dir.clone(),
701 seen: Arc::new(Mutex::new(HashMap::new())),
702 };
703 store.save_conversation(&conv).expect("save");
704 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
705 assert!(
706 !raw.contains("SHOTBYTES"),
707 "screenshot leaked to disk: {raw}"
708 );
709 assert!(raw.contains("USERIMG"), "user image should persist");
710 assert_eq!(
712 conv.messages[1].images.as_deref(),
713 Some(["SHOTBYTES".to_string()].as_slice())
714 );
715 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
716 }
717
718 #[test]
719 fn test_new_conversation_has_session_title() {
720 let conv =
721 ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
722 assert!(conv.title.starts_with("Session "));
723 assert_eq!(conv.model_name, "test-model");
724 assert_eq!(conv.project_path, "/tmp/project");
725 assert!(conv.messages.is_empty());
726 }
727
728 #[test]
729 fn test_title_updates_from_first_user_message() {
730 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
731 conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
732 assert_eq!(conv.title, "Fix the login bug");
733 }
734
735 #[test]
736 fn test_title_truncated_at_60_chars() {
737 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
738 let long_msg = "a".repeat(100);
739 conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
740 assert!(conv.title.ends_with("..."));
741 assert!(conv.title.len() <= 64); }
743
744 #[test]
745 fn test_title_set_only_once() {
746 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
747 conv.add_messages(&[ChatMessage::user("First message")], Local::now());
748 conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
749 assert_eq!(conv.title, "First message");
750 }
751
752 #[test]
753 fn test_input_history_deduplication() {
754 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
755 conv.add_to_input_history("hello".into());
756 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
758 assert_eq!(conv.input_history.len(), 2);
759 }
760
761 #[test]
762 fn test_input_history_skips_empty() {
763 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
764 conv.add_to_input_history("".into());
765 conv.add_to_input_history(" ".into());
766 assert_eq!(conv.input_history.len(), 0);
767 }
768
769 #[test]
770 fn test_input_history_capped_at_100() {
771 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
772 for i in 0..110 {
773 conv.add_to_input_history(format!("msg{}", i));
774 }
775 assert_eq!(conv.input_history.len(), 100);
776 assert_eq!(conv.input_history.front().unwrap(), "msg10");
777 }
778
779 #[test]
780 fn test_save_load_roundtrip() {
781 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
782 let _ = fs::remove_dir_all(&dir);
783 let manager = ConversationManager::new(&dir).unwrap();
784
785 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
786 conv.add_messages(&[ChatMessage::user("test message")], Local::now());
787 conv.add_to_input_history("test message".into());
788
789 manager.save_conversation(&conv).unwrap();
790 let loaded = manager.load_conversation(&conv.id).unwrap();
791
792 assert_eq!(loaded.id, conv.id);
793 assert_eq!(loaded.title, conv.title);
794 assert_eq!(loaded.messages.len(), 1);
795 assert_eq!(loaded.input_history.len(), 1);
796
797 let _ = fs::remove_dir_all(&dir);
798 }
799
800 #[test]
801 fn test_list_conversations_ordered_by_updated_at() {
802 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
803 let _ = fs::remove_dir_all(&dir);
804 let manager = ConversationManager::new(&dir).unwrap();
805
806 let conv1 = touched("/tmp");
807 std::thread::sleep(std::time::Duration::from_millis(10));
808 let conv2 = touched("/tmp");
809
810 manager.save_conversation(&conv1).unwrap();
811 manager.save_conversation(&conv2).unwrap();
812
813 let list = manager.list_conversations().unwrap();
814 assert_eq!(list.len(), 2);
815 assert_eq!(list[0].id, conv2.id);
817 assert_eq!(list[1].id, conv1.id);
818
819 let _ = fs::remove_dir_all(&dir);
820 }
821
822 #[test]
823 fn test_load_last_conversation() {
824 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
825 let _ = fs::remove_dir_all(&dir);
826 let manager = ConversationManager::new(&dir).unwrap();
827
828 assert!(manager.load_last_conversation().unwrap().is_none());
829
830 let conv = touched("/tmp");
831 manager.save_conversation(&conv).unwrap();
832
833 let last = manager.load_last_conversation().unwrap().unwrap();
834 assert_eq!(last.id, conv.id);
835
836 let _ = fs::remove_dir_all(&dir);
837 }
838
839 #[test]
840 fn test_load_last_conversation_picks_newest_by_mtime() {
841 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
846 let _ = fs::remove_dir_all(&dir);
847 let manager = ConversationManager::new(&dir).unwrap();
848
849 let conv1 = touched("/tmp");
850 manager.save_conversation(&conv1).unwrap();
851 std::thread::sleep(std::time::Duration::from_millis(10));
852
853 let conv2 = touched("/tmp");
854 manager.save_conversation(&conv2).unwrap();
855 std::thread::sleep(std::time::Duration::from_millis(10));
856
857 let conv3 = touched("/tmp");
858 manager.save_conversation(&conv3).unwrap();
859
860 let last = manager.load_last_conversation().unwrap().unwrap();
861 assert_eq!(
862 last.id, conv3.id,
863 "should return the most-recently-written file"
864 );
865
866 let _ = fs::remove_dir_all(&dir);
867 }
868
869 #[test]
870 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
871 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
872 let _ = fs::remove_dir_all(&dir);
873 let manager = ConversationManager::new(&dir).unwrap();
874
875 let good = touched("/tmp");
876 manager.save_conversation(&good).unwrap();
877 std::thread::sleep(std::time::Duration::from_millis(10));
878
879 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
882 fs::write(&corrupt, b"{ not valid json").unwrap();
883
884 let last = manager.load_last_conversation().unwrap().unwrap();
885 assert_eq!(
886 last.id, good.id,
887 "must fall back to the newest VALID conversation"
888 );
889 let _ = fs::remove_dir_all(&dir);
890 }
891
892 #[test]
893 fn load_last_conversation_none_when_only_corrupt() {
894 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
895 let _ = fs::remove_dir_all(&dir);
896 let manager = ConversationManager::new(&dir).unwrap();
897 fs::write(
898 manager.conversations_dir().join("20991231_235959_998.json"),
899 b"nope",
900 )
901 .unwrap();
902 assert!(manager.load_last_conversation().unwrap().is_none());
903 let _ = fs::remove_dir_all(&dir);
904 }
905
906 #[test]
907 fn load_conversation_tolerates_unknown_message_role() {
908 let dir =
913 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
914 let _ = fs::remove_dir_all(&dir);
915 let manager = ConversationManager::new(&dir).unwrap();
916
917 let id = "20260101_120000_001";
918 let json = format!(
919 r#"{{
920 "id": "{id}",
921 "title": "skew",
922 "messages": [
923 {{
924 "role": "Developer",
925 "content": "from a newer build",
926 "timestamp": "2026-01-01T12:00:00-04:00"
927 }}
928 ],
929 "model_name": "m",
930 "project_path": "/tmp",
931 "created_at": "2026-01-01T12:00:00-04:00",
932 "updated_at": "2026-01-01T12:00:00-04:00",
933 "total_tokens": null
934 }}"#
935 );
936 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
937
938 let loaded = manager
939 .load_conversation(id)
940 .expect("must load despite an unknown role");
941 assert_eq!(loaded.messages.len(), 1);
942 assert_eq!(
943 loaded.messages[0].role,
944 MessageRole::System,
945 "an unknown role becomes a neutral System message"
946 );
947
948 let last = manager
950 .load_last_conversation()
951 .unwrap()
952 .expect("the newest session must load");
953 assert_eq!(last.id, id);
954
955 let _ = fs::remove_dir_all(&dir);
956 }
957
958 #[test]
959 fn test_delete_conversation() {
960 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
961 let _ = fs::remove_dir_all(&dir);
962 let manager = ConversationManager::new(&dir).unwrap();
963
964 let conv = touched("/tmp");
965 manager.save_conversation(&conv).unwrap();
966 assert_eq!(manager.list_conversations().unwrap().len(), 1);
967
968 manager.delete_conversation(&conv.id).unwrap();
969 assert_eq!(manager.list_conversations().unwrap().len(), 0);
970
971 let _ = fs::remove_dir_all(&dir);
972 }
973
974 #[test]
975 fn empty_session_is_not_saved() {
976 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
977 let _ = fs::remove_dir_all(&dir);
978 let manager = ConversationManager::new(&dir).unwrap();
979
980 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
982 manager.save_conversation(&conv).unwrap();
983 assert!(
984 manager.list_conversations().unwrap().is_empty(),
985 "empty session must not be listed"
986 );
987 assert!(
988 manager.load_last_conversation().unwrap().is_none(),
989 "empty session must not be --continue-able"
990 );
991
992 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
994 manager.save_conversation(&conv).unwrap();
995 assert_eq!(manager.list_conversations().unwrap().len(), 1);
996
997 let _ = fs::remove_dir_all(&dir);
998 }
999
1000 #[test]
1001 fn resume_paths_skip_pre_existing_empty_files() {
1002 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
1005 let _ = fs::remove_dir_all(&dir);
1006 let manager = ConversationManager::new(&dir).unwrap();
1007
1008 let real = touched("/tmp");
1009 manager.save_conversation(&real).unwrap();
1010 std::thread::sleep(std::time::Duration::from_millis(10));
1012 let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1013 let path = manager
1014 .conversations_dir()
1015 .join(format!("{}.json", empty.id));
1016 fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
1017
1018 let list = manager.list_conversations().unwrap();
1019 assert_eq!(list.len(), 1, "the empty file must not be listed");
1020 assert_eq!(list[0].id, real.id);
1021 assert_eq!(
1022 manager.load_last_conversation().unwrap().unwrap().id,
1023 real.id,
1024 "--continue must skip the newer empty file"
1025 );
1026
1027 let _ = fs::remove_dir_all(&dir);
1028 }
1029
1030 #[test]
1031 fn read_conversation_capped_refuses_oversized_file() {
1032 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
1035 let _ = fs::remove_dir_all(&dir);
1036 fs::create_dir_all(&dir).unwrap();
1037
1038 let small = dir.join("small.json");
1039 fs::write(&small, b"{}").unwrap();
1040 assert!(read_conversation_capped(&small).is_ok());
1041
1042 let big = dir.join("big.json");
1043 let f = fs::File::create(&big).unwrap();
1044 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
1045 assert!(
1046 read_conversation_capped(&big).is_err(),
1047 "a file over the cap must be refused, not slurped into memory"
1048 );
1049
1050 let _ = fs::remove_dir_all(&dir);
1051 }
1052
1053 #[test]
1054 fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
1055 let dir =
1061 std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
1062 let _ = fs::remove_dir_all(&dir);
1063 let manager = ConversationManager::new(&dir).unwrap();
1064
1065 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1066 conv.add_messages(&[ChatMessage::user("ours")], Local::now());
1067 manager.save_conversation(&conv).unwrap();
1068 let main = manager
1069 .conversations_dir()
1070 .join(format!("{}.json", conv.id));
1071
1072 let other = ConversationManager::new(&dir).unwrap();
1075 let mut their_conv = other.load_conversation(&conv.id).unwrap();
1076 their_conv.add_messages(
1077 &[ChatMessage::user("theirs - extra content here")],
1078 Local::now(),
1079 );
1080 other.save_conversation(&their_conv).unwrap();
1081
1082 manager.save_conversation(&conv).unwrap();
1085 let on_disk: ConversationHistory =
1086 serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
1087 assert_eq!(
1088 on_disk.messages.len(),
1089 2,
1090 "the concurrent writer's file must be left intact"
1091 );
1092
1093 let mut conflicts = fs::read_dir(manager.conversations_dir())
1095 .unwrap()
1096 .flatten()
1097 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
1098 .map(|e| e.path())
1099 .collect::<Vec<_>>();
1100 assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
1101 let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
1102 assert!(
1103 sibling.contains("ours") && !sibling.contains("theirs"),
1104 "the .conflict sibling holds OUR copy, not the concurrent writer's"
1105 );
1106
1107 let listed = manager.list_conversations().unwrap();
1110 assert_eq!(
1111 listed.len(),
1112 1,
1113 ".conflict sibling must not appear as a conversation"
1114 );
1115 assert_eq!(listed[0].id, conv.id);
1116
1117 let _ = fs::remove_dir_all(&dir);
1118 }
1119
1120 #[test]
1121 fn save_conversation_repeated_self_saves_do_not_conflict() {
1122 let dir =
1125 std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
1126 let _ = fs::remove_dir_all(&dir);
1127 let manager = ConversationManager::new(&dir).unwrap();
1128
1129 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1130 conv.add_messages(&[ChatMessage::user("first")], Local::now());
1131 manager.save_conversation(&conv).unwrap();
1132 conv.add_messages(&[ChatMessage::user("second")], Local::now());
1133 manager.save_conversation(&conv).unwrap();
1134
1135 let conflicts = fs::read_dir(manager.conversations_dir())
1136 .unwrap()
1137 .flatten()
1138 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
1139 .count();
1140 assert_eq!(
1141 conflicts, 0,
1142 "our own repeated saves must not be flagged as conflicts"
1143 );
1144 let loaded = manager.load_conversation(&conv.id).unwrap();
1145 assert_eq!(loaded.messages.len(), 2, "latest save must win for us");
1146
1147 let _ = fs::remove_dir_all(&dir);
1148 }
1149}