1use anyhow::Result;
2use chrono::{DateTime, Local};
3use mermaid_domain::{CompactionArchive, ConversationHistory};
4use mermaid_model::models::{ChatMessage, MessageRole};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
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#[must_use]
90pub fn detect_git_branch(dir: &Path) -> Option<String> {
91 let output = std::process::Command::new("git")
92 .args(["rev-parse", "--abbrev-ref", "HEAD"])
93 .current_dir(dir)
94 .output()
95 .ok()?;
96 if !output.status.success() {
97 return None;
98 }
99 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
100 (!branch.is_empty() && branch != "HEAD").then_some(branch)
102}
103
104#[must_use]
108pub fn detect_git_sha(dir: &Path) -> Option<String> {
109 let output = std::process::Command::new("git")
110 .args(["rev-parse", "--short", "HEAD"])
111 .current_dir(dir)
112 .output()
113 .ok()?;
114 if !output.status.success() {
115 return None;
116 }
117 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
118 (!sha.is_empty()).then_some(sha)
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ConversationMeta {
127 pub id: String,
128 pub title: String,
129 pub updated_at: DateTime<Local>,
130 #[serde(default)]
131 pub git_branch: Option<String>,
132 #[serde(default)]
133 pub message_count: usize,
134 #[serde(default)]
135 pub forked_from: Option<String>,
136}
137
138impl ConversationMeta {
139 fn from_history(h: &ConversationHistory) -> Self {
140 Self {
141 id: h.id.clone(),
142 title: h.title.clone(),
143 updated_at: h.updated_at,
144 git_branch: h.git_branch.clone(),
145 message_count: h.messages().len(),
146 forked_from: h.forked_from.clone(),
147 }
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157struct FileStamp {
158 mtime: SystemTime,
159 len: u64,
160}
161
162fn file_stamp(path: &Path) -> Option<FileStamp> {
164 let meta = fs::metadata(path).ok()?;
165 let mtime = meta.modified().ok()?;
166 Some(FileStamp {
167 mtime,
168 len: meta.len(),
169 })
170}
171
172static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
175
176#[derive(Clone)]
178pub struct ConversationManager {
179 project_dir: PathBuf,
182 conversations_dir: PathBuf,
183 compactions_dir: PathBuf,
184 seen: Arc<Mutex<HashMap<String, FileStamp>>>,
191}
192
193impl ConversationManager {
194 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
203 let mermaid_dir = project_dir.as_ref().join(".mermaid");
204 let conversations_dir = mermaid_dir.join("conversations");
205 let compactions_dir = mermaid_dir.join("compactions");
206
207 fs::create_dir_all(&conversations_dir)?;
209 fs::create_dir_all(&compactions_dir)?;
210
211 Ok(Self {
212 project_dir: project_dir.as_ref().to_path_buf(),
213 conversations_dir,
214 compactions_dir,
215 seen: Arc::new(Mutex::new(HashMap::new())),
216 })
217 }
218
219 fn record_stamp(&self, id: &str, path: &Path) {
224 if let Some(stamp) = file_stamp(path) {
225 self.seen
226 .lock()
227 .unwrap_or_else(|e| e.into_inner())
228 .insert(id.to_string(), stamp);
229 }
230 }
231
232 fn conflict_sibling_path(&self, id: &str) -> PathBuf {
237 let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
238 self.conversations_dir
239 .join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
240 }
241
242 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
254 validate_conversation_id(&conversation.id)?;
258
259 if conversation.messages().is_empty() {
264 return Ok(());
265 }
266
267 let filename = format!("{}.json", conversation.id);
268 let path = self.conversations_dir.join(filename);
269
270 let mut value = match strip_persisted_screenshots(conversation.messages()) {
275 Some(sanitized) => {
276 let mut stripped = conversation.clone();
277 *stripped.messages_mut() = sanitized;
278 serde_json::to_value(&stripped)?
279 },
280 None => serde_json::to_value(conversation)?,
281 };
282 mermaid_model::utils::redact_json(&mut value);
283 let json = serde_json::to_string_pretty(&value)?;
284
285 let baseline = self
293 .seen
294 .lock()
295 .unwrap_or_else(|e| e.into_inner())
296 .get(&conversation.id)
297 .copied();
298 if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
299 && current != base
300 {
301 let sibling = self.conflict_sibling_path(&conversation.id);
302 mermaid_runtime::write_atomic_with_mode(&sibling, json.as_bytes(), 0o600)?;
304 tracing::warn!(
305 id = %conversation.id,
306 main = %path.display(),
307 conflict = %sibling.display(),
308 "conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
309 );
310 return Ok(());
311 }
312
313 mermaid_runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
317 self.record_stamp(&conversation.id, &path);
320
321 let meta = ConversationMeta::from_history(conversation);
325 if let Ok(meta_json) = serde_json::to_string(&meta) {
326 let meta_path = self
327 .conversations_dir
328 .join(format!("{}.meta", conversation.id));
329 let _ =
330 mermaid_runtime::write_atomic_with_mode(&meta_path, meta_json.as_bytes(), 0o600);
331 }
332
333 Ok(())
334 }
335
336 pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
348 validate_conversation_id(&archive.conversation_id)?;
351 anyhow::ensure!(
352 !archive.id.is_empty()
353 && !archive.id.contains(['/', '\\'])
354 && !archive.id.contains(".."),
355 "invalid compaction archive id: {:?}",
356 archive.id
357 );
358 let dir = self.compactions_dir.join(&archive.conversation_id);
359 fs::create_dir_all(&dir)?;
360 let path = dir.join(format!("{}.json", archive.id));
361 let mut value = match strip_persisted_screenshots(&archive.messages) {
365 Some(sanitized) => {
366 let mut stripped = archive.clone();
367 stripped.messages = sanitized;
368 serde_json::to_value(&stripped)?
369 },
370 None => serde_json::to_value(archive)?,
371 };
372 mermaid_model::utils::redact_json(&mut value);
373 let json = serde_json::to_string_pretty(&value)?;
374 mermaid_runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
377 Ok(path)
378 }
379
380 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
389 validate_conversation_id(id)?;
390 let filename = format!("{id}.json");
391 let path = self.conversations_dir.join(filename);
392
393 let json = read_conversation_capped(&path)?;
394 let conversation: ConversationHistory = serde_json::from_str(&json)?;
395 validate_conversation_id(&conversation.id)?;
398
399 self.record_stamp(&conversation.id, &path);
402
403 Ok(conversation)
404 }
405
406 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
422 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
423 return Ok(None);
424 };
425
426 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
427 .flatten()
428 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
429 .filter_map(|e| {
430 let mtime = e.metadata().ok()?.modified().ok()?;
431 Some((mtime, e.path()))
432 })
433 .collect();
434 candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
435
436 for (_, path) in candidates {
437 let Ok(json) = read_conversation_capped(&path) else {
438 tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
439 continue;
440 };
441 let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
442 tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
443 continue;
444 };
445 if validate_conversation_id(&conv.id).is_err() {
448 tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
449 continue;
450 }
451 if conv.messages().is_empty() {
454 continue;
455 }
456 self.record_stamp(&conv.id, &path);
459 return Ok(Some(conv));
460 }
461 Ok(None)
462 }
463
464 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
472 let mut conversations = Vec::new();
473
474 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
476 for entry in entries.flatten() {
477 if let Some(ext) = entry.path().extension()
478 && ext == "json"
479 && let Ok(json) = read_conversation_capped(&entry.path())
480 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
481 && !conv.messages().is_empty()
484 {
485 conversations.push(conv);
486 }
487 }
488 }
489
490 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
492
493 Ok(conversations)
494 }
495
496 pub fn list_conversation_metas(&self) -> Result<Vec<ConversationMeta>> {
508 let mut metas = Vec::new();
509 let mut seen = std::collections::HashSet::new();
510 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
511 return Ok(metas);
512 };
513 let paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
514 for path in &paths {
516 if path.extension().is_some_and(|e| e == "meta")
517 && let Ok(raw) = fs::read_to_string(path)
518 && let Ok(meta) = serde_json::from_str::<ConversationMeta>(&raw)
519 && meta.message_count > 0
520 {
521 seen.insert(meta.id.clone());
522 metas.push(meta);
523 }
524 }
525 for path in &paths {
527 if path.extension().is_some_and(|e| e == "json")
528 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
529 && !seen.contains(stem)
530 && let Ok(json) = read_conversation_capped(path)
531 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
532 && !conv.messages().is_empty()
533 {
534 metas.push(ConversationMeta::from_history(&conv));
535 }
536 }
537 metas.sort_by_key(|m| std::cmp::Reverse(m.updated_at));
538 Ok(metas)
539 }
540
541 pub fn delete_conversation(&self, id: &str) -> Result<()> {
550 validate_conversation_id(id)?;
551 let path = self.conversations_dir.join(format!("{id}.json"));
552 if path.exists() {
553 fs::remove_file(path)?;
554 }
555 let _ = fs::remove_file(self.conversations_dir.join(format!("{id}.meta")));
557 let _ = crate::session::scratchpad::remove(&self.project_dir, id);
561
562 Ok(())
563 }
564
565 #[must_use]
567 pub fn conversations_dir(&self) -> &Path {
568 &self.conversations_dir
569 }
570
571 #[must_use]
572 pub fn compactions_dir(&self) -> &Path {
573 &self.compactions_dir
574 }
575}
576
577#[must_use]
581pub fn probe_session_provenance(cwd: &Path) -> mermaid_domain::SessionProvenance {
582 mermaid_domain::SessionProvenance {
583 git_branch: detect_git_branch(cwd),
584 git_sha: detect_git_sha(cwd),
585 cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 fn touched(project: &str) -> ConversationHistory {
596 let mut c = ConversationHistory::new(project.into(), "m".into(), Local::now());
597 c.add_messages(&[ChatMessage::user("hi")], Local::now());
598 c
599 }
600
601 #[test]
602 fn legacy_conversation_json_without_git_branch_deserializes() {
603 let json = r#"{
607 "id": "20260101_120000_001",
608 "title": "Legacy session",
609 "messages": [],
610 "model_name": "ollama/test",
611 "project_path": "/tmp/proj",
612 "created_at": "2026-01-01T12:00:00-05:00",
613 "updated_at": "2026-01-01T12:00:00-05:00",
614 "total_tokens": null
615 }"#;
616 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
617 assert!(conv.git_branch.is_none());
618 assert_eq!(conv.title, "Legacy session");
619 let mut fresh =
621 ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
622 fresh.git_branch = Some("feature/x".to_string());
623 let round: ConversationHistory =
624 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
625 assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
626 }
627
628 #[test]
629 fn legacy_json_defaults_session_state_fields() {
630 let json = r#"{
634 "id": "20260101_120000_002",
635 "title": "Old",
636 "messages": [],
637 "model_name": "m",
638 "project_path": "/tmp/proj",
639 "created_at": "2026-01-01T12:00:00-05:00",
640 "updated_at": "2026-01-01T12:00:00-05:00",
641 "total_tokens": null
642 }"#;
643 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
644 assert_eq!(conv.safety_mode, None);
645 assert_eq!(
646 conv.cumulative_token_usage,
647 mermaid_domain::TokenUsageTotals::default()
648 );
649 assert!(conv.last_token_usage.is_none());
650 assert!(conv.context_usage.is_none());
651 assert!(conv.tasks.tasks.is_empty());
652 assert_eq!(conv.tasks.next_id, 0);
653 assert!(
654 conv.advertised_context.is_none(),
655 "pre-field saves load a None baseline (silent seed)"
656 );
657 }
658
659 #[test]
660 fn advertised_context_round_trips_through_conversation_json() {
661 let mut fresh = touched("/tmp/proj");
662 fresh.advertised_context = Some(mermaid_domain::AdvertisedContext {
663 plan_path: Some(std::path::PathBuf::from("/tmp/proj/.mermaid/plans/x.md")),
664 safety_mode: mermaid_runtime::SafetyMode::Ask,
665 model_id: "ollama/test".to_string(),
666 });
667 let round: ConversationHistory =
668 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
669 let ctx = round.advertised_context.expect("field survives");
670 assert_eq!(
671 ctx.plan_path.as_deref(),
672 Some(std::path::Path::new("/tmp/proj/.mermaid/plans/x.md"))
673 );
674 assert_eq!(ctx.model_id, "ollama/test");
675 }
676
677 #[test]
678 fn tasks_round_trip_through_conversation_json() {
679 let mut fresh = touched("/tmp/proj");
680 fresh.tasks.create(
681 vec![mermaid_domain::ChecklistSpec {
682 subject: "wire broker".into(),
683 active_form: "wiring broker".into(),
684 description: Some("through ExecContext".into()),
685 in_progress: true,
686 }],
687 mermaid_domain::ChecklistOrigin::Model,
688 mermaid_domain::Stamp {
689 now_epoch: 42,
690 run_tokens: 7,
691 },
692 );
693 let round: ConversationHistory =
694 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
695 assert_eq!(round.tasks, fresh.tasks);
696 assert_eq!(round.tasks.tasks[0].started_at, Some(42));
697 }
698
699 #[test]
700 fn session_state_round_trips_through_json() {
701 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
702 conv.safety_mode = Some(mermaid_runtime::SafetyMode::FullAccess);
703 conv.cumulative_token_usage = mermaid_domain::TokenUsageTotals {
704 prompt_tokens: 777,
705 ..Default::default()
706 };
707 let round: ConversationHistory =
708 serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
709 assert_eq!(
710 round.safety_mode,
711 Some(mermaid_runtime::SafetyMode::FullAccess)
712 );
713 assert_eq!(round.cumulative_token_usage.total_tokens(), 777);
714 }
715
716 #[test]
717 fn validate_conversation_id_rejects_traversal() {
718 assert!(validate_conversation_id("20260101_120000_001").is_ok());
719 assert!(validate_conversation_id("../secret").is_err());
720 assert!(validate_conversation_id("..\\secret").is_err());
721 assert!(validate_conversation_id("/etc/passwd").is_err());
722 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
725
726 #[test]
727 fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
728 let messages = vec![
729 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
730 ChatMessage::assistant("here is the screen")
731 .with_images(vec!["SCREENSHOT_B64".to_string()]),
732 ChatMessage::assistant("no image here"),
733 ];
734 let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
735 assert_eq!(
737 sanitized[0].images.as_deref(),
738 Some(["USER_PASTED_B64".to_string()].as_slice())
739 );
740 assert!(sanitized[1].images.is_none());
742 assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
743 assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
745 }
746
747 #[test]
748 fn strip_persisted_screenshots_is_none_without_assistant_images() {
749 let messages = vec![
750 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
751 ChatMessage::assistant("no images"),
752 ];
753 assert!(strip_persisted_screenshots(&messages).is_none());
754 }
755
756 #[test]
757 fn saved_conversation_json_has_no_screenshot_bytes() {
758 let dir = std::env::temp_dir().join("mermaid_strip_test");
759 let _ = fs::create_dir_all(&dir);
760 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
761 *conv.messages_mut() = vec![
762 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
763 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
764 ];
765 let store = ConversationManager {
766 project_dir: dir.clone(),
767 conversations_dir: dir.clone(),
768 compactions_dir: dir.clone(),
769 seen: Arc::new(Mutex::new(HashMap::new())),
770 };
771 store.save_conversation(&conv).expect("save");
772 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
773 assert!(
774 !raw.contains("SHOTBYTES"),
775 "screenshot leaked to disk: {raw}"
776 );
777 assert!(raw.contains("USERIMG"), "user image should persist");
778 assert_eq!(
780 conv.messages()[1].images.as_deref(),
781 Some(["SHOTBYTES".to_string()].as_slice())
782 );
783 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
784 }
785
786 #[test]
787 fn saved_conversation_redacts_secrets_and_is_owner_only() {
788 let dir = std::env::temp_dir().join(format!("mermaid_conv_redact_{}", std::process::id()));
789 let _ = fs::remove_dir_all(&dir);
790 let _ = fs::create_dir_all(&dir);
791 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
792 *conv.messages_mut() = vec![
794 ChatMessage::user("read .env"),
795 ChatMessage::assistant("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
796 ];
797 let store = ConversationManager {
798 project_dir: dir.clone(),
799 conversations_dir: dir.clone(),
800 compactions_dir: dir.clone(),
801 seen: Arc::new(Mutex::new(HashMap::new())),
802 };
803 store.save_conversation(&conv).expect("save");
804 let path = dir.join(format!("{}.json", conv.id));
805 let raw = fs::read_to_string(&path).expect("read");
806 assert!(
807 !raw.contains("sk-abcdefghijklmnop1234"),
808 "secret leaked to the conversation store: {raw}"
809 );
810 assert!(
811 raw.contains("[REDACTED]"),
812 "expected redaction marker: {raw}"
813 );
814 #[cfg(unix)]
815 {
816 use std::os::unix::fs::PermissionsExt;
817 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
818 assert_eq!(
819 mode, 0o600,
820 "conversation file must be owner-only, got {mode:o}"
821 );
822 }
823 assert!(
825 conv.messages()[1]
826 .content
827 .contains("sk-abcdefghijklmnop1234")
828 );
829 let _ = fs::remove_dir_all(&dir);
830 }
831
832 #[test]
833 fn test_new_conversation_has_session_title() {
834 let conv =
835 ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
836 assert!(conv.title.starts_with("Session "));
837 assert_eq!(conv.model_name, "test-model");
838 assert_eq!(conv.project_path, "/tmp/project");
839 assert!(conv.messages().is_empty());
840 }
841
842 #[test]
843 fn test_title_updates_from_first_user_message() {
844 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
845 conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
846 assert_eq!(conv.title, "Fix the login bug");
847 }
848
849 #[test]
850 fn test_title_truncated_at_60_chars() {
851 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
852 let long_msg = "a".repeat(100);
853 conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
854 assert!(conv.title.ends_with("..."));
855 assert!(conv.title.len() <= 64); }
857
858 #[test]
859 fn test_title_set_only_once() {
860 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
861 conv.add_messages(&[ChatMessage::user("First message")], Local::now());
862 conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
863 assert_eq!(conv.title, "First message");
864 }
865
866 #[test]
867 fn test_input_history_deduplication() {
868 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
869 conv.add_to_input_history("hello".into());
870 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
872 assert_eq!(conv.input_history.len(), 2);
873 }
874
875 #[test]
876 fn test_input_history_skips_empty() {
877 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
878 conv.add_to_input_history("".into());
879 conv.add_to_input_history(" ".into());
880 assert_eq!(conv.input_history.len(), 0);
881 }
882
883 #[test]
884 fn test_input_history_capped_at_100() {
885 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
886 for i in 0..110 {
887 conv.add_to_input_history(format!("msg{i}"));
888 }
889 assert_eq!(conv.input_history.len(), 100);
890 assert_eq!(conv.input_history.front().unwrap(), "msg10");
891 }
892
893 #[test]
894 fn sidecar_powers_metadata_listing() {
895 let dir = std::env::temp_dir().join("mermaid_test_meta_sidecar");
896 let _ = fs::remove_dir_all(&dir);
897 let manager = ConversationManager::new(&dir).unwrap();
898 let mut conv = ConversationHistory::new("/tmp/proj".into(), "model".into(), Local::now());
899 conv.title = "My session".into();
900 conv.add_messages(
901 &[ChatMessage::user("hi"), ChatMessage::user("there")],
902 Local::now(),
903 );
904 manager.save_conversation(&conv).unwrap();
905
906 assert!(
907 manager
908 .conversations_dir()
909 .join(format!("{}.meta", conv.id))
910 .exists()
911 );
912 let metas = manager.list_conversation_metas().unwrap();
913 assert_eq!(metas.len(), 1);
914 assert_eq!(metas[0].id, conv.id);
915 assert_eq!(metas[0].title, "My session");
916 assert_eq!(metas[0].message_count, 2);
917
918 manager.delete_conversation(&conv.id).unwrap();
920 assert!(
921 !manager
922 .conversations_dir()
923 .join(format!("{}.meta", conv.id))
924 .exists()
925 );
926 assert!(manager.list_conversation_metas().unwrap().is_empty());
927 let _ = fs::remove_dir_all(&dir);
928 }
929
930 #[test]
931 fn metadata_listing_falls_back_to_full_parse_without_sidecar() {
932 let dir = std::env::temp_dir().join("mermaid_test_meta_fallback");
933 let _ = fs::remove_dir_all(&dir);
934 let manager = ConversationManager::new(&dir).unwrap();
935 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
936 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
937 manager.save_conversation(&conv).unwrap();
938 fs::remove_file(
940 manager
941 .conversations_dir()
942 .join(format!("{}.meta", conv.id)),
943 )
944 .unwrap();
945 let metas = manager.list_conversation_metas().unwrap();
946 assert_eq!(metas.len(), 1, "falls back to parsing the .json");
947 assert_eq!(metas[0].message_count, 1);
948 let _ = fs::remove_dir_all(&dir);
949 }
950
951 #[test]
952 fn lineage_fields_default_on_old_sessions() {
953 let json = r#"{"id":"x","title":"t","messages":[],"model_name":"m","project_path":"/p","created_at":"2026-01-01T00:00:00+00:00","updated_at":"2026-01-01T00:00:00+00:00","total_tokens":null}"#;
955 let conv: ConversationHistory = serde_json::from_str(json).unwrap();
956 assert!(conv.git_sha.is_none());
957 assert!(conv.cli_version.is_none());
958 assert!(conv.forked_from.is_none());
959 assert!(conv.parent_session.is_none());
960 }
961
962 #[test]
963 fn test_save_load_roundtrip() {
964 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
965 let _ = fs::remove_dir_all(&dir);
966 let manager = ConversationManager::new(&dir).unwrap();
967
968 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
969 conv.add_messages(&[ChatMessage::user("test message")], Local::now());
970 conv.add_to_input_history("test message".into());
971
972 manager.save_conversation(&conv).unwrap();
973 let loaded = manager.load_conversation(&conv.id).unwrap();
974
975 assert_eq!(loaded.id, conv.id);
976 assert_eq!(loaded.title, conv.title);
977 assert_eq!(loaded.messages().len(), 1);
978 assert_eq!(loaded.input_history.len(), 1);
979
980 let _ = fs::remove_dir_all(&dir);
981 }
982
983 #[test]
984 fn test_list_conversations_ordered_by_updated_at() {
985 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
986 let _ = fs::remove_dir_all(&dir);
987 let manager = ConversationManager::new(&dir).unwrap();
988
989 let conv1 = touched("/tmp");
990 std::thread::sleep(std::time::Duration::from_millis(10));
991 let conv2 = touched("/tmp");
992
993 manager.save_conversation(&conv1).unwrap();
994 manager.save_conversation(&conv2).unwrap();
995
996 let list = manager.list_conversations().unwrap();
997 assert_eq!(list.len(), 2);
998 assert_eq!(list[0].id, conv2.id);
1000 assert_eq!(list[1].id, conv1.id);
1001
1002 let _ = fs::remove_dir_all(&dir);
1003 }
1004
1005 #[test]
1006 fn test_load_last_conversation() {
1007 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
1008 let _ = fs::remove_dir_all(&dir);
1009 let manager = ConversationManager::new(&dir).unwrap();
1010
1011 assert!(manager.load_last_conversation().unwrap().is_none());
1012
1013 let conv = touched("/tmp");
1014 manager.save_conversation(&conv).unwrap();
1015
1016 let last = manager.load_last_conversation().unwrap().unwrap();
1017 assert_eq!(last.id, conv.id);
1018
1019 let _ = fs::remove_dir_all(&dir);
1020 }
1021
1022 #[test]
1023 fn test_load_last_conversation_picks_newest_by_mtime() {
1024 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
1029 let _ = fs::remove_dir_all(&dir);
1030 let manager = ConversationManager::new(&dir).unwrap();
1031
1032 let conv1 = touched("/tmp");
1033 manager.save_conversation(&conv1).unwrap();
1034 std::thread::sleep(std::time::Duration::from_millis(10));
1035
1036 let conv2 = touched("/tmp");
1037 manager.save_conversation(&conv2).unwrap();
1038 std::thread::sleep(std::time::Duration::from_millis(10));
1039
1040 let conv3 = touched("/tmp");
1041 manager.save_conversation(&conv3).unwrap();
1042
1043 let last = manager.load_last_conversation().unwrap().unwrap();
1044 assert_eq!(
1045 last.id, conv3.id,
1046 "should return the most-recently-written file"
1047 );
1048
1049 let _ = fs::remove_dir_all(&dir);
1050 }
1051
1052 #[test]
1053 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
1054 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
1055 let _ = fs::remove_dir_all(&dir);
1056 let manager = ConversationManager::new(&dir).unwrap();
1057
1058 let good = touched("/tmp");
1059 manager.save_conversation(&good).unwrap();
1060 std::thread::sleep(std::time::Duration::from_millis(10));
1061
1062 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
1065 fs::write(&corrupt, b"{ not valid json").unwrap();
1066
1067 let last = manager.load_last_conversation().unwrap().unwrap();
1068 assert_eq!(
1069 last.id, good.id,
1070 "must fall back to the newest VALID conversation"
1071 );
1072 let _ = fs::remove_dir_all(&dir);
1073 }
1074
1075 #[test]
1076 fn load_last_conversation_none_when_only_corrupt() {
1077 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
1078 let _ = fs::remove_dir_all(&dir);
1079 let manager = ConversationManager::new(&dir).unwrap();
1080 fs::write(
1081 manager.conversations_dir().join("20991231_235959_998.json"),
1082 b"nope",
1083 )
1084 .unwrap();
1085 assert!(manager.load_last_conversation().unwrap().is_none());
1086 let _ = fs::remove_dir_all(&dir);
1087 }
1088
1089 #[test]
1090 fn load_conversation_tolerates_unknown_message_role() {
1091 let dir =
1096 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
1097 let _ = fs::remove_dir_all(&dir);
1098 let manager = ConversationManager::new(&dir).unwrap();
1099
1100 let id = "20260101_120000_001";
1101 let json = format!(
1102 r#"{{
1103 "id": "{id}",
1104 "title": "skew",
1105 "messages": [
1106 {{
1107 "role": "Developer",
1108 "content": "from a newer build",
1109 "timestamp": "2026-01-01T12:00:00-04:00"
1110 }}
1111 ],
1112 "model_name": "m",
1113 "project_path": "/tmp",
1114 "created_at": "2026-01-01T12:00:00-04:00",
1115 "updated_at": "2026-01-01T12:00:00-04:00",
1116 "total_tokens": null
1117 }}"#
1118 );
1119 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
1120
1121 let loaded = manager
1122 .load_conversation(id)
1123 .expect("must load despite an unknown role");
1124 assert_eq!(loaded.messages().len(), 1);
1125 assert_eq!(
1126 loaded.messages()[0].role,
1127 MessageRole::System,
1128 "an unknown role becomes a neutral System message"
1129 );
1130
1131 let last = manager
1133 .load_last_conversation()
1134 .unwrap()
1135 .expect("the newest session must load");
1136 assert_eq!(last.id, id);
1137
1138 let _ = fs::remove_dir_all(&dir);
1139 }
1140
1141 #[test]
1142 fn test_delete_conversation() {
1143 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
1144 let _ = fs::remove_dir_all(&dir);
1145 let manager = ConversationManager::new(&dir).unwrap();
1146
1147 let conv = touched("/tmp");
1148 manager.save_conversation(&conv).unwrap();
1149 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1150
1151 manager.delete_conversation(&conv.id).unwrap();
1152 assert_eq!(manager.list_conversations().unwrap().len(), 0);
1153
1154 let _ = fs::remove_dir_all(&dir);
1155 }
1156
1157 #[test]
1158 fn empty_session_is_not_saved() {
1159 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
1160 let _ = fs::remove_dir_all(&dir);
1161 let manager = ConversationManager::new(&dir).unwrap();
1162
1163 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1165 manager.save_conversation(&conv).unwrap();
1166 assert!(
1167 manager.list_conversations().unwrap().is_empty(),
1168 "empty session must not be listed"
1169 );
1170 assert!(
1171 manager.load_last_conversation().unwrap().is_none(),
1172 "empty session must not be --continue-able"
1173 );
1174
1175 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1177 manager.save_conversation(&conv).unwrap();
1178 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1179
1180 let _ = fs::remove_dir_all(&dir);
1181 }
1182
1183 #[test]
1184 fn resume_paths_skip_pre_existing_empty_files() {
1185 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
1188 let _ = fs::remove_dir_all(&dir);
1189 let manager = ConversationManager::new(&dir).unwrap();
1190
1191 let real = touched("/tmp");
1192 manager.save_conversation(&real).unwrap();
1193 std::thread::sleep(std::time::Duration::from_millis(10));
1195 let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1196 let path = manager
1197 .conversations_dir()
1198 .join(format!("{}.json", empty.id));
1199 fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
1200
1201 let list = manager.list_conversations().unwrap();
1202 assert_eq!(list.len(), 1, "the empty file must not be listed");
1203 assert_eq!(list[0].id, real.id);
1204 assert_eq!(
1205 manager.load_last_conversation().unwrap().unwrap().id,
1206 real.id,
1207 "--continue must skip the newer empty file"
1208 );
1209
1210 let _ = fs::remove_dir_all(&dir);
1211 }
1212
1213 #[test]
1214 fn read_conversation_capped_refuses_oversized_file() {
1215 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
1218 let _ = fs::remove_dir_all(&dir);
1219 fs::create_dir_all(&dir).unwrap();
1220
1221 let small = dir.join("small.json");
1222 fs::write(&small, b"{}").unwrap();
1223 assert!(read_conversation_capped(&small).is_ok());
1224
1225 let big = dir.join("big.json");
1226 let f = fs::File::create(&big).unwrap();
1227 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
1228 assert!(
1229 read_conversation_capped(&big).is_err(),
1230 "a file over the cap must be refused, not slurped into memory"
1231 );
1232
1233 let _ = fs::remove_dir_all(&dir);
1234 }
1235
1236 #[test]
1237 fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
1238 let dir =
1244 std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
1245 let _ = fs::remove_dir_all(&dir);
1246 let manager = ConversationManager::new(&dir).unwrap();
1247
1248 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1249 conv.add_messages(&[ChatMessage::user("ours")], Local::now());
1250 manager.save_conversation(&conv).unwrap();
1251 let main = manager
1252 .conversations_dir()
1253 .join(format!("{}.json", conv.id));
1254
1255 let other = ConversationManager::new(&dir).unwrap();
1258 let mut their_conv = other.load_conversation(&conv.id).unwrap();
1259 their_conv.add_messages(
1260 &[ChatMessage::user("theirs - extra content here")],
1261 Local::now(),
1262 );
1263 other.save_conversation(&their_conv).unwrap();
1264
1265 manager.save_conversation(&conv).unwrap();
1268 let on_disk: ConversationHistory =
1269 serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
1270 assert_eq!(
1271 on_disk.messages().len(),
1272 2,
1273 "the concurrent writer's file must be left intact"
1274 );
1275
1276 let mut conflicts = fs::read_dir(manager.conversations_dir())
1278 .unwrap()
1279 .flatten()
1280 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
1281 .map(|e| e.path())
1282 .collect::<Vec<_>>();
1283 assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
1284 let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
1285 assert!(
1286 sibling.contains("ours") && !sibling.contains("theirs"),
1287 "the .conflict sibling holds OUR copy, not the concurrent writer's"
1288 );
1289
1290 let listed = manager.list_conversations().unwrap();
1293 assert_eq!(
1294 listed.len(),
1295 1,
1296 ".conflict sibling must not appear as a conversation"
1297 );
1298 assert_eq!(listed[0].id, conv.id);
1299
1300 let _ = fs::remove_dir_all(&dir);
1301 }
1302
1303 #[test]
1304 fn save_conversation_repeated_self_saves_do_not_conflict() {
1305 let dir =
1308 std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
1309 let _ = fs::remove_dir_all(&dir);
1310 let manager = ConversationManager::new(&dir).unwrap();
1311
1312 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1313 conv.add_messages(&[ChatMessage::user("first")], Local::now());
1314 manager.save_conversation(&conv).unwrap();
1315 conv.add_messages(&[ChatMessage::user("second")], Local::now());
1316 manager.save_conversation(&conv).unwrap();
1317
1318 let conflicts = fs::read_dir(manager.conversations_dir())
1319 .unwrap()
1320 .flatten()
1321 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
1322 .count();
1323 assert_eq!(
1324 conflicts, 0,
1325 "our own repeated saves must not be flagged as conflicts"
1326 );
1327 let loaded = manager.load_conversation(&conv.id).unwrap();
1328 assert_eq!(loaded.messages().len(), 2, "latest save must win for us");
1329
1330 let _ = fs::remove_dir_all(&dir);
1331 }
1332}