1use anyhow::{Context, Result};
2use chrono::{DateTime, Local};
3use mermaid_domain::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::Arc;
10use std::time::SystemTime;
11
12fn validate_conversation_id(id: &str) -> Result<()> {
19 let valid = id.len() == 19
20 && id.as_bytes().iter().enumerate().all(|(i, b)| match i {
21 8 | 15 => *b == b'_',
22 _ => b.is_ascii_digit(),
23 });
24 anyhow::ensure!(valid, "invalid conversation id: {id:?}");
25 Ok(())
26}
27
28const MAX_CONVERSATION_BYTES: u64 = 64 * 1024 * 1024;
33
34fn read_conversation_capped(path: &Path) -> std::io::Result<String> {
37 let len = fs::metadata(path)?.len();
38 if len > MAX_CONVERSATION_BYTES {
39 return Err(std::io::Error::new(
40 std::io::ErrorKind::InvalidData,
41 format!(
42 "conversation file {} is {len} bytes, over the {} MiB cap",
43 path.display(),
44 MAX_CONVERSATION_BYTES / (1024 * 1024)
45 ),
46 ));
47 }
48 fs::read_to_string(path)
49}
50
51const TOOL_IMAGE_ELIDED_MARKER: &str = "\n[tool image not persisted]";
53
54const CHECKPOINT_SEQ_KEY: &str = "checkpoint_seq";
59
60fn strip_persisted_tool_images(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
73 let needs = messages
74 .iter()
75 .any(|m| m.role != MessageRole::User && m.images.is_some());
76 if !needs {
77 return None;
78 }
79 let mut out = messages.to_vec();
80 for m in out.iter_mut() {
81 if m.role != MessageRole::User && m.images.is_some() {
82 m.images = None;
83 if !m.content.ends_with(TOOL_IMAGE_ELIDED_MARKER) {
84 m.content.push_str(TOOL_IMAGE_ELIDED_MARKER);
85 }
86 }
87 }
88 Some(out)
89}
90
91#[must_use]
96pub fn detect_git_branch(dir: &Path) -> Option<String> {
97 let output = std::process::Command::new("git")
98 .args(["rev-parse", "--abbrev-ref", "HEAD"])
99 .current_dir(dir)
100 .output()
101 .ok()?;
102 if !output.status.success() {
103 return None;
104 }
105 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
106 (!branch.is_empty() && branch != "HEAD").then_some(branch)
108}
109
110#[must_use]
114pub fn detect_git_sha(dir: &Path) -> Option<String> {
115 let output = std::process::Command::new("git")
116 .args(["rev-parse", "--short", "HEAD"])
117 .current_dir(dir)
118 .output()
119 .ok()?;
120 if !output.status.success() {
121 return None;
122 }
123 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
124 (!sha.is_empty()).then_some(sha)
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ConversationMeta {
137 pub id: String,
138 pub title: String,
139 pub updated_at: DateTime<Local>,
140 #[serde(default)]
141 pub git_branch: Option<String>,
142 #[serde(default)]
143 pub message_count: usize,
144 #[serde(default)]
145 pub forked_from: Option<String>,
146}
147
148impl ConversationMeta {
149 fn from_history(h: &ConversationHistory) -> Self {
150 Self {
151 id: h.id.clone(),
152 title: h.title.clone(),
153 updated_at: h.updated_at,
154 git_branch: h.git_branch.clone(),
155 message_count: h.messages().len(),
156 forked_from: h.forked_from.clone(),
157 }
158 }
159}
160
161fn read_checkpoint(path: &Path) -> Result<(ConversationHistory, Option<u64>)> {
170 let json = read_conversation_capped(path)?;
171 let value: serde_json::Value = serde_json::from_str(&json)?;
172 let seq = value
173 .get(CHECKPOINT_SEQ_KEY)
174 .and_then(serde_json::Value::as_u64);
175 let conversation: ConversationHistory = serde_json::from_value(value)?;
176 Ok((conversation, seq))
177}
178
179#[derive(Clone)]
181pub struct ConversationManager {
182 project_dir: PathBuf,
185 conversations_dir: PathBuf,
186 events: Arc<crate::session::event_log::EventLog>,
189}
190
191impl ConversationManager {
192 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
201 let conversations_dir = project_dir.as_ref().join(".mermaid").join("conversations");
202 fs::create_dir_all(&conversations_dir)?;
203
204 Ok(Self {
205 project_dir: project_dir.as_ref().to_path_buf(),
206 events: Arc::new(crate::session::event_log::EventLog::new(
207 conversations_dir.clone(),
208 )),
209 conversations_dir,
210 })
211 }
212
213 pub fn append_session_events(
224 &self,
225 snapshot: &ConversationHistory,
226 events: &[mermaid_domain::SessionEvent],
227 ) -> Result<()> {
228 validate_conversation_id(&snapshot.id)?;
229 let appended = self.events.append(snapshot, events);
230 self.write_meta(snapshot);
236 appended
237 }
238
239 fn write_meta(&self, conversation: &ConversationHistory) {
243 if conversation.messages().is_empty() {
244 return;
245 }
246 let meta = ConversationMeta::from_history(conversation);
247 if let Ok(json) = serde_json::to_string(&meta) {
248 let path = self
249 .conversations_dir
250 .join(format!("{}.meta", conversation.id));
251 let _ = mermaid_runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600);
252 }
253 }
254
255 #[must_use]
259 pub fn event_log_path(&self, id: &str) -> PathBuf {
260 self.events.path_for(id)
261 }
262
263 pub fn read_session_events(
279 &self,
280 id: &str,
281 ) -> Result<Option<Vec<mermaid_domain::SessionEvent>>> {
282 validate_conversation_id(id)?;
283 Ok(self
284 .events
285 .read_events(id, None)?
286 .map(|(events, _highest)| events))
287 }
288
289 pub fn fold_conversation_from_log(&self, id: &str) -> Result<Option<ConversationHistory>> {
299 validate_conversation_id(id)?;
300 let Some(folded) = self.events.fold(id)? else {
301 return Ok(None);
302 };
303 validate_conversation_id(&folded.id)?;
306 Ok(Some(folded))
307 }
308
309 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
321 validate_conversation_id(&conversation.id)?;
325
326 if conversation.messages().is_empty() {
331 return Ok(());
332 }
333
334 let filename = format!("{}.json", conversation.id);
335 let path = self.conversations_dir.join(filename);
336
337 let mut value = match strip_persisted_tool_images(conversation.messages()) {
342 Some(sanitized) => {
343 let mut stripped = conversation.clone();
344 *stripped.messages_mut() = sanitized;
345 serde_json::to_value(&stripped)?
346 },
347 None => serde_json::to_value(conversation)?,
348 };
349 mermaid_model::utils::redact_json(&mut value);
350 if let Some(seq) = self.events.checkpoint_seq(&conversation.id)
358 && let Some(object) = value.as_object_mut()
359 {
360 object.insert(CHECKPOINT_SEQ_KEY.to_string(), seq.into());
361 }
362 let json = serde_json::to_string_pretty(&value)?;
363
364 mermaid_runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
377 self.write_meta(conversation);
384
385 Ok(())
386 }
387
388 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
397 validate_conversation_id(id)?;
398 let path = self.conversations_dir.join(format!("{id}.json"));
399 let checkpoint = read_checkpoint(&path);
400
401 if !self.events.exists(id) {
405 let (conversation, _) = checkpoint?;
406 validate_conversation_id(&conversation.id)?;
407 return Ok(conversation);
408 }
409
410 if let Ok((checkpoint, Some(seq))) = checkpoint
417 && validate_conversation_id(&checkpoint.id).is_ok()
418 && let Some(resumed) = self.events.replay_onto(id, checkpoint, seq)?
419 {
420 return Ok(resumed);
421 }
422
423 let folded = self
424 .fold_conversation_from_log(id)?
425 .with_context(|| format!("session {id} has a log that could not be folded"))?;
426 Ok(folded)
427 }
428
429 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
445 for id in self.session_ids_newest_first() {
450 let conv = match self.load_conversation(&id) {
453 Ok(conv) => conv,
454 Err(error) => {
455 tracing::warn!(id, %error, "skipping session that would not load");
456 continue;
457 },
458 };
459 if conv.messages().is_empty() {
462 continue;
463 }
464 return Ok(Some(conv));
465 }
466 Ok(None)
467 }
468
469 fn session_ids_newest_first(&self) -> Vec<String> {
476 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
477 return Vec::new();
478 };
479 let mut best: HashMap<String, (bool, SystemTime)> = HashMap::new();
484 for entry in entries.flatten() {
485 let path = entry.path();
486 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
487 continue;
488 };
489 let is_log = match ext {
490 "jsonl" => true,
491 "json" => false,
492 _ => continue,
493 };
494 let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
495 continue;
496 };
497 if validate_conversation_id(id).is_err() {
498 continue;
499 }
500 let Ok(mtime) = entry.metadata().and_then(|meta| meta.modified()) else {
501 continue;
502 };
503 match best.get(id) {
504 Some((had_log, _)) if *had_log && !is_log => {},
505 _ => {
506 best.insert(id.to_string(), (is_log, mtime));
507 },
508 }
509 }
510 let mut ranked: Vec<(SystemTime, String)> = best
511 .into_iter()
512 .map(|(id, (_, mtime))| (mtime, id))
513 .collect();
514 ranked.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
515 ranked.into_iter().map(|(_, id)| id).collect()
516 }
517
518 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
526 let mut conversations = Vec::new();
527
528 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
530 for entry in entries.flatten() {
531 if let Some(ext) = entry.path().extension()
532 && ext == "json"
533 && let Ok(json) = read_conversation_capped(&entry.path())
534 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
535 && !conv.messages().is_empty()
538 {
539 conversations.push(conv);
540 }
541 }
542 }
543
544 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
546
547 Ok(conversations)
548 }
549
550 pub fn list_conversation_metas(&self) -> Result<Vec<ConversationMeta>> {
562 let mut metas = Vec::new();
563 let mut seen = std::collections::HashSet::new();
564 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
565 return Ok(metas);
566 };
567 let paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
568 for path in &paths {
570 if path.extension().is_some_and(|e| e == "meta")
571 && let Ok(raw) = fs::read_to_string(path)
572 && let Ok(meta) = serde_json::from_str::<ConversationMeta>(&raw)
573 && meta.message_count > 0
574 {
575 seen.insert(meta.id.clone());
576 metas.push(meta);
577 }
578 }
579 for path in &paths {
581 if path.extension().is_some_and(|e| e == "json")
582 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
583 && !seen.contains(stem)
584 && let Ok(json) = read_conversation_capped(path)
585 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
586 && !conv.messages().is_empty()
587 {
588 seen.insert(stem.to_string());
589 metas.push(ConversationMeta::from_history(&conv));
590 }
591 }
592 for path in &paths {
599 if path.extension().is_some_and(|e| e == "jsonl")
600 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
601 && !seen.contains(stem)
602 && let Ok(Some(conv)) = self.fold_conversation_from_log(stem)
603 && !conv.messages().is_empty()
604 {
605 metas.push(ConversationMeta::from_history(&conv));
606 }
607 }
608 metas.sort_by_key(|m| std::cmp::Reverse(m.updated_at));
609 Ok(metas)
610 }
611
612 pub fn delete_conversation(&self, id: &str) -> Result<()> {
621 validate_conversation_id(id)?;
622 let _ = mermaid_runtime::with_shared_store(|store| store.sessions().delete(id));
626 let path = self.conversations_dir.join(format!("{id}.json"));
627 if path.exists() {
628 fs::remove_file(path)?;
629 }
630 let _ = fs::remove_file(self.conversations_dir.join(format!("{id}.meta")));
632 let _ = fs::remove_file(self.conversations_dir.join(format!("{id}.jsonl")));
633 let _ = crate::session::scratchpad::remove(&self.project_dir, id);
637
638 Ok(())
639 }
640
641 #[must_use]
643 pub fn conversations_dir(&self) -> &Path {
644 &self.conversations_dir
645 }
646}
647
648#[must_use]
652pub fn probe_session_provenance(cwd: &Path) -> mermaid_domain::SessionProvenance {
653 mermaid_domain::SessionProvenance {
654 git_branch: detect_git_branch(cwd),
655 git_sha: detect_git_sha(cwd),
656 cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 fn touched(project: &str) -> ConversationHistory {
667 let mut c = ConversationHistory::new(project.into(), "m".into(), Local::now());
668 c.add_messages(&[ChatMessage::user("hi")], Local::now());
669 c
670 }
671
672 #[test]
673 fn legacy_conversation_json_without_git_branch_deserializes() {
674 let json = r#"{
678 "id": "20260101_120000_001",
679 "title": "Legacy session",
680 "messages": [],
681 "model_name": "ollama/test",
682 "project_path": "/tmp/proj",
683 "created_at": "2026-01-01T12:00:00-05:00",
684 "updated_at": "2026-01-01T12:00:00-05:00",
685 "total_tokens": null
686 }"#;
687 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
688 assert!(conv.git_branch.is_none());
689 assert_eq!(conv.title, "Legacy session");
690 let mut fresh =
692 ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
693 fresh.git_branch = Some("feature/x".to_string());
694 let round: ConversationHistory =
695 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
696 assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
697 }
698
699 #[test]
700 fn legacy_json_defaults_session_state_fields() {
701 let json = r#"{
705 "id": "20260101_120000_002",
706 "title": "Old",
707 "messages": [],
708 "model_name": "m",
709 "project_path": "/tmp/proj",
710 "created_at": "2026-01-01T12:00:00-05:00",
711 "updated_at": "2026-01-01T12:00:00-05:00",
712 "total_tokens": null
713 }"#;
714 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
715 assert_eq!(conv.safety_mode, None);
716 assert_eq!(
717 conv.cumulative_token_usage,
718 mermaid_domain::TokenUsageTotals::default()
719 );
720 assert!(conv.last_token_usage.is_none());
721 assert!(conv.context_usage.is_none());
722 assert!(conv.tasks.tasks.is_empty());
723 assert_eq!(conv.tasks.next_id, 0);
724 assert!(
725 conv.advertised_context.is_none(),
726 "pre-field saves load a None baseline (silent seed)"
727 );
728 }
729
730 #[test]
731 fn a_save_from_the_retired_plan_mode_still_loads() {
732 let json = r#"{
738 "id": "20260101_120000_003",
739 "title": "Planning",
740 "messages": [],
741 "model_name": "m",
742 "project_path": "/tmp/proj",
743 "created_at": "2026-01-01T12:00:00-05:00",
744 "updated_at": "2026-01-01T12:00:00-05:00",
745 "total_tokens": null,
746 "safety_mode": "plan",
747 "advertised_context": {
748 "safety_mode": "plan",
749 "model_id": "ollama/test",
750 "plan_path": "/tmp/plans/p.md"
751 },
752 "plan": { "file": "/tmp/plans/p.md", "restore_mode": "auto" }
753 }"#;
754 let conv: ConversationHistory = serde_json::from_str(json).expect("plan-era json loads");
755 assert_eq!(conv.safety_mode, None);
756 assert!(conv.advertised_context.is_none());
757 }
758
759 #[test]
760 fn advertised_context_round_trips_through_conversation_json() {
761 let mut fresh = touched("/tmp/proj");
762 fresh.advertised_context = Some(mermaid_domain::AdvertisedContext {
763 safety_mode: mermaid_runtime::SafetyMode::Ask,
764 model_id: "ollama/test".to_string(),
765 });
766 let round: ConversationHistory =
767 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
768 let ctx = round.advertised_context.expect("field survives");
769 assert_eq!(ctx.safety_mode, mermaid_runtime::SafetyMode::Ask);
770 assert_eq!(ctx.model_id, "ollama/test");
771 }
772
773 #[test]
774 fn tasks_round_trip_through_conversation_json() {
775 let mut fresh = touched("/tmp/proj");
776 fresh.tasks.create(
777 vec![mermaid_domain::ChecklistSpec {
778 subject: "wire broker".into(),
779 active_form: "wiring broker".into(),
780 description: Some("through ExecContext".into()),
781 in_progress: true,
782 }],
783 mermaid_domain::ChecklistOrigin::Model,
784 mermaid_domain::Stamp {
785 now_epoch: 42,
786 run_tokens: 7,
787 },
788 );
789 let round: ConversationHistory =
790 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
791 assert_eq!(round.tasks, fresh.tasks);
792 assert_eq!(round.tasks.tasks[0].started_at, Some(42));
793 }
794
795 #[test]
796 fn session_state_round_trips_through_json() {
797 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
798 conv.safety_mode = Some(mermaid_runtime::SafetyMode::FullAccess);
799 conv.cumulative_token_usage = mermaid_domain::TokenUsageTotals {
800 prompt_tokens: 777,
801 ..Default::default()
802 };
803 let round: ConversationHistory =
804 serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
805 assert_eq!(
806 round.safety_mode,
807 Some(mermaid_runtime::SafetyMode::FullAccess)
808 );
809 assert_eq!(round.cumulative_token_usage.total_tokens(), 777);
810 }
811
812 #[test]
813 fn validate_conversation_id_rejects_traversal() {
814 assert!(validate_conversation_id("20260101_120000_001").is_ok());
815 assert!(validate_conversation_id("../secret").is_err());
816 assert!(validate_conversation_id("..\\secret").is_err());
817 assert!(validate_conversation_id("/etc/passwd").is_err());
818 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
821
822 #[test]
823 fn strip_persisted_tool_images_drops_assistant_images_keeps_user_images() {
824 let messages = vec![
825 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
826 ChatMessage::assistant("here is the screen")
827 .with_images(vec!["SCREENSHOT_B64".to_string()]),
828 ChatMessage::assistant("no image here"),
829 ];
830 let sanitized = strip_persisted_tool_images(&messages).expect("had a tool image to strip");
831 assert_eq!(
833 sanitized[0].images.as_deref(),
834 Some(["USER_PASTED_B64".to_string()].as_slice())
835 );
836 assert!(sanitized[1].images.is_none());
838 assert!(sanitized[1].content.ends_with(TOOL_IMAGE_ELIDED_MARKER));
839 assert!(!sanitized[2].content.ends_with(TOOL_IMAGE_ELIDED_MARKER));
841 }
842
843 #[test]
844 fn strip_persisted_tool_images_is_none_without_assistant_images() {
845 let messages = vec![
846 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
847 ChatMessage::assistant("no images"),
848 ];
849 assert!(strip_persisted_tool_images(&messages).is_none());
850 }
851
852 #[test]
853 fn saved_conversation_json_has_no_tool_image_bytes() {
854 let dir = std::env::temp_dir().join("mermaid_strip_test");
855 let _ = fs::create_dir_all(&dir);
856 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
857 *conv.messages_mut() = vec![
858 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
859 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
860 ];
861 let store = ConversationManager {
862 project_dir: dir.clone(),
863 events: Arc::new(crate::session::event_log::EventLog::new(dir.clone())),
864 conversations_dir: dir.clone(),
865 };
866 store.save_conversation(&conv).expect("save");
867 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
868 assert!(
869 !raw.contains("SHOTBYTES"),
870 "tool image leaked to disk: {raw}"
871 );
872 assert!(raw.contains("USERIMG"), "user image should persist");
873 assert_eq!(
875 conv.messages()[1].images.as_deref(),
876 Some(["SHOTBYTES".to_string()].as_slice())
877 );
878 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
879 }
880
881 #[test]
882 fn saved_conversation_redacts_secrets_and_is_owner_only() {
883 let dir = std::env::temp_dir().join(format!("mermaid_conv_redact_{}", std::process::id()));
884 let _ = fs::remove_dir_all(&dir);
885 let _ = fs::create_dir_all(&dir);
886 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
887 *conv.messages_mut() = vec![
889 ChatMessage::user("read .env"),
890 ChatMessage::assistant("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
891 ];
892 let store = ConversationManager {
893 project_dir: dir.clone(),
894 events: Arc::new(crate::session::event_log::EventLog::new(dir.clone())),
895 conversations_dir: dir.clone(),
896 };
897 store.save_conversation(&conv).expect("save");
898 let path = dir.join(format!("{}.json", conv.id));
899 let raw = fs::read_to_string(&path).expect("read");
900 assert!(
901 !raw.contains("sk-abcdefghijklmnop1234"),
902 "secret leaked to the conversation store: {raw}"
903 );
904 assert!(
905 raw.contains("[REDACTED]"),
906 "expected redaction marker: {raw}"
907 );
908 #[cfg(unix)]
909 {
910 use std::os::unix::fs::PermissionsExt;
911 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
912 assert_eq!(
913 mode, 0o600,
914 "conversation file must be owner-only, got {mode:o}"
915 );
916 }
917 assert!(
919 conv.messages()[1]
920 .content
921 .contains("sk-abcdefghijklmnop1234")
922 );
923 let _ = fs::remove_dir_all(&dir);
924 }
925
926 #[test]
927 fn test_new_conversation_has_session_title() {
928 let conv =
929 ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
930 assert!(conv.title.starts_with("Session "));
931 assert_eq!(conv.model_name, "test-model");
932 assert_eq!(conv.project_path, "/tmp/project");
933 assert!(conv.messages().is_empty());
934 }
935
936 #[test]
937 fn test_title_updates_from_first_user_message() {
938 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
939 conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
940 assert_eq!(conv.title, "Fix the login bug");
941 }
942
943 #[test]
944 fn test_title_truncated_at_60_chars() {
945 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
946 let long_msg = "a".repeat(100);
947 conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
948 assert!(conv.title.ends_with("..."));
949 assert!(conv.title.len() <= 64); }
951
952 #[test]
953 fn test_title_set_only_once() {
954 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
955 conv.add_messages(&[ChatMessage::user("First message")], Local::now());
956 conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
957 assert_eq!(conv.title, "First message");
958 }
959
960 #[test]
961 fn test_input_history_deduplication() {
962 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
963 conv.add_to_input_history("hello".into());
964 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
966 assert_eq!(conv.input_history.len(), 2);
967 }
968
969 #[test]
970 fn test_input_history_skips_empty() {
971 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
972 conv.add_to_input_history("".into());
973 conv.add_to_input_history(" ".into());
974 assert_eq!(conv.input_history.len(), 0);
975 }
976
977 #[test]
978 fn test_input_history_capped_at_100() {
979 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
980 for i in 0..110 {
981 conv.add_to_input_history(format!("msg{i}"));
982 }
983 assert_eq!(conv.input_history.len(), 100);
984 assert_eq!(conv.input_history.front().unwrap(), "msg10");
985 }
986
987 #[test]
988 fn sidecar_powers_metadata_listing() {
989 let dir = std::env::temp_dir().join("mermaid_test_meta_sidecar");
990 let _ = fs::remove_dir_all(&dir);
991 let manager = ConversationManager::new(&dir).unwrap();
992 let mut conv = ConversationHistory::new("/tmp/proj".into(), "model".into(), Local::now());
993 conv.title = "My session".into();
994 conv.add_messages(
995 &[ChatMessage::user("hi"), ChatMessage::user("there")],
996 Local::now(),
997 );
998 manager.save_conversation(&conv).unwrap();
999
1000 assert!(
1001 manager
1002 .conversations_dir()
1003 .join(format!("{}.meta", conv.id))
1004 .exists()
1005 );
1006 let metas = manager.list_conversation_metas().unwrap();
1007 assert_eq!(metas.len(), 1);
1008 assert_eq!(metas[0].id, conv.id);
1009 assert_eq!(metas[0].title, "My session");
1010 assert_eq!(metas[0].message_count, 2);
1011
1012 manager.delete_conversation(&conv.id).unwrap();
1014 assert!(
1015 !manager
1016 .conversations_dir()
1017 .join(format!("{}.meta", conv.id))
1018 .exists()
1019 );
1020 assert!(manager.list_conversation_metas().unwrap().is_empty());
1021 let _ = fs::remove_dir_all(&dir);
1022 }
1023
1024 #[test]
1025 fn metadata_listing_falls_back_to_full_parse_without_sidecar() {
1026 let dir = std::env::temp_dir().join("mermaid_test_meta_fallback");
1027 let _ = fs::remove_dir_all(&dir);
1028 let manager = ConversationManager::new(&dir).unwrap();
1029 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1030 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1031 manager.save_conversation(&conv).unwrap();
1032 fs::remove_file(
1034 manager
1035 .conversations_dir()
1036 .join(format!("{}.meta", conv.id)),
1037 )
1038 .unwrap();
1039 let metas = manager.list_conversation_metas().unwrap();
1040 assert_eq!(metas.len(), 1, "falls back to parsing the .json");
1041 assert_eq!(metas[0].message_count, 1);
1042 let _ = fs::remove_dir_all(&dir);
1043 }
1044
1045 #[test]
1046 fn lineage_fields_default_on_old_sessions() {
1047 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}"#;
1049 let conv: ConversationHistory = serde_json::from_str(json).unwrap();
1050 assert!(conv.git_sha.is_none());
1051 assert!(conv.cli_version.is_none());
1052 assert!(conv.forked_from.is_none());
1053 assert!(conv.parent_session.is_none());
1054 }
1055
1056 #[test]
1057 fn test_save_load_roundtrip() {
1058 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
1059 let _ = fs::remove_dir_all(&dir);
1060 let manager = ConversationManager::new(&dir).unwrap();
1061
1062 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1063 conv.add_messages(&[ChatMessage::user("test message")], Local::now());
1064 conv.add_to_input_history("test message".into());
1065
1066 manager.save_conversation(&conv).unwrap();
1067 let loaded = manager.load_conversation(&conv.id).unwrap();
1068
1069 assert_eq!(loaded.id, conv.id);
1070 assert_eq!(loaded.title, conv.title);
1071 assert_eq!(loaded.messages().len(), 1);
1072 assert_eq!(loaded.input_history.len(), 1);
1073
1074 let _ = fs::remove_dir_all(&dir);
1075 }
1076
1077 #[test]
1078 fn test_list_conversations_ordered_by_updated_at() {
1079 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
1080 let _ = fs::remove_dir_all(&dir);
1081 let manager = ConversationManager::new(&dir).unwrap();
1082
1083 let conv1 = touched("/tmp");
1084 std::thread::sleep(std::time::Duration::from_millis(10));
1085 let conv2 = touched("/tmp");
1086
1087 manager.save_conversation(&conv1).unwrap();
1088 manager.save_conversation(&conv2).unwrap();
1089
1090 let list = manager.list_conversations().unwrap();
1091 assert_eq!(list.len(), 2);
1092 assert_eq!(list[0].id, conv2.id);
1094 assert_eq!(list[1].id, conv1.id);
1095
1096 let _ = fs::remove_dir_all(&dir);
1097 }
1098
1099 #[test]
1100 fn test_load_last_conversation() {
1101 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
1102 let _ = fs::remove_dir_all(&dir);
1103 let manager = ConversationManager::new(&dir).unwrap();
1104
1105 assert!(manager.load_last_conversation().unwrap().is_none());
1106
1107 let conv = touched("/tmp");
1108 manager.save_conversation(&conv).unwrap();
1109
1110 let last = manager.load_last_conversation().unwrap().unwrap();
1111 assert_eq!(last.id, conv.id);
1112
1113 let _ = fs::remove_dir_all(&dir);
1114 }
1115
1116 #[test]
1117 fn test_load_last_conversation_picks_newest_by_mtime() {
1118 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
1123 let _ = fs::remove_dir_all(&dir);
1124 let manager = ConversationManager::new(&dir).unwrap();
1125
1126 let conv1 = touched("/tmp");
1127 manager.save_conversation(&conv1).unwrap();
1128 std::thread::sleep(std::time::Duration::from_millis(10));
1129
1130 let conv2 = touched("/tmp");
1131 manager.save_conversation(&conv2).unwrap();
1132 std::thread::sleep(std::time::Duration::from_millis(10));
1133
1134 let conv3 = touched("/tmp");
1135 manager.save_conversation(&conv3).unwrap();
1136
1137 let last = manager.load_last_conversation().unwrap().unwrap();
1138 assert_eq!(
1139 last.id, conv3.id,
1140 "should return the most-recently-written file"
1141 );
1142
1143 let _ = fs::remove_dir_all(&dir);
1144 }
1145
1146 #[test]
1147 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
1148 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
1149 let _ = fs::remove_dir_all(&dir);
1150 let manager = ConversationManager::new(&dir).unwrap();
1151
1152 let good = touched("/tmp");
1153 manager.save_conversation(&good).unwrap();
1154 std::thread::sleep(std::time::Duration::from_millis(10));
1155
1156 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
1159 fs::write(&corrupt, b"{ not valid json").unwrap();
1160
1161 let last = manager.load_last_conversation().unwrap().unwrap();
1162 assert_eq!(
1163 last.id, good.id,
1164 "must fall back to the newest VALID conversation"
1165 );
1166 let _ = fs::remove_dir_all(&dir);
1167 }
1168
1169 #[test]
1170 fn load_last_conversation_none_when_only_corrupt() {
1171 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
1172 let _ = fs::remove_dir_all(&dir);
1173 let manager = ConversationManager::new(&dir).unwrap();
1174 fs::write(
1175 manager.conversations_dir().join("20991231_235959_998.json"),
1176 b"nope",
1177 )
1178 .unwrap();
1179 assert!(manager.load_last_conversation().unwrap().is_none());
1180 let _ = fs::remove_dir_all(&dir);
1181 }
1182
1183 #[test]
1184 fn load_conversation_tolerates_unknown_message_role() {
1185 let dir =
1190 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
1191 let _ = fs::remove_dir_all(&dir);
1192 let manager = ConversationManager::new(&dir).unwrap();
1193
1194 let id = "20260101_120000_001";
1195 let json = format!(
1196 r#"{{
1197 "id": "{id}",
1198 "title": "skew",
1199 "messages": [
1200 {{
1201 "role": "Developer",
1202 "content": "from a newer build",
1203 "timestamp": "2026-01-01T12:00:00-04:00"
1204 }}
1205 ],
1206 "model_name": "m",
1207 "project_path": "/tmp",
1208 "created_at": "2026-01-01T12:00:00-04:00",
1209 "updated_at": "2026-01-01T12:00:00-04:00",
1210 "total_tokens": null
1211 }}"#
1212 );
1213 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
1214
1215 let loaded = manager
1216 .load_conversation(id)
1217 .expect("must load despite an unknown role");
1218 assert_eq!(loaded.messages().len(), 1);
1219 assert_eq!(
1220 loaded.messages()[0].role,
1221 MessageRole::System,
1222 "an unknown role becomes a neutral System message"
1223 );
1224
1225 let last = manager
1227 .load_last_conversation()
1228 .unwrap()
1229 .expect("the newest session must load");
1230 assert_eq!(last.id, id);
1231
1232 let _ = fs::remove_dir_all(&dir);
1233 }
1234
1235 #[test]
1236 fn test_delete_conversation() {
1237 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
1238 let _ = fs::remove_dir_all(&dir);
1239 let manager = ConversationManager::new(&dir).unwrap();
1240
1241 let conv = touched("/tmp");
1242 manager.save_conversation(&conv).unwrap();
1243 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1244
1245 manager.delete_conversation(&conv.id).unwrap();
1246 assert_eq!(manager.list_conversations().unwrap().len(), 0);
1247
1248 let _ = fs::remove_dir_all(&dir);
1249 }
1250
1251 #[test]
1252 fn empty_session_is_not_saved() {
1253 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
1254 let _ = fs::remove_dir_all(&dir);
1255 let manager = ConversationManager::new(&dir).unwrap();
1256
1257 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1259 manager.save_conversation(&conv).unwrap();
1260 assert!(
1261 manager.list_conversations().unwrap().is_empty(),
1262 "empty session must not be listed"
1263 );
1264 assert!(
1265 manager.load_last_conversation().unwrap().is_none(),
1266 "empty session must not be --continue-able"
1267 );
1268
1269 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1271 manager.save_conversation(&conv).unwrap();
1272 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1273
1274 let _ = fs::remove_dir_all(&dir);
1275 }
1276
1277 #[test]
1278 fn resume_paths_skip_pre_existing_empty_files() {
1279 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
1282 let _ = fs::remove_dir_all(&dir);
1283 let manager = ConversationManager::new(&dir).unwrap();
1284
1285 let real = touched("/tmp");
1286 manager.save_conversation(&real).unwrap();
1287 std::thread::sleep(std::time::Duration::from_millis(10));
1289 let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1290 let path = manager
1291 .conversations_dir()
1292 .join(format!("{}.json", empty.id));
1293 fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
1294
1295 let list = manager.list_conversations().unwrap();
1296 assert_eq!(list.len(), 1, "the empty file must not be listed");
1297 assert_eq!(list[0].id, real.id);
1298 assert_eq!(
1299 manager.load_last_conversation().unwrap().unwrap().id,
1300 real.id,
1301 "--continue must skip the newer empty file"
1302 );
1303
1304 let _ = fs::remove_dir_all(&dir);
1305 }
1306
1307 #[test]
1308 fn read_conversation_capped_refuses_oversized_file() {
1309 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
1312 let _ = fs::remove_dir_all(&dir);
1313 fs::create_dir_all(&dir).unwrap();
1314
1315 let small = dir.join("small.json");
1316 fs::write(&small, b"{}").unwrap();
1317 assert!(read_conversation_capped(&small).is_ok());
1318
1319 let big = dir.join("big.json");
1320 let f = fs::File::create(&big).unwrap();
1321 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
1322 assert!(
1323 read_conversation_capped(&big).is_err(),
1324 "a file over the cap must be refused, not slurped into memory"
1325 );
1326
1327 let _ = fs::remove_dir_all(&dir);
1328 }
1329
1330 }