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 messages: Vec<ChatMessage>,
97 #[serde(skip)]
101 revision: u64,
102 pub model_name: String,
103 pub project_path: String,
104 pub created_at: DateTime<Local>,
105 pub updated_at: DateTime<Local>,
106 #[serde(default)]
108 pub compactions: Vec<crate::domain::CompactionRecord>,
109 #[serde(default)]
111 pub input_history: VecDeque<String>,
112 #[serde(default)]
117 pub git_branch: Option<String>,
118 #[serde(default)]
125 pub safety_mode: Option<crate::runtime::SafetyMode>,
126 #[serde(default)]
130 pub plan: Option<crate::domain::PlanState>,
131 #[serde(default)]
138 pub advertised_context: Option<crate::domain::AdvertisedContext>,
139 #[serde(default)]
140 pub last_token_usage: Option<crate::domain::TokenUsageTotals>,
141 #[serde(default)]
142 pub cumulative_token_usage: crate::domain::TokenUsageTotals,
143 #[serde(default)]
144 pub context_usage: Option<crate::domain::ContextUsageSnapshot>,
145 #[serde(default)]
150 pub forked_from: Option<String>,
151 #[serde(default)]
152 pub parent_session: Option<String>,
153 #[serde(default)]
154 pub cli_version: Option<String>,
155 #[serde(default)]
156 pub git_sha: Option<String>,
157 #[serde(default)]
161 pub tasks: crate::domain::TaskStore,
162}
163
164pub fn detect_git_branch(dir: &Path) -> Option<String> {
169 let output = std::process::Command::new("git")
170 .args(["rev-parse", "--abbrev-ref", "HEAD"])
171 .current_dir(dir)
172 .output()
173 .ok()?;
174 if !output.status.success() {
175 return None;
176 }
177 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
178 (!branch.is_empty() && branch != "HEAD").then_some(branch)
180}
181
182pub fn detect_git_sha(dir: &Path) -> Option<String> {
186 let output = std::process::Command::new("git")
187 .args(["rev-parse", "--short", "HEAD"])
188 .current_dir(dir)
189 .output()
190 .ok()?;
191 if !output.status.success() {
192 return None;
193 }
194 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
195 (!sha.is_empty()).then_some(sha)
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ConversationMeta {
204 pub id: String,
205 pub title: String,
206 pub updated_at: DateTime<Local>,
207 #[serde(default)]
208 pub git_branch: Option<String>,
209 #[serde(default)]
210 pub message_count: usize,
211 #[serde(default)]
212 pub forked_from: Option<String>,
213}
214
215impl ConversationMeta {
216 fn from_history(h: &ConversationHistory) -> Self {
217 Self {
218 id: h.id.clone(),
219 title: h.title.clone(),
220 updated_at: h.updated_at,
221 git_branch: h.git_branch.clone(),
222 message_count: h.messages.len(),
223 forked_from: h.forked_from.clone(),
224 }
225 }
226}
227
228impl ConversationHistory {
229 pub fn messages(&self) -> &[ChatMessage] {
231 &self.messages
232 }
233
234 pub fn messages_mut(&mut self) -> &mut Vec<ChatMessage> {
242 self.revision = self.revision.wrapping_add(1);
243 &mut self.messages
244 }
245
246 pub fn set_messages(&mut self, messages: Vec<ChatMessage>) {
248 self.revision = self.revision.wrapping_add(1);
249 self.messages = messages;
250 }
251
252 pub fn revision(&self) -> u64 {
255 self.revision
256 }
257
258 pub fn new(project_path: String, model_name: String, now: DateTime<Local>) -> Self {
266 let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
268 Self {
269 id: id.clone(),
270 title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
271 messages: Vec::new(),
272 revision: 0,
273 model_name,
274 project_path,
275 created_at: now,
276 updated_at: now,
277 compactions: Vec::new(),
278 input_history: VecDeque::new(),
279 git_branch: None,
282 safety_mode: None,
284 plan: None,
285 advertised_context: None,
287 last_token_usage: None,
288 cumulative_token_usage: crate::domain::TokenUsageTotals::default(),
289 context_usage: None,
290 forked_from: None,
292 parent_session: None,
293 cli_version: None,
294 git_sha: None,
295 tasks: crate::domain::TaskStore::default(),
296 }
297 }
298
299 pub fn add_messages(&mut self, messages: &[ChatMessage], now: DateTime<Local>) {
303 self.messages.extend_from_slice(messages);
304 self.updated_at = now;
305 self.update_title();
306 }
307
308 pub fn replace_messages(&mut self, messages: Vec<ChatMessage>, now: DateTime<Local>) {
314 self.messages = messages;
315 self.updated_at = now;
316 }
317
318 pub fn add_compaction(
321 &mut self,
322 record: crate::domain::CompactionRecord,
323 now: DateTime<Local>,
324 ) {
325 self.compactions.push(record);
326 self.updated_at = now;
327 }
328
329 pub fn add_to_input_history(&mut self, input: String) {
331 if input.trim().is_empty() {
333 return;
334 }
335
336 if let Some(last) = self.input_history.back()
338 && last == &input
339 {
340 return;
341 }
342
343 if self.input_history.len() >= 100 {
345 self.input_history.pop_front(); }
347
348 self.input_history.push_back(input);
349 }
350
351 fn update_title(&mut self) {
354 if !self.title.starts_with("Session ") {
356 return;
357 }
358 if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
359 let preview = if first_user_msg.content.len() > 60 {
360 let end = first_user_msg.content.floor_char_boundary(60);
361 format!("{}...", &first_user_msg.content[..end])
362 } else {
363 first_user_msg.content.clone()
364 };
365 self.title = preview;
366 }
367 }
368
369 pub fn summary(&self) -> String {
371 let message_count = self.messages.len();
372 let duration = self.updated_at.signed_duration_since(self.created_at);
373 let hours = duration.num_hours();
374 let minutes = duration.num_minutes() % 60;
375
376 format!(
377 "{} | {} messages | {}h {}m | {}",
378 self.updated_at.format("%Y-%m-%d %H:%M"),
379 message_count,
380 hours,
381 minutes,
382 self.title
383 )
384 }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393struct FileStamp {
394 mtime: SystemTime,
395 len: u64,
396}
397
398fn file_stamp(path: &Path) -> Option<FileStamp> {
400 let meta = fs::metadata(path).ok()?;
401 let mtime = meta.modified().ok()?;
402 Some(FileStamp {
403 mtime,
404 len: meta.len(),
405 })
406}
407
408static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
411
412#[derive(Clone)]
414pub struct ConversationManager {
415 project_dir: PathBuf,
418 conversations_dir: PathBuf,
419 compactions_dir: PathBuf,
420 seen: Arc<Mutex<HashMap<String, FileStamp>>>,
427}
428
429impl ConversationManager {
430 pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
432 let mermaid_dir = project_dir.as_ref().join(".mermaid");
433 let conversations_dir = mermaid_dir.join("conversations");
434 let compactions_dir = mermaid_dir.join("compactions");
435
436 fs::create_dir_all(&conversations_dir)?;
438 fs::create_dir_all(&compactions_dir)?;
439
440 Ok(Self {
441 project_dir: project_dir.as_ref().to_path_buf(),
442 conversations_dir,
443 compactions_dir,
444 seen: Arc::new(Mutex::new(HashMap::new())),
445 })
446 }
447
448 fn record_stamp(&self, id: &str, path: &Path) {
453 if let Some(stamp) = file_stamp(path) {
454 self.seen
455 .lock()
456 .unwrap_or_else(|e| e.into_inner())
457 .insert(id.to_string(), stamp);
458 }
459 }
460
461 fn conflict_sibling_path(&self, id: &str) -> PathBuf {
466 let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
467 self.conversations_dir
468 .join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
469 }
470
471 pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
473 validate_conversation_id(&conversation.id)?;
477
478 if conversation.messages.is_empty() {
483 return Ok(());
484 }
485
486 let filename = format!("{}.json", conversation.id);
487 let path = self.conversations_dir.join(filename);
488
489 let mut value = match strip_persisted_screenshots(&conversation.messages) {
494 Some(sanitized) => {
495 let mut stripped = conversation.clone();
496 stripped.messages = sanitized;
497 serde_json::to_value(&stripped)?
498 },
499 None => serde_json::to_value(conversation)?,
500 };
501 crate::utils::redact_json(&mut value);
502 let json = serde_json::to_string_pretty(&value)?;
503
504 let baseline = self
512 .seen
513 .lock()
514 .unwrap_or_else(|e| e.into_inner())
515 .get(&conversation.id)
516 .copied();
517 if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
518 && current != base
519 {
520 let sibling = self.conflict_sibling_path(&conversation.id);
521 crate::runtime::write_atomic_with_mode(&sibling, json.as_bytes(), 0o600)?;
523 tracing::warn!(
524 id = %conversation.id,
525 main = %path.display(),
526 conflict = %sibling.display(),
527 "conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
528 );
529 return Ok(());
530 }
531
532 crate::runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
536 self.record_stamp(&conversation.id, &path);
539
540 let meta = ConversationMeta::from_history(conversation);
544 if let Ok(meta_json) = serde_json::to_string(&meta) {
545 let meta_path = self
546 .conversations_dir
547 .join(format!("{}.meta", conversation.id));
548 let _ = crate::runtime::write_atomic_with_mode(&meta_path, meta_json.as_bytes(), 0o600);
549 }
550
551 Ok(())
552 }
553
554 pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
558 validate_conversation_id(&archive.conversation_id)?;
561 anyhow::ensure!(
562 !archive.id.is_empty()
563 && !archive.id.contains(['/', '\\'])
564 && !archive.id.contains(".."),
565 "invalid compaction archive id: {:?}",
566 archive.id
567 );
568 let dir = self.compactions_dir.join(&archive.conversation_id);
569 fs::create_dir_all(&dir)?;
570 let path = dir.join(format!("{}.json", archive.id));
571 let mut value = match strip_persisted_screenshots(&archive.messages) {
575 Some(sanitized) => {
576 let mut stripped = archive.clone();
577 stripped.messages = sanitized;
578 serde_json::to_value(&stripped)?
579 },
580 None => serde_json::to_value(archive)?,
581 };
582 crate::utils::redact_json(&mut value);
583 let json = serde_json::to_string_pretty(&value)?;
584 crate::runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
587 Ok(path)
588 }
589
590 pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
592 validate_conversation_id(id)?;
593 let filename = format!("{}.json", id);
594 let path = self.conversations_dir.join(filename);
595
596 let json = read_conversation_capped(&path)?;
597 let conversation: ConversationHistory = serde_json::from_str(&json)?;
598 validate_conversation_id(&conversation.id)?;
601
602 self.record_stamp(&conversation.id, &path);
605
606 Ok(conversation)
607 }
608
609 pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
617 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
618 return Ok(None);
619 };
620
621 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
622 .flatten()
623 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
624 .filter_map(|e| {
625 let mtime = e.metadata().ok()?.modified().ok()?;
626 Some((mtime, e.path()))
627 })
628 .collect();
629 candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
630
631 for (_, path) in candidates {
632 let Ok(json) = read_conversation_capped(&path) else {
633 tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
634 continue;
635 };
636 let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
637 tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
638 continue;
639 };
640 if validate_conversation_id(&conv.id).is_err() {
643 tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
644 continue;
645 }
646 if conv.messages.is_empty() {
649 continue;
650 }
651 self.record_stamp(&conv.id, &path);
654 return Ok(Some(conv));
655 }
656 Ok(None)
657 }
658
659 pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
661 let mut conversations = Vec::new();
662
663 if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
665 for entry in entries.flatten() {
666 if let Some(ext) = entry.path().extension()
667 && ext == "json"
668 && let Ok(json) = read_conversation_capped(&entry.path())
669 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
670 && !conv.messages.is_empty()
673 {
674 conversations.push(conv);
675 }
676 }
677 }
678
679 conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
681
682 Ok(conversations)
683 }
684
685 pub fn list_conversation_metas(&self) -> Result<Vec<ConversationMeta>> {
690 let mut metas = Vec::new();
691 let mut seen = std::collections::HashSet::new();
692 let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
693 return Ok(metas);
694 };
695 let paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
696 for path in &paths {
698 if path.extension().is_some_and(|e| e == "meta")
699 && let Ok(raw) = fs::read_to_string(path)
700 && let Ok(meta) = serde_json::from_str::<ConversationMeta>(&raw)
701 && meta.message_count > 0
702 {
703 seen.insert(meta.id.clone());
704 metas.push(meta);
705 }
706 }
707 for path in &paths {
709 if path.extension().is_some_and(|e| e == "json")
710 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
711 && !seen.contains(stem)
712 && let Ok(json) = read_conversation_capped(path)
713 && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
714 && !conv.messages.is_empty()
715 {
716 metas.push(ConversationMeta::from_history(&conv));
717 }
718 }
719 metas.sort_by_key(|m| std::cmp::Reverse(m.updated_at));
720 Ok(metas)
721 }
722
723 pub fn delete_conversation(&self, id: &str) -> Result<()> {
725 validate_conversation_id(id)?;
726 let path = self.conversations_dir.join(format!("{}.json", id));
727 if path.exists() {
728 fs::remove_file(path)?;
729 }
730 let _ = fs::remove_file(self.conversations_dir.join(format!("{}.meta", id)));
732 let _ = crate::session::scratchpad::remove(&self.project_dir, id);
736
737 Ok(())
738 }
739
740 pub fn conversations_dir(&self) -> &Path {
742 &self.conversations_dir
743 }
744
745 pub fn compactions_dir(&self) -> &Path {
746 &self.compactions_dir
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 fn touched(project: &str) -> ConversationHistory {
757 let mut c = ConversationHistory::new(project.into(), "m".into(), Local::now());
758 c.add_messages(&[ChatMessage::user("hi")], Local::now());
759 c
760 }
761
762 #[test]
763 fn legacy_conversation_json_without_git_branch_deserializes() {
764 let json = r#"{
768 "id": "20260101_120000_001",
769 "title": "Legacy session",
770 "messages": [],
771 "model_name": "ollama/test",
772 "project_path": "/tmp/proj",
773 "created_at": "2026-01-01T12:00:00-05:00",
774 "updated_at": "2026-01-01T12:00:00-05:00",
775 "total_tokens": null
776 }"#;
777 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
778 assert!(conv.git_branch.is_none());
779 assert_eq!(conv.title, "Legacy session");
780 let mut fresh =
782 ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
783 fresh.git_branch = Some("feature/x".to_string());
784 let round: ConversationHistory =
785 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
786 assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
787 }
788
789 #[test]
790 fn legacy_json_defaults_session_state_fields() {
791 let json = r#"{
795 "id": "20260101_120000_002",
796 "title": "Old",
797 "messages": [],
798 "model_name": "m",
799 "project_path": "/tmp/proj",
800 "created_at": "2026-01-01T12:00:00-05:00",
801 "updated_at": "2026-01-01T12:00:00-05:00",
802 "total_tokens": null
803 }"#;
804 let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
805 assert_eq!(conv.safety_mode, None);
806 assert_eq!(
807 conv.cumulative_token_usage,
808 crate::domain::TokenUsageTotals::default()
809 );
810 assert!(conv.last_token_usage.is_none());
811 assert!(conv.context_usage.is_none());
812 assert!(conv.tasks.tasks.is_empty());
813 assert_eq!(conv.tasks.next_id, 0);
814 assert!(
815 conv.advertised_context.is_none(),
816 "pre-field saves load a None baseline (silent seed)"
817 );
818 }
819
820 #[test]
821 fn advertised_context_round_trips_through_conversation_json() {
822 let mut fresh = touched("/tmp/proj");
823 fresh.advertised_context = Some(crate::domain::AdvertisedContext {
824 plan_path: Some(std::path::PathBuf::from("/tmp/proj/.mermaid/plans/x.md")),
825 safety_mode: crate::runtime::SafetyMode::Ask,
826 model_id: "ollama/test".to_string(),
827 });
828 let round: ConversationHistory =
829 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
830 let ctx = round.advertised_context.expect("field survives");
831 assert_eq!(
832 ctx.plan_path.as_deref(),
833 Some(std::path::Path::new("/tmp/proj/.mermaid/plans/x.md"))
834 );
835 assert_eq!(ctx.model_id, "ollama/test");
836 }
837
838 #[test]
839 fn tasks_round_trip_through_conversation_json() {
840 let mut fresh = touched("/tmp/proj");
841 fresh.tasks.create(
842 vec![crate::domain::TaskSpec {
843 subject: "wire broker".into(),
844 active_form: "wiring broker".into(),
845 description: Some("through ExecContext".into()),
846 in_progress: true,
847 }],
848 crate::domain::TaskOrigin::Model,
849 crate::domain::Stamp {
850 now_epoch: 42,
851 run_tokens: 7,
852 },
853 );
854 let round: ConversationHistory =
855 serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
856 assert_eq!(round.tasks, fresh.tasks);
857 assert_eq!(round.tasks.tasks[0].started_at, Some(42));
858 }
859
860 #[test]
861 fn session_state_round_trips_through_json() {
862 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
863 conv.safety_mode = Some(crate::runtime::SafetyMode::FullAccess);
864 conv.cumulative_token_usage = crate::domain::TokenUsageTotals {
865 prompt_tokens: 777,
866 ..Default::default()
867 };
868 let round: ConversationHistory =
869 serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
870 assert_eq!(
871 round.safety_mode,
872 Some(crate::runtime::SafetyMode::FullAccess)
873 );
874 assert_eq!(round.cumulative_token_usage.total_tokens(), 777);
875 }
876
877 #[test]
878 fn validate_conversation_id_rejects_traversal() {
879 assert!(validate_conversation_id("20260101_120000_001").is_ok());
880 assert!(validate_conversation_id("../secret").is_err());
881 assert!(validate_conversation_id("..\\secret").is_err());
882 assert!(validate_conversation_id("/etc/passwd").is_err());
883 assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
886
887 #[test]
888 fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
889 let messages = vec![
890 ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
891 ChatMessage::assistant("here is the screen")
892 .with_images(vec!["SCREENSHOT_B64".to_string()]),
893 ChatMessage::assistant("no image here"),
894 ];
895 let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
896 assert_eq!(
898 sanitized[0].images.as_deref(),
899 Some(["USER_PASTED_B64".to_string()].as_slice())
900 );
901 assert!(sanitized[1].images.is_none());
903 assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
904 assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
906 }
907
908 #[test]
909 fn strip_persisted_screenshots_is_none_without_assistant_images() {
910 let messages = vec![
911 ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
912 ChatMessage::assistant("no images"),
913 ];
914 assert!(strip_persisted_screenshots(&messages).is_none());
915 }
916
917 #[test]
918 fn saved_conversation_json_has_no_screenshot_bytes() {
919 let dir = std::env::temp_dir().join("mermaid_strip_test");
920 let _ = fs::create_dir_all(&dir);
921 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
922 conv.messages = vec![
923 ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
924 ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
925 ];
926 let store = ConversationManager {
927 project_dir: dir.clone(),
928 conversations_dir: dir.clone(),
929 compactions_dir: dir.clone(),
930 seen: Arc::new(Mutex::new(HashMap::new())),
931 };
932 store.save_conversation(&conv).expect("save");
933 let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
934 assert!(
935 !raw.contains("SHOTBYTES"),
936 "screenshot leaked to disk: {raw}"
937 );
938 assert!(raw.contains("USERIMG"), "user image should persist");
939 assert_eq!(
941 conv.messages[1].images.as_deref(),
942 Some(["SHOTBYTES".to_string()].as_slice())
943 );
944 let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
945 }
946
947 #[test]
948 fn saved_conversation_redacts_secrets_and_is_owner_only() {
949 let dir = std::env::temp_dir().join(format!("mermaid_conv_redact_{}", std::process::id()));
950 let _ = fs::remove_dir_all(&dir);
951 let _ = fs::create_dir_all(&dir);
952 let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
953 conv.messages = vec![
955 ChatMessage::user("read .env"),
956 ChatMessage::assistant("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
957 ];
958 let store = ConversationManager {
959 project_dir: dir.clone(),
960 conversations_dir: dir.clone(),
961 compactions_dir: dir.clone(),
962 seen: Arc::new(Mutex::new(HashMap::new())),
963 };
964 store.save_conversation(&conv).expect("save");
965 let path = dir.join(format!("{}.json", conv.id));
966 let raw = fs::read_to_string(&path).expect("read");
967 assert!(
968 !raw.contains("sk-abcdefghijklmnop1234"),
969 "secret leaked to the conversation store: {raw}"
970 );
971 assert!(
972 raw.contains("[REDACTED]"),
973 "expected redaction marker: {raw}"
974 );
975 #[cfg(unix)]
976 {
977 use std::os::unix::fs::PermissionsExt;
978 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
979 assert_eq!(
980 mode, 0o600,
981 "conversation file must be owner-only, got {mode:o}"
982 );
983 }
984 assert!(conv.messages[1].content.contains("sk-abcdefghijklmnop1234"));
986 let _ = fs::remove_dir_all(&dir);
987 }
988
989 #[test]
990 fn test_new_conversation_has_session_title() {
991 let conv =
992 ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
993 assert!(conv.title.starts_with("Session "));
994 assert_eq!(conv.model_name, "test-model");
995 assert_eq!(conv.project_path, "/tmp/project");
996 assert!(conv.messages.is_empty());
997 }
998
999 #[test]
1000 fn test_title_updates_from_first_user_message() {
1001 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1002 conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
1003 assert_eq!(conv.title, "Fix the login bug");
1004 }
1005
1006 #[test]
1007 fn test_title_truncated_at_60_chars() {
1008 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1009 let long_msg = "a".repeat(100);
1010 conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
1011 assert!(conv.title.ends_with("..."));
1012 assert!(conv.title.len() <= 64); }
1014
1015 #[test]
1016 fn test_title_set_only_once() {
1017 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1018 conv.add_messages(&[ChatMessage::user("First message")], Local::now());
1019 conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
1020 assert_eq!(conv.title, "First message");
1021 }
1022
1023 #[test]
1024 fn test_input_history_deduplication() {
1025 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1026 conv.add_to_input_history("hello".into());
1027 conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
1029 assert_eq!(conv.input_history.len(), 2);
1030 }
1031
1032 #[test]
1033 fn test_input_history_skips_empty() {
1034 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1035 conv.add_to_input_history("".into());
1036 conv.add_to_input_history(" ".into());
1037 assert_eq!(conv.input_history.len(), 0);
1038 }
1039
1040 #[test]
1041 fn test_input_history_capped_at_100() {
1042 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1043 for i in 0..110 {
1044 conv.add_to_input_history(format!("msg{}", i));
1045 }
1046 assert_eq!(conv.input_history.len(), 100);
1047 assert_eq!(conv.input_history.front().unwrap(), "msg10");
1048 }
1049
1050 #[test]
1051 fn sidecar_powers_metadata_listing() {
1052 let dir = std::env::temp_dir().join("mermaid_test_meta_sidecar");
1053 let _ = fs::remove_dir_all(&dir);
1054 let manager = ConversationManager::new(&dir).unwrap();
1055 let mut conv = ConversationHistory::new("/tmp/proj".into(), "model".into(), Local::now());
1056 conv.title = "My session".into();
1057 conv.add_messages(
1058 &[ChatMessage::user("hi"), ChatMessage::user("there")],
1059 Local::now(),
1060 );
1061 manager.save_conversation(&conv).unwrap();
1062
1063 assert!(
1064 manager
1065 .conversations_dir()
1066 .join(format!("{}.meta", conv.id))
1067 .exists()
1068 );
1069 let metas = manager.list_conversation_metas().unwrap();
1070 assert_eq!(metas.len(), 1);
1071 assert_eq!(metas[0].id, conv.id);
1072 assert_eq!(metas[0].title, "My session");
1073 assert_eq!(metas[0].message_count, 2);
1074
1075 manager.delete_conversation(&conv.id).unwrap();
1077 assert!(
1078 !manager
1079 .conversations_dir()
1080 .join(format!("{}.meta", conv.id))
1081 .exists()
1082 );
1083 assert!(manager.list_conversation_metas().unwrap().is_empty());
1084 let _ = fs::remove_dir_all(&dir);
1085 }
1086
1087 #[test]
1088 fn metadata_listing_falls_back_to_full_parse_without_sidecar() {
1089 let dir = std::env::temp_dir().join("mermaid_test_meta_fallback");
1090 let _ = fs::remove_dir_all(&dir);
1091 let manager = ConversationManager::new(&dir).unwrap();
1092 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1093 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1094 manager.save_conversation(&conv).unwrap();
1095 fs::remove_file(
1097 manager
1098 .conversations_dir()
1099 .join(format!("{}.meta", conv.id)),
1100 )
1101 .unwrap();
1102 let metas = manager.list_conversation_metas().unwrap();
1103 assert_eq!(metas.len(), 1, "falls back to parsing the .json");
1104 assert_eq!(metas[0].message_count, 1);
1105 let _ = fs::remove_dir_all(&dir);
1106 }
1107
1108 #[test]
1109 fn lineage_fields_default_on_old_sessions() {
1110 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}"#;
1112 let conv: ConversationHistory = serde_json::from_str(json).unwrap();
1113 assert!(conv.git_sha.is_none());
1114 assert!(conv.cli_version.is_none());
1115 assert!(conv.forked_from.is_none());
1116 assert!(conv.parent_session.is_none());
1117 }
1118
1119 #[test]
1120 fn test_save_load_roundtrip() {
1121 let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
1122 let _ = fs::remove_dir_all(&dir);
1123 let manager = ConversationManager::new(&dir).unwrap();
1124
1125 let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1126 conv.add_messages(&[ChatMessage::user("test message")], Local::now());
1127 conv.add_to_input_history("test message".into());
1128
1129 manager.save_conversation(&conv).unwrap();
1130 let loaded = manager.load_conversation(&conv.id).unwrap();
1131
1132 assert_eq!(loaded.id, conv.id);
1133 assert_eq!(loaded.title, conv.title);
1134 assert_eq!(loaded.messages.len(), 1);
1135 assert_eq!(loaded.input_history.len(), 1);
1136
1137 let _ = fs::remove_dir_all(&dir);
1138 }
1139
1140 #[test]
1141 fn test_list_conversations_ordered_by_updated_at() {
1142 let dir = std::env::temp_dir().join("mermaid_test_conv_list");
1143 let _ = fs::remove_dir_all(&dir);
1144 let manager = ConversationManager::new(&dir).unwrap();
1145
1146 let conv1 = touched("/tmp");
1147 std::thread::sleep(std::time::Duration::from_millis(10));
1148 let conv2 = touched("/tmp");
1149
1150 manager.save_conversation(&conv1).unwrap();
1151 manager.save_conversation(&conv2).unwrap();
1152
1153 let list = manager.list_conversations().unwrap();
1154 assert_eq!(list.len(), 2);
1155 assert_eq!(list[0].id, conv2.id);
1157 assert_eq!(list[1].id, conv1.id);
1158
1159 let _ = fs::remove_dir_all(&dir);
1160 }
1161
1162 #[test]
1163 fn test_load_last_conversation() {
1164 let dir = std::env::temp_dir().join("mermaid_test_conv_last");
1165 let _ = fs::remove_dir_all(&dir);
1166 let manager = ConversationManager::new(&dir).unwrap();
1167
1168 assert!(manager.load_last_conversation().unwrap().is_none());
1169
1170 let conv = touched("/tmp");
1171 manager.save_conversation(&conv).unwrap();
1172
1173 let last = manager.load_last_conversation().unwrap().unwrap();
1174 assert_eq!(last.id, conv.id);
1175
1176 let _ = fs::remove_dir_all(&dir);
1177 }
1178
1179 #[test]
1180 fn test_load_last_conversation_picks_newest_by_mtime() {
1181 let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
1186 let _ = fs::remove_dir_all(&dir);
1187 let manager = ConversationManager::new(&dir).unwrap();
1188
1189 let conv1 = touched("/tmp");
1190 manager.save_conversation(&conv1).unwrap();
1191 std::thread::sleep(std::time::Duration::from_millis(10));
1192
1193 let conv2 = touched("/tmp");
1194 manager.save_conversation(&conv2).unwrap();
1195 std::thread::sleep(std::time::Duration::from_millis(10));
1196
1197 let conv3 = touched("/tmp");
1198 manager.save_conversation(&conv3).unwrap();
1199
1200 let last = manager.load_last_conversation().unwrap().unwrap();
1201 assert_eq!(
1202 last.id, conv3.id,
1203 "should return the most-recently-written file"
1204 );
1205
1206 let _ = fs::remove_dir_all(&dir);
1207 }
1208
1209 #[test]
1210 fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
1211 let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
1212 let _ = fs::remove_dir_all(&dir);
1213 let manager = ConversationManager::new(&dir).unwrap();
1214
1215 let good = touched("/tmp");
1216 manager.save_conversation(&good).unwrap();
1217 std::thread::sleep(std::time::Duration::from_millis(10));
1218
1219 let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
1222 fs::write(&corrupt, b"{ not valid json").unwrap();
1223
1224 let last = manager.load_last_conversation().unwrap().unwrap();
1225 assert_eq!(
1226 last.id, good.id,
1227 "must fall back to the newest VALID conversation"
1228 );
1229 let _ = fs::remove_dir_all(&dir);
1230 }
1231
1232 #[test]
1233 fn load_last_conversation_none_when_only_corrupt() {
1234 let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
1235 let _ = fs::remove_dir_all(&dir);
1236 let manager = ConversationManager::new(&dir).unwrap();
1237 fs::write(
1238 manager.conversations_dir().join("20991231_235959_998.json"),
1239 b"nope",
1240 )
1241 .unwrap();
1242 assert!(manager.load_last_conversation().unwrap().is_none());
1243 let _ = fs::remove_dir_all(&dir);
1244 }
1245
1246 #[test]
1247 fn load_conversation_tolerates_unknown_message_role() {
1248 let dir =
1253 std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
1254 let _ = fs::remove_dir_all(&dir);
1255 let manager = ConversationManager::new(&dir).unwrap();
1256
1257 let id = "20260101_120000_001";
1258 let json = format!(
1259 r#"{{
1260 "id": "{id}",
1261 "title": "skew",
1262 "messages": [
1263 {{
1264 "role": "Developer",
1265 "content": "from a newer build",
1266 "timestamp": "2026-01-01T12:00:00-04:00"
1267 }}
1268 ],
1269 "model_name": "m",
1270 "project_path": "/tmp",
1271 "created_at": "2026-01-01T12:00:00-04:00",
1272 "updated_at": "2026-01-01T12:00:00-04:00",
1273 "total_tokens": null
1274 }}"#
1275 );
1276 fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
1277
1278 let loaded = manager
1279 .load_conversation(id)
1280 .expect("must load despite an unknown role");
1281 assert_eq!(loaded.messages.len(), 1);
1282 assert_eq!(
1283 loaded.messages[0].role,
1284 MessageRole::System,
1285 "an unknown role becomes a neutral System message"
1286 );
1287
1288 let last = manager
1290 .load_last_conversation()
1291 .unwrap()
1292 .expect("the newest session must load");
1293 assert_eq!(last.id, id);
1294
1295 let _ = fs::remove_dir_all(&dir);
1296 }
1297
1298 #[test]
1299 fn test_delete_conversation() {
1300 let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
1301 let _ = fs::remove_dir_all(&dir);
1302 let manager = ConversationManager::new(&dir).unwrap();
1303
1304 let conv = touched("/tmp");
1305 manager.save_conversation(&conv).unwrap();
1306 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1307
1308 manager.delete_conversation(&conv.id).unwrap();
1309 assert_eq!(manager.list_conversations().unwrap().len(), 0);
1310
1311 let _ = fs::remove_dir_all(&dir);
1312 }
1313
1314 #[test]
1315 fn empty_session_is_not_saved() {
1316 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
1317 let _ = fs::remove_dir_all(&dir);
1318 let manager = ConversationManager::new(&dir).unwrap();
1319
1320 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1322 manager.save_conversation(&conv).unwrap();
1323 assert!(
1324 manager.list_conversations().unwrap().is_empty(),
1325 "empty session must not be listed"
1326 );
1327 assert!(
1328 manager.load_last_conversation().unwrap().is_none(),
1329 "empty session must not be --continue-able"
1330 );
1331
1332 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1334 manager.save_conversation(&conv).unwrap();
1335 assert_eq!(manager.list_conversations().unwrap().len(), 1);
1336
1337 let _ = fs::remove_dir_all(&dir);
1338 }
1339
1340 #[test]
1341 fn resume_paths_skip_pre_existing_empty_files() {
1342 let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
1345 let _ = fs::remove_dir_all(&dir);
1346 let manager = ConversationManager::new(&dir).unwrap();
1347
1348 let real = touched("/tmp");
1349 manager.save_conversation(&real).unwrap();
1350 std::thread::sleep(std::time::Duration::from_millis(10));
1352 let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1353 let path = manager
1354 .conversations_dir()
1355 .join(format!("{}.json", empty.id));
1356 fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
1357
1358 let list = manager.list_conversations().unwrap();
1359 assert_eq!(list.len(), 1, "the empty file must not be listed");
1360 assert_eq!(list[0].id, real.id);
1361 assert_eq!(
1362 manager.load_last_conversation().unwrap().unwrap().id,
1363 real.id,
1364 "--continue must skip the newer empty file"
1365 );
1366
1367 let _ = fs::remove_dir_all(&dir);
1368 }
1369
1370 #[test]
1371 fn read_conversation_capped_refuses_oversized_file() {
1372 let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
1375 let _ = fs::remove_dir_all(&dir);
1376 fs::create_dir_all(&dir).unwrap();
1377
1378 let small = dir.join("small.json");
1379 fs::write(&small, b"{}").unwrap();
1380 assert!(read_conversation_capped(&small).is_ok());
1381
1382 let big = dir.join("big.json");
1383 let f = fs::File::create(&big).unwrap();
1384 f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
1385 assert!(
1386 read_conversation_capped(&big).is_err(),
1387 "a file over the cap must be refused, not slurped into memory"
1388 );
1389
1390 let _ = fs::remove_dir_all(&dir);
1391 }
1392
1393 #[test]
1394 fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
1395 let dir =
1401 std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
1402 let _ = fs::remove_dir_all(&dir);
1403 let manager = ConversationManager::new(&dir).unwrap();
1404
1405 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1406 conv.add_messages(&[ChatMessage::user("ours")], Local::now());
1407 manager.save_conversation(&conv).unwrap();
1408 let main = manager
1409 .conversations_dir()
1410 .join(format!("{}.json", conv.id));
1411
1412 let other = ConversationManager::new(&dir).unwrap();
1415 let mut their_conv = other.load_conversation(&conv.id).unwrap();
1416 their_conv.add_messages(
1417 &[ChatMessage::user("theirs - extra content here")],
1418 Local::now(),
1419 );
1420 other.save_conversation(&their_conv).unwrap();
1421
1422 manager.save_conversation(&conv).unwrap();
1425 let on_disk: ConversationHistory =
1426 serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
1427 assert_eq!(
1428 on_disk.messages.len(),
1429 2,
1430 "the concurrent writer's file must be left intact"
1431 );
1432
1433 let mut conflicts = fs::read_dir(manager.conversations_dir())
1435 .unwrap()
1436 .flatten()
1437 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
1438 .map(|e| e.path())
1439 .collect::<Vec<_>>();
1440 assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
1441 let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
1442 assert!(
1443 sibling.contains("ours") && !sibling.contains("theirs"),
1444 "the .conflict sibling holds OUR copy, not the concurrent writer's"
1445 );
1446
1447 let listed = manager.list_conversations().unwrap();
1450 assert_eq!(
1451 listed.len(),
1452 1,
1453 ".conflict sibling must not appear as a conversation"
1454 );
1455 assert_eq!(listed[0].id, conv.id);
1456
1457 let _ = fs::remove_dir_all(&dir);
1458 }
1459
1460 #[test]
1461 fn save_conversation_repeated_self_saves_do_not_conflict() {
1462 let dir =
1465 std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
1466 let _ = fs::remove_dir_all(&dir);
1467 let manager = ConversationManager::new(&dir).unwrap();
1468
1469 let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1470 conv.add_messages(&[ChatMessage::user("first")], Local::now());
1471 manager.save_conversation(&conv).unwrap();
1472 conv.add_messages(&[ChatMessage::user("second")], Local::now());
1473 manager.save_conversation(&conv).unwrap();
1474
1475 let conflicts = fs::read_dir(manager.conversations_dir())
1476 .unwrap()
1477 .flatten()
1478 .filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
1479 .count();
1480 assert_eq!(
1481 conflicts, 0,
1482 "our own repeated saves must not be flagged as conflicts"
1483 );
1484 let loaded = manager.load_conversation(&conv.id).unwrap();
1485 assert_eq!(loaded.messages.len(), 2, "latest save must win for us");
1486
1487 let _ = fs::remove_dir_all(&dir);
1488 }
1489}