use crate::domain::CompactionArchive;
use crate::models::{ChatMessage, MessageRole};
use anyhow::Result;
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
fn validate_conversation_id(id: &str) -> Result<()> {
let valid = id.len() == 19
&& id.as_bytes().iter().enumerate().all(|(i, b)| match i {
8 | 15 => *b == b'_',
_ => b.is_ascii_digit(),
});
anyhow::ensure!(valid, "invalid conversation id: {id:?}");
Ok(())
}
const MAX_CONVERSATION_BYTES: u64 = 64 * 1024 * 1024;
fn read_conversation_capped(path: &Path) -> std::io::Result<String> {
let len = fs::metadata(path)?.len();
if len > MAX_CONVERSATION_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"conversation file {} is {len} bytes, over the {} MiB cap",
path.display(),
MAX_CONVERSATION_BYTES / (1024 * 1024)
),
));
}
fs::read_to_string(path)
}
const SCREENSHOT_ELIDED_MARKER: &str = "\n[screenshot not persisted]";
fn strip_persisted_screenshots(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
let needs = messages
.iter()
.any(|m| m.role != MessageRole::User && m.images.is_some());
if !needs {
return None;
}
let mut out = messages.to_vec();
for m in out.iter_mut() {
if m.role != MessageRole::User && m.images.is_some() {
m.images = None;
if !m.content.ends_with(SCREENSHOT_ELIDED_MARKER) {
m.content.push_str(SCREENSHOT_ELIDED_MARKER);
}
}
}
Some(out)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationHistory {
pub id: String,
pub title: String,
pub messages: Vec<ChatMessage>,
pub model_name: String,
pub project_path: String,
pub created_at: DateTime<Local>,
pub updated_at: DateTime<Local>,
pub total_tokens: Option<usize>,
#[serde(default)]
pub compactions: Vec<crate::domain::CompactionRecord>,
#[serde(default)]
pub input_history: VecDeque<String>,
}
impl ConversationHistory {
pub fn new(project_path: String, model_name: String, now: DateTime<Local>) -> Self {
let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
Self {
id: id.clone(),
title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
messages: Vec::new(),
model_name,
project_path,
created_at: now,
updated_at: now,
total_tokens: None,
compactions: Vec::new(),
input_history: VecDeque::new(),
}
}
pub fn add_messages(&mut self, messages: &[ChatMessage], now: DateTime<Local>) {
self.messages.extend_from_slice(messages);
self.updated_at = now;
self.update_title();
}
pub fn replace_messages(&mut self, messages: Vec<ChatMessage>, now: DateTime<Local>) {
self.messages = messages;
self.updated_at = now;
}
pub fn add_compaction(
&mut self,
record: crate::domain::CompactionRecord,
now: DateTime<Local>,
) {
self.compactions.push(record);
self.updated_at = now;
}
pub fn add_to_input_history(&mut self, input: String) {
if input.trim().is_empty() {
return;
}
if let Some(last) = self.input_history.back()
&& last == &input
{
return;
}
if self.input_history.len() >= 100 {
self.input_history.pop_front(); }
self.input_history.push_back(input);
}
fn update_title(&mut self) {
if !self.title.starts_with("Session ") {
return;
}
if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
let preview = if first_user_msg.content.len() > 60 {
let end = first_user_msg.content.floor_char_boundary(60);
format!("{}...", &first_user_msg.content[..end])
} else {
first_user_msg.content.clone()
};
self.title = preview;
}
}
pub fn summary(&self) -> String {
let message_count = self.messages.len();
let duration = self.updated_at.signed_duration_since(self.created_at);
let hours = duration.num_hours();
let minutes = duration.num_minutes() % 60;
format!(
"{} | {} messages | {}h {}m | {}",
self.updated_at.format("%Y-%m-%d %H:%M"),
message_count,
hours,
minutes,
self.title
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FileStamp {
mtime: SystemTime,
len: u64,
}
fn file_stamp(path: &Path) -> Option<FileStamp> {
let meta = fs::metadata(path).ok()?;
let mtime = meta.modified().ok()?;
Some(FileStamp {
mtime,
len: meta.len(),
})
}
static CONFLICT_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Clone)]
pub struct ConversationManager {
conversations_dir: PathBuf,
compactions_dir: PathBuf,
seen: Arc<Mutex<HashMap<String, FileStamp>>>,
}
impl ConversationManager {
pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
let mermaid_dir = project_dir.as_ref().join(".mermaid");
let conversations_dir = mermaid_dir.join("conversations");
let compactions_dir = mermaid_dir.join("compactions");
fs::create_dir_all(&conversations_dir)?;
fs::create_dir_all(&compactions_dir)?;
Ok(Self {
conversations_dir,
compactions_dir,
seen: Arc::new(Mutex::new(HashMap::new())),
})
}
fn record_stamp(&self, id: &str, path: &Path) {
if let Some(stamp) = file_stamp(path) {
self.seen
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(id.to_string(), stamp);
}
}
fn conflict_sibling_path(&self, id: &str) -> PathBuf {
let n = CONFLICT_COUNTER.fetch_add(1, Ordering::Relaxed);
self.conversations_dir
.join(format!("{}.{}.{}.conflict", id, std::process::id(), n))
}
pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
validate_conversation_id(&conversation.id)?;
let filename = format!("{}.json", conversation.id);
let path = self.conversations_dir.join(filename);
let json = match strip_persisted_screenshots(&conversation.messages) {
Some(sanitized) => {
let mut redacted = conversation.clone();
redacted.messages = sanitized;
serde_json::to_string_pretty(&redacted)?
},
None => serde_json::to_string_pretty(conversation)?,
};
let baseline = self
.seen
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&conversation.id)
.copied();
if let (Some(current), Some(base)) = (file_stamp(&path), baseline)
&& current != base
{
let sibling = self.conflict_sibling_path(&conversation.id);
crate::runtime::write_atomic(&sibling, json.as_bytes())?;
tracing::warn!(
id = %conversation.id,
main = %path.display(),
conflict = %sibling.display(),
"conversation changed on disk since load (concurrent writer); wrote our copy to a .conflict sibling instead of overwriting"
);
return Ok(());
}
crate::runtime::write_atomic(&path, json.as_bytes())?;
self.record_stamp(&conversation.id, &path);
Ok(())
}
pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
validate_conversation_id(&archive.conversation_id)?;
anyhow::ensure!(
!archive.id.is_empty()
&& !archive.id.contains(['/', '\\'])
&& !archive.id.contains(".."),
"invalid compaction archive id: {:?}",
archive.id
);
let dir = self.compactions_dir.join(&archive.conversation_id);
fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.json", archive.id));
let json = match strip_persisted_screenshots(&archive.messages) {
Some(sanitized) => {
let mut redacted = archive.clone();
redacted.messages = sanitized;
serde_json::to_string_pretty(&redacted)?
},
None => serde_json::to_string_pretty(archive)?,
};
crate::runtime::write_atomic(&path, json.as_bytes())?;
Ok(path)
}
pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
validate_conversation_id(id)?;
let filename = format!("{}.json", id);
let path = self.conversations_dir.join(filename);
let json = read_conversation_capped(&path)?;
let conversation: ConversationHistory = serde_json::from_str(&json)?;
validate_conversation_id(&conversation.id)?;
self.record_stamp(&conversation.id, &path);
Ok(conversation)
}
pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
return Ok(None);
};
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let mtime = e.metadata().ok()?.modified().ok()?;
Some((mtime, e.path()))
})
.collect();
candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
for (_, path) in candidates {
let Ok(json) = read_conversation_capped(&path) else {
tracing::warn!(path = %path.display(), "skipping unreadable or oversized conversation file");
continue;
};
let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
continue;
};
if validate_conversation_id(&conv.id).is_err() {
tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
continue;
}
self.record_stamp(&conv.id, &path);
return Ok(Some(conv));
}
Ok(None)
}
pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
let mut conversations = Vec::new();
if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
for entry in entries.flatten() {
if let Some(ext) = entry.path().extension()
&& ext == "json"
&& let Ok(json) = read_conversation_capped(&entry.path())
&& let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
{
conversations.push(conv);
}
}
}
conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
Ok(conversations)
}
pub fn delete_conversation(&self, id: &str) -> Result<()> {
validate_conversation_id(id)?;
let filename = format!("{}.json", id);
let path = self.conversations_dir.join(filename);
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
pub fn conversations_dir(&self) -> &Path {
&self.conversations_dir
}
pub fn compactions_dir(&self) -> &Path {
&self.compactions_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_conversation_id_rejects_traversal() {
assert!(validate_conversation_id("20260101_120000_001").is_ok());
assert!(validate_conversation_id("../secret").is_err());
assert!(validate_conversation_id("..\\secret").is_err());
assert!(validate_conversation_id("/etc/passwd").is_err());
assert!(validate_conversation_id("20260101_120000").is_err()); assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); }
#[test]
fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
let messages = vec![
ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
ChatMessage::assistant("here is the screen")
.with_images(vec!["SCREENSHOT_B64".to_string()]),
ChatMessage::assistant("no image here"),
];
let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
assert_eq!(
sanitized[0].images.as_deref(),
Some(["USER_PASTED_B64".to_string()].as_slice())
);
assert!(sanitized[1].images.is_none());
assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
}
#[test]
fn strip_persisted_screenshots_is_none_without_assistant_images() {
let messages = vec![
ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
ChatMessage::assistant("no images"),
];
assert!(strip_persisted_screenshots(&messages).is_none());
}
#[test]
fn saved_conversation_json_has_no_screenshot_bytes() {
let dir = std::env::temp_dir().join("mermaid_strip_test");
let _ = fs::create_dir_all(&dir);
let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
conv.messages = vec![
ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
];
let store = ConversationManager {
conversations_dir: dir.clone(),
compactions_dir: dir.clone(),
seen: Arc::new(Mutex::new(HashMap::new())),
};
store.save_conversation(&conv).expect("save");
let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
assert!(
!raw.contains("SHOTBYTES"),
"screenshot leaked to disk: {raw}"
);
assert!(raw.contains("USERIMG"), "user image should persist");
assert_eq!(
conv.messages[1].images.as_deref(),
Some(["SHOTBYTES".to_string()].as_slice())
);
let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
}
#[test]
fn test_new_conversation_has_session_title() {
let conv =
ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
assert!(conv.title.starts_with("Session "));
assert_eq!(conv.model_name, "test-model");
assert_eq!(conv.project_path, "/tmp/project");
assert!(conv.messages.is_empty());
}
#[test]
fn test_title_updates_from_first_user_message() {
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
assert_eq!(conv.title, "Fix the login bug");
}
#[test]
fn test_title_truncated_at_60_chars() {
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
let long_msg = "a".repeat(100);
conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
assert!(conv.title.ends_with("..."));
assert!(conv.title.len() <= 64); }
#[test]
fn test_title_set_only_once() {
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
conv.add_messages(&[ChatMessage::user("First message")], Local::now());
conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
assert_eq!(conv.title, "First message");
}
#[test]
fn test_input_history_deduplication() {
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
conv.add_to_input_history("hello".into());
conv.add_to_input_history("hello".into()); conv.add_to_input_history("world".into());
assert_eq!(conv.input_history.len(), 2);
}
#[test]
fn test_input_history_skips_empty() {
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
conv.add_to_input_history("".into());
conv.add_to_input_history(" ".into());
assert_eq!(conv.input_history.len(), 0);
}
#[test]
fn test_input_history_capped_at_100() {
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
for i in 0..110 {
conv.add_to_input_history(format!("msg{}", i));
}
assert_eq!(conv.input_history.len(), 100);
assert_eq!(conv.input_history.front().unwrap(), "msg10");
}
#[test]
fn test_save_load_roundtrip() {
let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
conv.add_messages(&[ChatMessage::user("test message")], Local::now());
conv.add_to_input_history("test message".into());
manager.save_conversation(&conv).unwrap();
let loaded = manager.load_conversation(&conv.id).unwrap();
assert_eq!(loaded.id, conv.id);
assert_eq!(loaded.title, conv.title);
assert_eq!(loaded.messages.len(), 1);
assert_eq!(loaded.input_history.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_list_conversations_ordered_by_updated_at() {
let dir = std::env::temp_dir().join("mermaid_test_conv_list");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let conv1 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
std::thread::sleep(std::time::Duration::from_millis(10));
let conv2 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
manager.save_conversation(&conv1).unwrap();
manager.save_conversation(&conv2).unwrap();
let list = manager.list_conversations().unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, conv2.id);
assert_eq!(list[1].id, conv1.id);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_load_last_conversation() {
let dir = std::env::temp_dir().join("mermaid_test_conv_last");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
assert!(manager.load_last_conversation().unwrap().is_none());
let conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
manager.save_conversation(&conv).unwrap();
let last = manager.load_last_conversation().unwrap().unwrap();
assert_eq!(last.id, conv.id);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_load_last_conversation_picks_newest_by_mtime() {
let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let conv1 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
manager.save_conversation(&conv1).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let conv2 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
manager.save_conversation(&conv2).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let conv3 = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
manager.save_conversation(&conv3).unwrap();
let last = manager.load_last_conversation().unwrap().unwrap();
assert_eq!(
last.id, conv3.id,
"should return the most-recently-written file"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let good = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
manager.save_conversation(&good).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
fs::write(&corrupt, b"{ not valid json").unwrap();
let last = manager.load_last_conversation().unwrap().unwrap();
assert_eq!(
last.id, good.id,
"must fall back to the newest VALID conversation"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_last_conversation_none_when_only_corrupt() {
let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
fs::write(
manager.conversations_dir().join("20991231_235959_998.json"),
b"nope",
)
.unwrap();
assert!(manager.load_last_conversation().unwrap().is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_conversation_tolerates_unknown_message_role() {
let dir =
std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let id = "20260101_120000_001";
let json = format!(
r#"{{
"id": "{id}",
"title": "skew",
"messages": [
{{
"role": "Developer",
"content": "from a newer build",
"timestamp": "2026-01-01T12:00:00-04:00"
}}
],
"model_name": "m",
"project_path": "/tmp",
"created_at": "2026-01-01T12:00:00-04:00",
"updated_at": "2026-01-01T12:00:00-04:00",
"total_tokens": null
}}"#
);
fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
let loaded = manager
.load_conversation(id)
.expect("must load despite an unknown role");
assert_eq!(loaded.messages.len(), 1);
assert_eq!(
loaded.messages[0].role,
MessageRole::System,
"an unknown role becomes a neutral System message"
);
let last = manager
.load_last_conversation()
.unwrap()
.expect("the newest session must load");
assert_eq!(last.id, id);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_delete_conversation() {
let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
manager.save_conversation(&conv).unwrap();
assert_eq!(manager.list_conversations().unwrap().len(), 1);
manager.delete_conversation(&conv.id).unwrap();
assert_eq!(manager.list_conversations().unwrap().len(), 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn read_conversation_capped_refuses_oversized_file() {
let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let small = dir.join("small.json");
fs::write(&small, b"{}").unwrap();
assert!(read_conversation_capped(&small).is_ok());
let big = dir.join("big.json");
let f = fs::File::create(&big).unwrap();
f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
assert!(
read_conversation_capped(&big).is_err(),
"a file over the cap must be refused, not slurped into memory"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn save_conversation_detects_concurrent_writer_and_writes_conflict_sibling() {
let dir =
std::env::temp_dir().join(format!("mermaid_conv_conflict_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
conv.add_messages(&[ChatMessage::user("ours")], Local::now());
manager.save_conversation(&conv).unwrap();
let main = manager
.conversations_dir()
.join(format!("{}.json", conv.id));
let other = ConversationManager::new(&dir).unwrap();
let mut their_conv = other.load_conversation(&conv.id).unwrap();
their_conv.add_messages(
&[ChatMessage::user("theirs - extra content here")],
Local::now(),
);
other.save_conversation(&their_conv).unwrap();
manager.save_conversation(&conv).unwrap();
let on_disk: ConversationHistory =
serde_json::from_str(&fs::read_to_string(&main).unwrap()).unwrap();
assert_eq!(
on_disk.messages.len(),
2,
"the concurrent writer's file must be left intact"
);
let mut conflicts = fs::read_dir(manager.conversations_dir())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
.map(|e| e.path())
.collect::<Vec<_>>();
assert_eq!(conflicts.len(), 1, "exactly one .conflict sibling expected");
let sibling = fs::read_to_string(conflicts.pop().unwrap()).unwrap();
assert!(
sibling.contains("ours") && !sibling.contains("theirs"),
"the .conflict sibling holds OUR copy, not the concurrent writer's"
);
let listed = manager.list_conversations().unwrap();
assert_eq!(
listed.len(),
1,
".conflict sibling must not appear as a conversation"
);
assert_eq!(listed[0].id, conv.id);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn save_conversation_repeated_self_saves_do_not_conflict() {
let dir =
std::env::temp_dir().join(format!("mermaid_conv_self_save_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
conv.add_messages(&[ChatMessage::user("first")], Local::now());
manager.save_conversation(&conv).unwrap();
conv.add_messages(&[ChatMessage::user("second")], Local::now());
manager.save_conversation(&conv).unwrap();
let conflicts = fs::read_dir(manager.conversations_dir())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".conflict"))
.count();
assert_eq!(
conflicts, 0,
"our own repeated saves must not be flagged as conflicts"
);
let loaded = manager.load_conversation(&conv.id).unwrap();
assert_eq!(loaded.messages.len(), 2, "latest save must win for us");
let _ = fs::remove_dir_all(&dir);
}
}