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