1use crate::domain::CompactionArchive;
2use crate::models::{ChatMessage, MessageRole};
3use anyhow::Result;
4use chrono::{DateTime, Local};
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, VecDeque};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11use std::time::SystemTime;
12
13fn validate_conversation_id(id: &str) -> Result<()> {
20 let valid = id.len() == 19
21 && id.as_bytes().iter().enumerate().all(|(i, b)| match i {
22 8 | 15 => *b == b'_',
23 _ => b.is_ascii_digit(),
24 });
25 anyhow::ensure!(valid, "invalid conversation id: {id:?}");
26 Ok(())
27}
28
29const MAX_CONVERSATION_BYTES: u64 = 64 * 1024 * 1024;
34
35fn read_conversation_capped(path: &Path) -> std::io::Result<String> {
38 let len = fs::metadata(path)?.len();
39 if len > MAX_CONVERSATION_BYTES {
40 return Err(std::io::Error::new(
41 std::io::ErrorKind::InvalidData,
42 format!(
43 "conversation file {} is {len} bytes, over the {} MiB cap",
44 path.display(),
45 MAX_CONVERSATION_BYTES / (1024 * 1024)
46 ),
47 ));
48 }
49 fs::read_to_string(path)
50}
51
52const SCREENSHOT_ELIDED_MARKER: &str = "\n[screenshot not persisted]";
54
55fn strip_persisted_screenshots(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
67 let needs = messages
68 .iter()
69 .any(|m| m.role != MessageRole::User && m.images.is_some());
70 if !needs {
71 return None;
72 }
73 let mut out = messages.to_vec();
74 for m in out.iter_mut() {
75 if m.role != MessageRole::User && m.images.is_some() {
76 m.images = None;
77 if !m.content.ends_with(SCREENSHOT_ELIDED_MARKER) {
78 m.content.push_str(SCREENSHOT_ELIDED_MARKER);
79 }
80 }
81 }
82 Some(out)
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ConversationHistory {
88 pub id: String,
89 pub title: String,
90 pub messages: Vec<ChatMessage>,
91 pub model_name: String,
92 pub project_path: String,
93 pub created_at: DateTime<Local>,
94 pub updated_at: DateTime<Local>,
95 #[serde(default)]
97 pub compactions: Vec<crate::domain::CompactionRecord>,
98 #[serde(default)]
100 pub input_history: VecDeque<String>,
101 #[serde(default)]
106 pub git_branch: Option<String>,
107 #[serde(default)]
114 pub safety_mode: Option<crate::runtime::SafetyMode>,
115 #[serde(default)]
119 pub plan: Option<crate::domain::PlanState>,
120 #[serde(default)]
121 pub last_token_usage: Option<crate::domain::TokenUsageTotals>,
122 #[serde(default)]
123 pub cumulative_token_usage: crate::domain::TokenUsageTotals,
124 #[serde(default)]
125 pub context_usage: Option<crate::domain::ContextUsageSnapshot>,
126 #[serde(default)]
131 pub forked_from: Option<String>,
132 #[serde(default)]
133 pub parent_session: Option<String>,
134 #[serde(default)]
135 pub cli_version: Option<String>,
136 #[serde(default)]
137 pub git_sha: Option<String>,
138 #[serde(default)]
142 pub tasks: crate::domain::TaskStore,
143}
144
145pub fn detect_git_branch(dir: &Path) -> Option<String> {
150 let output = std::process::Command::new("git")
151 .args(["rev-parse", "--abbrev-ref", "HEAD"])
152 .current_dir(dir)
153 .output()
154 .ok()?;
155 if !output.status.success() {
156 return None;
157 }
158 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
159 (!branch.is_empty() && branch != "HEAD").then_some(branch)
161}
162
163pub fn detect_git_sha(dir: &Path) -> Option<String> {
167 let output = std::process::Command::new("git")
168 .args(["rev-parse", "--short", "HEAD"])
169 .current_dir(dir)
170 .output()
171 .ok()?;
172 if !output.status.success() {
173 return None;
174 }
175 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
176 (!sha.is_empty()).then_some(sha)
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ConversationMeta {
185 pub id: String,
186 pub title: String,
187 pub updated_at: DateTime<Local>,
188 #[serde(default)]
189 pub git_branch: Option<String>,
190 #[serde(default)]
191 pub message_count: usize,
192 #[serde(default)]
193 pub forked_from: Option<String>,
194}
195
196impl ConversationMeta {
197 fn from_history(h: &ConversationHistory) -> Self {
198 Self {
199 id: h.id.clone(),
200 title: h.title.clone(),
201 updated_at: h.updated_at,
202 git_branch: h.git_branch.clone(),
203 message_count: h.messages.len(),
204 forked_from: h.forked_from.clone(),
205 }
206 }
207}
208
209impl ConversationHistory {
210 pub fn new(project_path: String, model_name: String, now: DateTime<Local>) -> Self {
218 let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
220 Self {
221 id: id.clone(),
222 title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
223 messages: Vec::new(),
224 model_name,
225 project_path,
226 created_at: now,
227 updated_at: now,
228 compactions: Vec::new(),
229 input_history: VecDeque::new(),
230 git_branch: None,
233 safety_mode: None,
235 plan: None,
236 last_token_usage: None,
237 cumulative_token_usage: crate::domain::TokenUsageTotals::default(),
238 context_usage: None,
239 forked_from: None,
241 parent_session: None,
242 cli_version: None,
243 git_sha: None,
244 tasks: crate::domain::TaskStore::default(),
245 }
246 }
247
248 pub fn add_messages(&mut self, messages: &[ChatMessage], now: DateTime<Local>) {
252 self.messages.extend_from_slice(messages);
253 self.updated_at = now;
254 self.update_title();
255 }
256
257 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>, now: DateTime<Local>) {
263 self.messages = messages;
264 self.updated_at = now;
265 }
266
267 pub fn add_compaction(
270 &mut self,
271 record: crate::domain::CompactionRecord,
272 now: DateTime<Local>,
273 ) {
274 self.compactions.push(record);
275 self.updated_at = now;
276 }
277
278 pub fn add_to_input_history(&mut self, input: String) {
280 if input.trim().is_empty() {
282 return;
283 }
284
285 if let Some(last) = self.input_history.back()
287 && last == &input
288 {
289 return;
290 }
291
292 if self.input_history.len() >= 100 {
294 self.input_history.pop_front(); }
296
297 self.input_history.push_back(input);
298 }
299
300 fn update_title(&mut self) {
303 if !self.title.starts_with("Session ") {
305 return;
306 }
307 if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
308 let preview = if first_user_msg.content.len() > 60 {
309 let end = first_user_msg.content.floor_char_boundary(60);
310 format!("{}...", &first_user_msg.content[..end])
311 } else {
312 first_user_msg.content.clone()
313 };
314 self.title = preview;
315 }
316 }
317
318 pub fn summary(&self) -> String {
320 let message_count = self.messages.len();
321 let duration = self.updated_at.signed_duration_since(self.created_at);
322 let hours = duration.num_hours();
323 let minutes = duration.num_minutes() % 60;
324
325 format!(
326 "{} | {} messages | {}h {}m | {}",
327 self.updated_at.format("%Y-%m-%d %H:%M"),
328 message_count,
329 hours,
330 minutes,
331 self.title
332 )
333 }
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342struct FileStamp {
343 mtime: SystemTime,
344 len: u64,
345}
346
347fn file_stamp(path: &Path) -> Option<FileStamp> {
349 let meta = fs::metadata(path).ok()?;
350 let mtime = meta.modified().ok()?;
351 Some(FileStamp {
352 mtime,
353 len: meta.len(),
354 })
355}
356
357static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
360
361#[derive(Clone)]
363pub struct ConversationManager {
364 project_dir: PathBuf,
367 conversations_dir: PathBuf,
368 compactions_dir: PathBuf,
369 seen: Arc<Mutex<HashMap<String, FileStamp>>>,
376}
377
378impl ConversationManager {
379 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
381 let mermaid_dir = project_dir.as_ref().join(".mermaid");
382 let conversations_dir = mermaid_dir.join("conversations");
383 let compactions_dir = mermaid_dir.join("compactions");
384
385 fs::create_dir_all(&conversations_dir)?;
387 fs::create_dir_all(&compactions_dir)?;
388
389 Ok(Self {
390 project_dir: project_dir.as_ref().to_path_buf(),
391 conversations_dir,
392 compactions_dir,
393 seen: Arc::new(Mutex::new(HashMap::new())),
394 })
395 }
396
397 fn record_stamp(&self, id: &str, path: &Path) {
402 if let Some(stamp) = file_stamp(path) {
403 self.seen
404 .lock()
405 .unwrap_or_else(|e| e.into_inner())
406 .insert(id.to_string(), stamp);
407 }
408 }
409
410 fn conflict_sibling_path(&self, id: &str) -> PathBuf {
415 let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
416 self.conversations_dir
417 .join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
418 }
419
420 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
422 validate_conversation_id(&conversation.id)?;
426
427 if conversation.messages.is_empty() {
432 return Ok(());
433 }
434
435 let filename = format!("{}.json", conversation.id);
436 let path = self.conversations_dir.join(filename);
437
438 let mut value = match strip_persisted_screenshots(&conversation.messages) {
443 Some(sanitized) => {
444 let mut stripped = conversation.clone();
445 stripped.messages = sanitized;
446 serde_json::to_value(&stripped)?
447 },
448 None => serde_json::to_value(conversation)?,
449 };
450 crate::utils::redact_json(&mut value);
451 let json = serde_json::to_string_pretty(&value)?;
452
453 let baseline = self
461 .seen
462 .lock()
463 .unwrap_or_else(|e| e.into_inner())
464 .get(&conversation.id)
465 .copied();
466 if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
467 && current != base
468 {
469 let sibling = self.conflict_sibling_path(&conversation.id);
470 crate::runtime::write_atomic_with_mode(&sibling, json.as_bytes(), 0o600)?;
472 tracing::warn!(
473 id = %conversation.id,
474 main = %path.display(),
475 conflict = %sibling.display(),
476 "conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
477 );
478 return Ok(());
479 }
480
481 crate::runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
485 self.record_stamp(&conversation.id, &path);
488
489 let meta = ConversationMeta::from_history(conversation);
493 if let Ok(meta_json) = serde_json::to_string(&meta) {
494 let meta_path = self
495 .conversations_dir
496 .join(format!("{}.meta", conversation.id));
497 let _ = crate::runtime::write_atomic_with_mode(&meta_path, meta_json.as_bytes(), 0o600);
498 }
499
500 Ok(())
501 }
502
503 pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
507 validate_conversation_id(&archive.conversation_id)?;
510 anyhow::ensure!(
511 !archive.id.is_empty()
512 && !archive.id.contains(['/', '\\'])
513 && !archive.id.contains(".."),
514 "invalid compaction archive id: {:?}",
515 archive.id
516 );
517 let dir = self.compactions_dir.join(&archive.conversation_id);
518 fs::create_dir_all(&dir)?;
519 let path = dir.join(format!("{}.json", archive.id));
520 let mut value = match strip_persisted_screenshots(&archive.messages) {
524 Some(sanitized) => {
525 let mut stripped = archive.clone();
526 stripped.messages = sanitized;
527 serde_json::to_value(&stripped)?
528 },
529 None => serde_json::to_value(archive)?,
530 };
531 crate::utils::redact_json(&mut value);
532 let json = serde_json::to_string_pretty(&value)?;
533 crate::runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
536 Ok(path)
537 }
538
539 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
541 validate_conversation_id(id)?;
542 let filename = format!("{}.json", id);
543 let path = self.conversations_dir.join(filename);
544
545 let json = read_conversation_capped(&path)?;
546 let conversation: ConversationHistory = serde_json::from_str(&json)?;
547 validate_conversation_id(&conversation.id)?;
550
551 self.record_stamp(&conversation.id, &path);
554
555 Ok(conversation)
556 }
557
558 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
566 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
567 return Ok(None);
568 };
569
570 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
571 .flatten()
572 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
573 .filter_map(|e| {
574 let mtime = e.metadata().ok()?.modified().ok()?;
575 Some((mtime, e.path()))
576 })
577 .collect();
578 candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
579
580 for (_, path) in candidates {
581 let Ok(json) = read_conversation_capped(&path) else {
582 tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
583 continue;
584 };
585 let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
586 tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
587 continue;
588 };
589 if validate_conversation_id(&conv.id).is_err() {
592 tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
593 continue;
594 }
595 if conv.messages.is_empty() {
598 continue;
599 }
600 self.record_stamp(&conv.id, &path);
603 return Ok(Some(conv));
604 }
605 Ok(None)
606 }
607
608 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
610 let mut conversations = Vec::new();
611
612 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
614 for entry in entries.flatten() {
615 if let Some(ext) = entry.path().extension()
616 && ext == "json"
617 && let Ok(json) = read_conversation_capped(&entry.path())
618 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
619 && !conv.messages.is_empty()
622 {
623 conversations.push(conv);
624 }
625 }
626 }
627
628 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
630
631 Ok(conversations)
632 }
633
634 pub fn list_conversation_metas(&self) -> Result<Vec<ConversationMeta>> {
639 let mut metas = Vec::new();
640 let mut seen = std::collections::HashSet::new();
641 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
642 return Ok(metas);
643 };
644 let paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
645 for path in &paths {
647 if path.extension().is_some_and(|e| e == "meta")
648 && let Ok(raw) = fs::read_to_string(path)
649 && let Ok(meta) = serde_json::from_str::<ConversationMeta>(&raw)
650 && meta.message_count > 0
651 {
652 seen.insert(meta.id.clone());
653 metas.push(meta);
654 }
655 }
656 for path in &paths {
658 if path.extension().is_some_and(|e| e == "json")
659 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
660 && !seen.contains(stem)
661 && let Ok(json) = read_conversation_capped(path)
662 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
663 && !conv.messages.is_empty()
664 {
665 metas.push(ConversationMeta::from_history(&conv));
666 }
667 }
668 metas.sort_by_key(|m| std::cmp::Reverse(m.updated_at));
669 Ok(metas)
670 }
671
672 pub fn delete_conversation(&self, id: &str) -> Result<()> {
674 validate_conversation_id(id)?;
675 let path = self.conversations_dir.join(format!("{}.json", id));
676 if path.exists() {
677 fs::remove_file(path)?;
678 }
679 let _ = fs::remove_file(self.conversations_dir.join(format!("{}.meta", id)));
681 let _ = crate::session::scratchpad::remove(&self.project_dir, id);
685
686 Ok(())
687 }
688
689 pub fn conversations_dir(&self) -> &Path {
691 &self.conversations_dir
692 }
693
694 pub fn compactions_dir(&self) -> &Path {
695 &self.compactions_dir
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702
703 fn touched(project: &str) -> ConversationHistory {
706 let mut c = ConversationHistory::new(project.into(), "m".into(), Local::now());
707 c.add_messages(&[ChatMessage::user("hi")], Local::now());
708 c
709 }
710
711 #[test]
712 fn legacy_conversation_json_without_git_branch_deserializes() {
713 let json = r#"{
717 "id": "20260101_120000_001",
718 "title": "Legacy session",
719 "messages": [],
720 "model_name": "ollama/test",
721 "project_path": "/tmp/proj",
722 "created_at": "2026-01-01T12:00:00-05:00",
723 "updated_at": "2026-01-01T12:00:00-05:00",
724 "total_tokens": null
725 }"#;
726 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
727 assert!(conv.git_branch.is_none());
728 assert_eq!(conv.title, "Legacy session");
729 let mut fresh =
731 ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
732 fresh.git_branch = Some("feature/x".to_string());
733 let round: ConversationHistory =
734 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
735 assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
736 }
737
738 #[test]
739 fn legacy_json_defaults_session_state_fields() {
740 let json = r#"{
744 "id": "20260101_120000_002",
745 "title": "Old",
746 "messages": [],
747 "model_name": "m",
748 "project_path": "/tmp/proj",
749 "created_at": "2026-01-01T12:00:00-05:00",
750 "updated_at": "2026-01-01T12:00:00-05:00",
751 "total_tokens": null
752 }"#;
753 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
754 assert_eq!(conv.safety_mode, None);
755 assert_eq!(
756 conv.cumulative_token_usage,
757 crate::domain::TokenUsageTotals::default()
758 );
759 assert!(conv.last_token_usage.is_none());
760 assert!(conv.context_usage.is_none());
761 assert!(conv.tasks.tasks.is_empty());
762 assert_eq!(conv.tasks.next_id, 0);
763 }
764
765 #[test]
766 fn tasks_round_trip_through_conversation_json() {
767 let mut fresh = touched("/tmp/proj");
768 fresh.tasks.create(
769 vec![crate::domain::TaskSpec {
770 subject: "wire broker".into(),
771 active_form: "wiring broker".into(),
772 description: Some("through ExecContext".into()),
773 in_progress: true,
774 }],
775 crate::domain::TaskOrigin::Model,
776 crate::domain::Stamp {
777 now_epoch: 42,
778 run_tokens: 7,
779 },
780 );
781 let round: ConversationHistory =
782 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
783 assert_eq!(round.tasks, fresh.tasks);
784 assert_eq!(round.tasks.tasks[0].started_at, Some(42));
785 }
786
787 #[test]
788 fn session_state_round_trips_through_json() {
789 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
790 conv.safety_mode = Some(crate::runtime::SafetyMode::FullAccess);
791 conv.cumulative_token_usage = crate::domain::TokenUsageTotals {
792 prompt_tokens: 777,
793 ..Default::default()
794 };
795 let round: ConversationHistory =
796 serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
797 assert_eq!(
798 round.safety_mode,
799 Some(crate::runtime::SafetyMode::FullAccess)
800 );
801 assert_eq!(round.cumulative_token_usage.total_tokens(), 777);
802 }
803
804 #[test]
805 fn validate_conversation_id_rejects_traversal() {
806 assert!(validate_conversation_id("20260101_120000_001").is_ok());
807 assert!(validate_conversation_id("../secret").is_err());
808 assert!(validate_conversation_id("..\\secret").is_err());
809 assert!(validate_conversation_id("/etc/passwd").is_err());
810 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
813
814 #[test]
815 fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
816 let messages = vec![
817 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
818 ChatMessage::assistant("here is the screen")
819 .with_images(vec!["SCREENSHOT_B64".to_string()]),
820 ChatMessage::assistant("no image here"),
821 ];
822 let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
823 assert_eq!(
825 sanitized[0].images.as_deref(),
826 Some(["USER_PASTED_B64".to_string()].as_slice())
827 );
828 assert!(sanitized[1].images.is_none());
830 assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
831 assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
833 }
834
835 #[test]
836 fn strip_persisted_screenshots_is_none_without_assistant_images() {
837 let messages = vec![
838 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
839 ChatMessage::assistant("no images"),
840 ];
841 assert!(strip_persisted_screenshots(&messages).is_none());
842 }
843
844 #[test]
845 fn saved_conversation_json_has_no_screenshot_bytes() {
846 let dir = std::env::temp_dir().join("mermaid_strip_test");
847 let _ = fs::create_dir_all(&dir);
848 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
849 conv.messages = vec![
850 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
851 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
852 ];
853 let store = ConversationManager {
854 project_dir: dir.clone(),
855 conversations_dir: dir.clone(),
856 compactions_dir: dir.clone(),
857 seen: Arc::new(Mutex::new(HashMap::new())),
858 };
859 store.save_conversation(&conv).expect("save");
860 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
861 assert!(
862 !raw.contains("SHOTBYTES"),
863 "screenshot leaked to disk: {raw}"
864 );
865 assert!(raw.contains("USERIMG"), "user image should persist");
866 assert_eq!(
868 conv.messages[1].images.as_deref(),
869 Some(["SHOTBYTES".to_string()].as_slice())
870 );
871 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
872 }
873
874 #[test]
875 fn saved_conversation_redacts_secrets_and_is_owner_only() {
876 let dir = std::env::temp_dir().join(format!("mermaid_conv_redact_{}", std::process::id()));
877 let _ = fs::remove_dir_all(&dir);
878 let _ = fs::create_dir_all(&dir);
879 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
880 conv.messages = vec![
882 ChatMessage::user("read .env"),
883 ChatMessage::assistant("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
884 ];
885 let store = ConversationManager {
886 project_dir: dir.clone(),
887 conversations_dir: dir.clone(),
888 compactions_dir: dir.clone(),
889 seen: Arc::new(Mutex::new(HashMap::new())),
890 };
891 store.save_conversation(&conv).expect("save");
892 let path = dir.join(format!("{}.json", conv.id));
893 let raw = fs::read_to_string(&path).expect("read");
894 assert!(
895 !raw.contains("sk-abcdefghijklmnop1234"),
896 "secret leaked to the conversation store: {raw}"
897 );
898 assert!(
899 raw.contains("[REDACTED]"),
900 "expected redaction marker: {raw}"
901 );
902 #[cfg(unix)]
903 {
904 use std::os::unix::fs::PermissionsExt;
905 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
906 assert_eq!(
907 mode, 0o600,
908 "conversation file must be owner-only, got {mode:o}"
909 );
910 }
911 assert!(conv.messages[1].content.contains("sk-abcdefghijklmnop1234"));
913 let _ = fs::remove_dir_all(&dir);
914 }
915
916 #[test]
917 fn test_new_conversation_has_session_title() {
918 let conv =
919 ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
920 assert!(conv.title.starts_with("Session "));
921 assert_eq!(conv.model_name, "test-model");
922 assert_eq!(conv.project_path, "/tmp/project");
923 assert!(conv.messages.is_empty());
924 }
925
926 #[test]
927 fn test_title_updates_from_first_user_message() {
928 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
929 conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
930 assert_eq!(conv.title, "Fix the login bug");
931 }
932
933 #[test]
934 fn test_title_truncated_at_60_chars() {
935 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
936 let long_msg = "a".repeat(100);
937 conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
938 assert!(conv.title.ends_with("..."));
939 assert!(conv.title.len() <= 64); }
941
942 #[test]
943 fn test_title_set_only_once() {
944 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
945 conv.add_messages(&[ChatMessage::user("First message")], Local::now());
946 conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
947 assert_eq!(conv.title, "First message");
948 }
949
950 #[test]
951 fn test_input_history_deduplication() {
952 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
953 conv.add_to_input_history("hello".into());
954 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
956 assert_eq!(conv.input_history.len(), 2);
957 }
958
959 #[test]
960 fn test_input_history_skips_empty() {
961 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
962 conv.add_to_input_history("".into());
963 conv.add_to_input_history(" ".into());
964 assert_eq!(conv.input_history.len(), 0);
965 }
966
967 #[test]
968 fn test_input_history_capped_at_100() {
969 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
970 for i in 0..110 {
971 conv.add_to_input_history(format!("msg{}", i));
972 }
973 assert_eq!(conv.input_history.len(), 100);
974 assert_eq!(conv.input_history.front().unwrap(), "msg10");
975 }
976
977 #[test]
978 fn sidecar_powers_metadata_listing() {
979 let dir = std::env::temp_dir().join("mermaid_test_meta_sidecar");
980 let _ = fs::remove_dir_all(&dir);
981 let manager = ConversationManager::new(&dir).unwrap();
982 let mut conv = ConversationHistory::new("/tmp/proj".into(), "model".into(), Local::now());
983 conv.title = "My session".into();
984 conv.add_messages(
985 &[ChatMessage::user("hi"), ChatMessage::user("there")],
986 Local::now(),
987 );
988 manager.save_conversation(&conv).unwrap();
989
990 assert!(
991 manager
992 .conversations_dir()
993 .join(format!("{}.meta", conv.id))
994 .exists()
995 );
996 let metas = manager.list_conversation_metas().unwrap();
997 assert_eq!(metas.len(), 1);
998 assert_eq!(metas[0].id, conv.id);
999 assert_eq!(metas[0].title, "My session");
1000 assert_eq!(metas[0].message_count, 2);
1001
1002 manager.delete_conversation(&conv.id).unwrap();
1004 assert!(
1005 !manager
1006 .conversations_dir()
1007 .join(format!("{}.meta", conv.id))
1008 .exists()
1009 );
1010 assert!(manager.list_conversation_metas().unwrap().is_empty());
1011 let _ = fs::remove_dir_all(&dir);
1012 }
1013
1014 #[test]
1015 fn metadata_listing_falls_back_to_full_parse_without_sidecar() {
1016 let dir = std::env::temp_dir().join("mermaid_test_meta_fallback");
1017 let _ = fs::remove_dir_all(&dir);
1018 let manager = ConversationManager::new(&dir).unwrap();
1019 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1020 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1021 manager.save_conversation(&conv).unwrap();
1022 fs::remove_file(
1024 manager
1025 .conversations_dir()
1026 .join(format!("{}.meta", conv.id)),
1027 )
1028 .unwrap();
1029 let metas = manager.list_conversation_metas().unwrap();
1030 assert_eq!(metas.len(), 1, "falls back to parsing the .json");
1031 assert_eq!(metas[0].message_count, 1);
1032 let _ = fs::remove_dir_all(&dir);
1033 }
1034
1035 #[test]
1036 fn lineage_fields_default_on_old_sessions() {
1037 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}"#;
1039 let conv: ConversationHistory = serde_json::from_str(json).unwrap();
1040 assert!(conv.git_sha.is_none());
1041 assert!(conv.cli_version.is_none());
1042 assert!(conv.forked_from.is_none());
1043 assert!(conv.parent_session.is_none());
1044 }
1045
1046 #[test]
1047 fn test_save_load_roundtrip() {
1048 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
1049 let _ = fs::remove_dir_all(&dir);
1050 let manager = ConversationManager::new(&dir).unwrap();
1051
1052 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1053 conv.add_messages(&[ChatMessage::user("test message")], Local::now());
1054 conv.add_to_input_history("test message".into());
1055
1056 manager.save_conversation(&conv).unwrap();
1057 let loaded = manager.load_conversation(&conv.id).unwrap();
1058
1059 assert_eq!(loaded.id, conv.id);
1060 assert_eq!(loaded.title, conv.title);
1061 assert_eq!(loaded.messages.len(), 1);
1062 assert_eq!(loaded.input_history.len(), 1);
1063
1064 let _ = fs::remove_dir_all(&dir);
1065 }
1066
1067 #[test]
1068 fn test_list_conversations_ordered_by_updated_at() {
1069 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
1070 let _ = fs::remove_dir_all(&dir);
1071 let manager = ConversationManager::new(&dir).unwrap();
1072
1073 let conv1 = touched("/tmp");
1074 std::thread::sleep(std::time::Duration::from_millis(10));
1075 let conv2 = touched("/tmp");
1076
1077 manager.save_conversation(&conv1).unwrap();
1078 manager.save_conversation(&conv2).unwrap();
1079
1080 let list = manager.list_conversations().unwrap();
1081 assert_eq!(list.len(), 2);
1082 assert_eq!(list[0].id, conv2.id);
1084 assert_eq!(list[1].id, conv1.id);
1085
1086 let _ = fs::remove_dir_all(&dir);
1087 }
1088
1089 #[test]
1090 fn test_load_last_conversation() {
1091 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
1092 let _ = fs::remove_dir_all(&dir);
1093 let manager = ConversationManager::new(&dir).unwrap();
1094
1095 assert!(manager.load_last_conversation().unwrap().is_none());
1096
1097 let conv = touched("/tmp");
1098 manager.save_conversation(&conv).unwrap();
1099
1100 let last = manager.load_last_conversation().unwrap().unwrap();
1101 assert_eq!(last.id, conv.id);
1102
1103 let _ = fs::remove_dir_all(&dir);
1104 }
1105
1106 #[test]
1107 fn test_load_last_conversation_picks_newest_by_mtime() {
1108 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
1113 let _ = fs::remove_dir_all(&dir);
1114 let manager = ConversationManager::new(&dir).unwrap();
1115
1116 let conv1 = touched("/tmp");
1117 manager.save_conversation(&conv1).unwrap();
1118 std::thread::sleep(std::time::Duration::from_millis(10));
1119
1120 let conv2 = touched("/tmp");
1121 manager.save_conversation(&conv2).unwrap();
1122 std::thread::sleep(std::time::Duration::from_millis(10));
1123
1124 let conv3 = touched("/tmp");
1125 manager.save_conversation(&conv3).unwrap();
1126
1127 let last = manager.load_last_conversation().unwrap().unwrap();
1128 assert_eq!(
1129 last.id, conv3.id,
1130 "should return the most-recently-written file"
1131 );
1132
1133 let _ = fs::remove_dir_all(&dir);
1134 }
1135
1136 #[test]
1137 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
1138 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
1139 let _ = fs::remove_dir_all(&dir);
1140 let manager = ConversationManager::new(&dir).unwrap();
1141
1142 let good = touched("/tmp");
1143 manager.save_conversation(&good).unwrap();
1144 std::thread::sleep(std::time::Duration::from_millis(10));
1145
1146 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
1149 fs::write(&corrupt, b"{ not valid json").unwrap();
1150
1151 let last = manager.load_last_conversation().unwrap().unwrap();
1152 assert_eq!(
1153 last.id, good.id,
1154 "must fall back to the newest VALID conversation"
1155 );
1156 let _ = fs::remove_dir_all(&dir);
1157 }
1158
1159 #[test]
1160 fn load_last_conversation_none_when_only_corrupt() {
1161 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
1162 let _ = fs::remove_dir_all(&dir);
1163 let manager = ConversationManager::new(&dir).unwrap();
1164 fs::write(
1165 manager.conversations_dir().join("20991231_235959_998.json"),
1166 b"nope",
1167 )
1168 .unwrap();
1169 assert!(manager.load_last_conversation().unwrap().is_none());
1170 let _ = fs::remove_dir_all(&dir);
1171 }
1172
1173 #[test]
1174 fn load_conversation_tolerates_unknown_message_role() {
1175 let dir =
1180 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
1181 let _ = fs::remove_dir_all(&dir);
1182 let manager = ConversationManager::new(&dir).unwrap();
1183
1184 let id = "20260101_120000_001";
1185 let json = format!(
1186 r#"{{
1187 "id": "{id}",
1188 "title": "skew",
1189 "messages": [
1190 {{
1191 "role": "Developer",
1192 "content": "from a newer build",
1193 "timestamp": "2026-01-01T12:00:00-04:00"
1194 }}
1195 ],
1196 "model_name": "m",
1197 "project_path": "/tmp",
1198 "created_at": "2026-01-01T12:00:00-04:00",
1199 "updated_at": "2026-01-01T12:00:00-04:00",
1200 "total_tokens": null
1201 }}"#
1202 );
1203 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
1204
1205 let loaded = manager
1206 .load_conversation(id)
1207 .expect("must load despite an unknown role");
1208 assert_eq!(loaded.messages.len(), 1);
1209 assert_eq!(
1210 loaded.messages[0].role,
1211 MessageRole::System,
1212 "an unknown role becomes a neutral System message"
1213 );
1214
1215 let last = manager
1217 .load_last_conversation()
1218 .unwrap()
1219 .expect("the newest session must load");
1220 assert_eq!(last.id, id);
1221
1222 let _ = fs::remove_dir_all(&dir);
1223 }
1224
1225 #[test]
1226 fn test_delete_conversation() {
1227 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
1228 let _ = fs::remove_dir_all(&dir);
1229 let manager = ConversationManager::new(&dir).unwrap();
1230
1231 let conv = touched("/tmp");
1232 manager.save_conversation(&conv).unwrap();
1233 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1234
1235 manager.delete_conversation(&conv.id).unwrap();
1236 assert_eq!(manager.list_conversations().unwrap().len(), 0);
1237
1238 let _ = fs::remove_dir_all(&dir);
1239 }
1240
1241 #[test]
1242 fn empty_session_is_not_saved() {
1243 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
1244 let _ = fs::remove_dir_all(&dir);
1245 let manager = ConversationManager::new(&dir).unwrap();
1246
1247 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1249 manager.save_conversation(&conv).unwrap();
1250 assert!(
1251 manager.list_conversations().unwrap().is_empty(),
1252 "empty session must not be listed"
1253 );
1254 assert!(
1255 manager.load_last_conversation().unwrap().is_none(),
1256 "empty session must not be --continue-able"
1257 );
1258
1259 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1261 manager.save_conversation(&conv).unwrap();
1262 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1263
1264 let _ = fs::remove_dir_all(&dir);
1265 }
1266
1267 #[test]
1268 fn resume_paths_skip_pre_existing_empty_files() {
1269 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
1272 let _ = fs::remove_dir_all(&dir);
1273 let manager = ConversationManager::new(&dir).unwrap();
1274
1275 let real = touched("/tmp");
1276 manager.save_conversation(&real).unwrap();
1277 std::thread::sleep(std::time::Duration::from_millis(10));
1279 let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1280 let path = manager
1281 .conversations_dir()
1282 .join(format!("{}.json", empty.id));
1283 fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
1284
1285 let list = manager.list_conversations().unwrap();
1286 assert_eq!(list.len(), 1, "the empty file must not be listed");
1287 assert_eq!(list[0].id, real.id);
1288 assert_eq!(
1289 manager.load_last_conversation().unwrap().unwrap().id,
1290 real.id,
1291 "--continue must skip the newer empty file"
1292 );
1293
1294 let _ = fs::remove_dir_all(&dir);
1295 }
1296
1297 #[test]
1298 fn read_conversation_capped_refuses_oversized_file() {
1299 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
1302 let _ = fs::remove_dir_all(&dir);
1303 fs::create_dir_all(&dir).unwrap();
1304
1305 let small = dir.join("small.json");
1306 fs::write(&small, b"{}").unwrap();
1307 assert!(read_conversation_capped(&small).is_ok());
1308
1309 let big = dir.join("big.json");
1310 let f = fs::File::create(&big).unwrap();
1311 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
1312 assert!(
1313 read_conversation_capped(&big).is_err(),
1314 "a file over the cap must be refused, not slurped into memory"
1315 );
1316
1317 let _ = fs::remove_dir_all(&dir);
1318 }
1319
1320 #[test]
1321 fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
1322 let dir =
1328 std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
1329 let _ = fs::remove_dir_all(&dir);
1330 let manager = ConversationManager::new(&dir).unwrap();
1331
1332 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1333 conv.add_messages(&[ChatMessage::user("ours")], Local::now());
1334 manager.save_conversation(&conv).unwrap();
1335 let main = manager
1336 .conversations_dir()
1337 .join(format!("{}.json", conv.id));
1338
1339 let other = ConversationManager::new(&dir).unwrap();
1342 let mut their_conv = other.load_conversation(&conv.id).unwrap();
1343 their_conv.add_messages(
1344 &[ChatMessage::user("theirs - extra content here")],
1345 Local::now(),
1346 );
1347 other.save_conversation(&their_conv).unwrap();
1348
1349 manager.save_conversation(&conv).unwrap();
1352 let on_disk: ConversationHistory =
1353 serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
1354 assert_eq!(
1355 on_disk.messages.len(),
1356 2,
1357 "the concurrent writer's file must be left intact"
1358 );
1359
1360 let mut conflicts = fs::read_dir(manager.conversations_dir())
1362 .unwrap()
1363 .flatten()
1364 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
1365 .map(|e| e.path())
1366 .collect::<Vec<_>>();
1367 assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
1368 let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
1369 assert!(
1370 sibling.contains("ours") && !sibling.contains("theirs"),
1371 "the .conflict sibling holds OUR copy, not the concurrent writer's"
1372 );
1373
1374 let listed = manager.list_conversations().unwrap();
1377 assert_eq!(
1378 listed.len(),
1379 1,
1380 ".conflict sibling must not appear as a conversation"
1381 );
1382 assert_eq!(listed[0].id, conv.id);
1383
1384 let _ = fs::remove_dir_all(&dir);
1385 }
1386
1387 #[test]
1388 fn save_conversation_repeated_self_saves_do_not_conflict() {
1389 let dir =
1392 std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
1393 let _ = fs::remove_dir_all(&dir);
1394 let manager = ConversationManager::new(&dir).unwrap();
1395
1396 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1397 conv.add_messages(&[ChatMessage::user("first")], Local::now());
1398 manager.save_conversation(&conv).unwrap();
1399 conv.add_messages(&[ChatMessage::user("second")], Local::now());
1400 manager.save_conversation(&conv).unwrap();
1401
1402 let conflicts = fs::read_dir(manager.conversations_dir())
1403 .unwrap()
1404 .flatten()
1405 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
1406 .count();
1407 assert_eq!(
1408 conflicts, 0,
1409 "our own repeated saves must not be flagged as conflicts"
1410 );
1411 let loaded = manager.load_conversation(&conv.id).unwrap();
1412 assert_eq!(loaded.messages.len(), 2, "latest save must win for us");
1413
1414 let _ = fs::remove_dir_all(&dir);
1415 }
1416}