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 advertised_context_round_trips_through_conversation_json() {
732 let mut fresh = touched("/tmp/proj");
733 fresh.advertised_context = Some(mermaid_domain::AdvertisedContext {
734 plan_path: Some(std::path::PathBuf::from("/tmp/proj/.mermaid/plans/x.md")),
735 safety_mode: mermaid_runtime::SafetyMode::Ask,
736 model_id: "ollama/test".to_string(),
737 });
738 let round: ConversationHistory =
739 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
740 let ctx = round.advertised_context.expect("field survives");
741 assert_eq!(
742 ctx.plan_path.as_deref(),
743 Some(std::path::Path::new("/tmp/proj/.mermaid/plans/x.md"))
744 );
745 assert_eq!(ctx.model_id, "ollama/test");
746 }
747
748 #[test]
749 fn tasks_round_trip_through_conversation_json() {
750 let mut fresh = touched("/tmp/proj");
751 fresh.tasks.create(
752 vec![mermaid_domain::ChecklistSpec {
753 subject: "wire broker".into(),
754 active_form: "wiring broker".into(),
755 description: Some("through ExecContext".into()),
756 in_progress: true,
757 }],
758 mermaid_domain::ChecklistOrigin::Model,
759 mermaid_domain::Stamp {
760 now_epoch: 42,
761 run_tokens: 7,
762 },
763 );
764 let round: ConversationHistory =
765 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
766 assert_eq!(round.tasks, fresh.tasks);
767 assert_eq!(round.tasks.tasks[0].started_at, Some(42));
768 }
769
770 #[test]
771 fn session_state_round_trips_through_json() {
772 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
773 conv.safety_mode = Some(mermaid_runtime::SafetyMode::FullAccess);
774 conv.cumulative_token_usage = mermaid_domain::TokenUsageTotals {
775 prompt_tokens: 777,
776 ..Default::default()
777 };
778 let round: ConversationHistory =
779 serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
780 assert_eq!(
781 round.safety_mode,
782 Some(mermaid_runtime::SafetyMode::FullAccess)
783 );
784 assert_eq!(round.cumulative_token_usage.total_tokens(), 777);
785 }
786
787 #[test]
788 fn validate_conversation_id_rejects_traversal() {
789 assert!(validate_conversation_id("20260101_120000_001").is_ok());
790 assert!(validate_conversation_id("../secret").is_err());
791 assert!(validate_conversation_id("..\\secret").is_err());
792 assert!(validate_conversation_id("/etc/passwd").is_err());
793 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
796
797 #[test]
798 fn strip_persisted_tool_images_drops_assistant_images_keeps_user_images() {
799 let messages = vec![
800 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
801 ChatMessage::assistant("here is the screen")
802 .with_images(vec!["SCREENSHOT_B64".to_string()]),
803 ChatMessage::assistant("no image here"),
804 ];
805 let sanitized = strip_persisted_tool_images(&messages).expect("had a tool image to strip");
806 assert_eq!(
808 sanitized[0].images.as_deref(),
809 Some(["USER_PASTED_B64".to_string()].as_slice())
810 );
811 assert!(sanitized[1].images.is_none());
813 assert!(sanitized[1].content.ends_with(TOOL_IMAGE_ELIDED_MARKER));
814 assert!(!sanitized[2].content.ends_with(TOOL_IMAGE_ELIDED_MARKER));
816 }
817
818 #[test]
819 fn strip_persisted_tool_images_is_none_without_assistant_images() {
820 let messages = vec![
821 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
822 ChatMessage::assistant("no images"),
823 ];
824 assert!(strip_persisted_tool_images(&messages).is_none());
825 }
826
827 #[test]
828 fn saved_conversation_json_has_no_tool_image_bytes() {
829 let dir = std::env::temp_dir().join("mermaid_strip_test");
830 let _ = fs::create_dir_all(&dir);
831 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
832 *conv.messages_mut() = vec![
833 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
834 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
835 ];
836 let store = ConversationManager {
837 project_dir: dir.clone(),
838 events: Arc::new(crate::session::event_log::EventLog::new(dir.clone())),
839 conversations_dir: dir.clone(),
840 };
841 store.save_conversation(&conv).expect("save");
842 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
843 assert!(
844 !raw.contains("SHOTBYTES"),
845 "tool image leaked to disk: {raw}"
846 );
847 assert!(raw.contains("USERIMG"), "user image should persist");
848 assert_eq!(
850 conv.messages()[1].images.as_deref(),
851 Some(["SHOTBYTES".to_string()].as_slice())
852 );
853 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
854 }
855
856 #[test]
857 fn saved_conversation_redacts_secrets_and_is_owner_only() {
858 let dir = std::env::temp_dir().join(format!("mermaid_conv_redact_{}", std::process::id()));
859 let _ = fs::remove_dir_all(&dir);
860 let _ = fs::create_dir_all(&dir);
861 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
862 *conv.messages_mut() = vec![
864 ChatMessage::user("read .env"),
865 ChatMessage::assistant("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
866 ];
867 let store = ConversationManager {
868 project_dir: dir.clone(),
869 events: Arc::new(crate::session::event_log::EventLog::new(dir.clone())),
870 conversations_dir: dir.clone(),
871 };
872 store.save_conversation(&conv).expect("save");
873 let path = dir.join(format!("{}.json", conv.id));
874 let raw = fs::read_to_string(&path).expect("read");
875 assert!(
876 !raw.contains("sk-abcdefghijklmnop1234"),
877 "secret leaked to the conversation store: {raw}"
878 );
879 assert!(
880 raw.contains("[REDACTED]"),
881 "expected redaction marker: {raw}"
882 );
883 #[cfg(unix)]
884 {
885 use std::os::unix::fs::PermissionsExt;
886 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
887 assert_eq!(
888 mode, 0o600,
889 "conversation file must be owner-only, got {mode:o}"
890 );
891 }
892 assert!(
894 conv.messages()[1]
895 .content
896 .contains("sk-abcdefghijklmnop1234")
897 );
898 let _ = fs::remove_dir_all(&dir);
899 }
900
901 #[test]
902 fn test_new_conversation_has_session_title() {
903 let conv =
904 ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
905 assert!(conv.title.starts_with("Session "));
906 assert_eq!(conv.model_name, "test-model");
907 assert_eq!(conv.project_path, "/tmp/project");
908 assert!(conv.messages().is_empty());
909 }
910
911 #[test]
912 fn test_title_updates_from_first_user_message() {
913 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
914 conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
915 assert_eq!(conv.title, "Fix the login bug");
916 }
917
918 #[test]
919 fn test_title_truncated_at_60_chars() {
920 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
921 let long_msg = "a".repeat(100);
922 conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
923 assert!(conv.title.ends_with("..."));
924 assert!(conv.title.len() <= 64); }
926
927 #[test]
928 fn test_title_set_only_once() {
929 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
930 conv.add_messages(&[ChatMessage::user("First message")], Local::now());
931 conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
932 assert_eq!(conv.title, "First message");
933 }
934
935 #[test]
936 fn test_input_history_deduplication() {
937 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
938 conv.add_to_input_history("hello".into());
939 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
941 assert_eq!(conv.input_history.len(), 2);
942 }
943
944 #[test]
945 fn test_input_history_skips_empty() {
946 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
947 conv.add_to_input_history("".into());
948 conv.add_to_input_history(" ".into());
949 assert_eq!(conv.input_history.len(), 0);
950 }
951
952 #[test]
953 fn test_input_history_capped_at_100() {
954 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
955 for i in 0..110 {
956 conv.add_to_input_history(format!("msg{i}"));
957 }
958 assert_eq!(conv.input_history.len(), 100);
959 assert_eq!(conv.input_history.front().unwrap(), "msg10");
960 }
961
962 #[test]
963 fn sidecar_powers_metadata_listing() {
964 let dir = std::env::temp_dir().join("mermaid_test_meta_sidecar");
965 let _ = fs::remove_dir_all(&dir);
966 let manager = ConversationManager::new(&dir).unwrap();
967 let mut conv = ConversationHistory::new("/tmp/proj".into(), "model".into(), Local::now());
968 conv.title = "My session".into();
969 conv.add_messages(
970 &[ChatMessage::user("hi"), ChatMessage::user("there")],
971 Local::now(),
972 );
973 manager.save_conversation(&conv).unwrap();
974
975 assert!(
976 manager
977 .conversations_dir()
978 .join(format!("{}.meta", conv.id))
979 .exists()
980 );
981 let metas = manager.list_conversation_metas().unwrap();
982 assert_eq!(metas.len(), 1);
983 assert_eq!(metas[0].id, conv.id);
984 assert_eq!(metas[0].title, "My session");
985 assert_eq!(metas[0].message_count, 2);
986
987 manager.delete_conversation(&conv.id).unwrap();
989 assert!(
990 !manager
991 .conversations_dir()
992 .join(format!("{}.meta", conv.id))
993 .exists()
994 );
995 assert!(manager.list_conversation_metas().unwrap().is_empty());
996 let _ = fs::remove_dir_all(&dir);
997 }
998
999 #[test]
1000 fn metadata_listing_falls_back_to_full_parse_without_sidecar() {
1001 let dir = std::env::temp_dir().join("mermaid_test_meta_fallback");
1002 let _ = fs::remove_dir_all(&dir);
1003 let manager = ConversationManager::new(&dir).unwrap();
1004 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1005 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1006 manager.save_conversation(&conv).unwrap();
1007 fs::remove_file(
1009 manager
1010 .conversations_dir()
1011 .join(format!("{}.meta", conv.id)),
1012 )
1013 .unwrap();
1014 let metas = manager.list_conversation_metas().unwrap();
1015 assert_eq!(metas.len(), 1, "falls back to parsing the .json");
1016 assert_eq!(metas[0].message_count, 1);
1017 let _ = fs::remove_dir_all(&dir);
1018 }
1019
1020 #[test]
1021 fn lineage_fields_default_on_old_sessions() {
1022 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}"#;
1024 let conv: ConversationHistory = serde_json::from_str(json).unwrap();
1025 assert!(conv.git_sha.is_none());
1026 assert!(conv.cli_version.is_none());
1027 assert!(conv.forked_from.is_none());
1028 assert!(conv.parent_session.is_none());
1029 }
1030
1031 #[test]
1032 fn test_save_load_roundtrip() {
1033 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
1034 let _ = fs::remove_dir_all(&dir);
1035 let manager = ConversationManager::new(&dir).unwrap();
1036
1037 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1038 conv.add_messages(&[ChatMessage::user("test message")], Local::now());
1039 conv.add_to_input_history("test message".into());
1040
1041 manager.save_conversation(&conv).unwrap();
1042 let loaded = manager.load_conversation(&conv.id).unwrap();
1043
1044 assert_eq!(loaded.id, conv.id);
1045 assert_eq!(loaded.title, conv.title);
1046 assert_eq!(loaded.messages().len(), 1);
1047 assert_eq!(loaded.input_history.len(), 1);
1048
1049 let _ = fs::remove_dir_all(&dir);
1050 }
1051
1052 #[test]
1053 fn test_list_conversations_ordered_by_updated_at() {
1054 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
1055 let _ = fs::remove_dir_all(&dir);
1056 let manager = ConversationManager::new(&dir).unwrap();
1057
1058 let conv1 = touched("/tmp");
1059 std::thread::sleep(std::time::Duration::from_millis(10));
1060 let conv2 = touched("/tmp");
1061
1062 manager.save_conversation(&conv1).unwrap();
1063 manager.save_conversation(&conv2).unwrap();
1064
1065 let list = manager.list_conversations().unwrap();
1066 assert_eq!(list.len(), 2);
1067 assert_eq!(list[0].id, conv2.id);
1069 assert_eq!(list[1].id, conv1.id);
1070
1071 let _ = fs::remove_dir_all(&dir);
1072 }
1073
1074 #[test]
1075 fn test_load_last_conversation() {
1076 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
1077 let _ = fs::remove_dir_all(&dir);
1078 let manager = ConversationManager::new(&dir).unwrap();
1079
1080 assert!(manager.load_last_conversation().unwrap().is_none());
1081
1082 let conv = touched("/tmp");
1083 manager.save_conversation(&conv).unwrap();
1084
1085 let last = manager.load_last_conversation().unwrap().unwrap();
1086 assert_eq!(last.id, conv.id);
1087
1088 let _ = fs::remove_dir_all(&dir);
1089 }
1090
1091 #[test]
1092 fn test_load_last_conversation_picks_newest_by_mtime() {
1093 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
1098 let _ = fs::remove_dir_all(&dir);
1099 let manager = ConversationManager::new(&dir).unwrap();
1100
1101 let conv1 = touched("/tmp");
1102 manager.save_conversation(&conv1).unwrap();
1103 std::thread::sleep(std::time::Duration::from_millis(10));
1104
1105 let conv2 = touched("/tmp");
1106 manager.save_conversation(&conv2).unwrap();
1107 std::thread::sleep(std::time::Duration::from_millis(10));
1108
1109 let conv3 = touched("/tmp");
1110 manager.save_conversation(&conv3).unwrap();
1111
1112 let last = manager.load_last_conversation().unwrap().unwrap();
1113 assert_eq!(
1114 last.id, conv3.id,
1115 "should return the most-recently-written file"
1116 );
1117
1118 let _ = fs::remove_dir_all(&dir);
1119 }
1120
1121 #[test]
1122 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
1123 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
1124 let _ = fs::remove_dir_all(&dir);
1125 let manager = ConversationManager::new(&dir).unwrap();
1126
1127 let good = touched("/tmp");
1128 manager.save_conversation(&good).unwrap();
1129 std::thread::sleep(std::time::Duration::from_millis(10));
1130
1131 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
1134 fs::write(&corrupt, b"{ not valid json").unwrap();
1135
1136 let last = manager.load_last_conversation().unwrap().unwrap();
1137 assert_eq!(
1138 last.id, good.id,
1139 "must fall back to the newest VALID conversation"
1140 );
1141 let _ = fs::remove_dir_all(&dir);
1142 }
1143
1144 #[test]
1145 fn load_last_conversation_none_when_only_corrupt() {
1146 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
1147 let _ = fs::remove_dir_all(&dir);
1148 let manager = ConversationManager::new(&dir).unwrap();
1149 fs::write(
1150 manager.conversations_dir().join("20991231_235959_998.json"),
1151 b"nope",
1152 )
1153 .unwrap();
1154 assert!(manager.load_last_conversation().unwrap().is_none());
1155 let _ = fs::remove_dir_all(&dir);
1156 }
1157
1158 #[test]
1159 fn load_conversation_tolerates_unknown_message_role() {
1160 let dir =
1165 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
1166 let _ = fs::remove_dir_all(&dir);
1167 let manager = ConversationManager::new(&dir).unwrap();
1168
1169 let id = "20260101_120000_001";
1170 let json = format!(
1171 r#"{{
1172 "id": "{id}",
1173 "title": "skew",
1174 "messages": [
1175 {{
1176 "role": "Developer",
1177 "content": "from a newer build",
1178 "timestamp": "2026-01-01T12:00:00-04:00"
1179 }}
1180 ],
1181 "model_name": "m",
1182 "project_path": "/tmp",
1183 "created_at": "2026-01-01T12:00:00-04:00",
1184 "updated_at": "2026-01-01T12:00:00-04:00",
1185 "total_tokens": null
1186 }}"#
1187 );
1188 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
1189
1190 let loaded = manager
1191 .load_conversation(id)
1192 .expect("must load despite an unknown role");
1193 assert_eq!(loaded.messages().len(), 1);
1194 assert_eq!(
1195 loaded.messages()[0].role,
1196 MessageRole::System,
1197 "an unknown role becomes a neutral System message"
1198 );
1199
1200 let last = manager
1202 .load_last_conversation()
1203 .unwrap()
1204 .expect("the newest session must load");
1205 assert_eq!(last.id, id);
1206
1207 let _ = fs::remove_dir_all(&dir);
1208 }
1209
1210 #[test]
1211 fn test_delete_conversation() {
1212 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
1213 let _ = fs::remove_dir_all(&dir);
1214 let manager = ConversationManager::new(&dir).unwrap();
1215
1216 let conv = touched("/tmp");
1217 manager.save_conversation(&conv).unwrap();
1218 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1219
1220 manager.delete_conversation(&conv.id).unwrap();
1221 assert_eq!(manager.list_conversations().unwrap().len(), 0);
1222
1223 let _ = fs::remove_dir_all(&dir);
1224 }
1225
1226 #[test]
1227 fn empty_session_is_not_saved() {
1228 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
1229 let _ = fs::remove_dir_all(&dir);
1230 let manager = ConversationManager::new(&dir).unwrap();
1231
1232 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1234 manager.save_conversation(&conv).unwrap();
1235 assert!(
1236 manager.list_conversations().unwrap().is_empty(),
1237 "empty session must not be listed"
1238 );
1239 assert!(
1240 manager.load_last_conversation().unwrap().is_none(),
1241 "empty session must not be --continue-able"
1242 );
1243
1244 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1246 manager.save_conversation(&conv).unwrap();
1247 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1248
1249 let _ = fs::remove_dir_all(&dir);
1250 }
1251
1252 #[test]
1253 fn resume_paths_skip_pre_existing_empty_files() {
1254 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
1257 let _ = fs::remove_dir_all(&dir);
1258 let manager = ConversationManager::new(&dir).unwrap();
1259
1260 let real = touched("/tmp");
1261 manager.save_conversation(&real).unwrap();
1262 std::thread::sleep(std::time::Duration::from_millis(10));
1264 let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1265 let path = manager
1266 .conversations_dir()
1267 .join(format!("{}.json", empty.id));
1268 fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
1269
1270 let list = manager.list_conversations().unwrap();
1271 assert_eq!(list.len(), 1, "the empty file must not be listed");
1272 assert_eq!(list[0].id, real.id);
1273 assert_eq!(
1274 manager.load_last_conversation().unwrap().unwrap().id,
1275 real.id,
1276 "--continue must skip the newer empty file"
1277 );
1278
1279 let _ = fs::remove_dir_all(&dir);
1280 }
1281
1282 #[test]
1283 fn read_conversation_capped_refuses_oversized_file() {
1284 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
1287 let _ = fs::remove_dir_all(&dir);
1288 fs::create_dir_all(&dir).unwrap();
1289
1290 let small = dir.join("small.json");
1291 fs::write(&small, b"{}").unwrap();
1292 assert!(read_conversation_capped(&small).is_ok());
1293
1294 let big = dir.join("big.json");
1295 let f = fs::File::create(&big).unwrap();
1296 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
1297 assert!(
1298 read_conversation_capped(&big).is_err(),
1299 "a file over the cap must be refused, not slurped into memory"
1300 );
1301
1302 let _ = fs::remove_dir_all(&dir);
1303 }
1304
1305 }