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>,
#[serde(default)]
pub compactions: Vec<crate::domain::CompactionRecord>,
#[serde(default)]
pub input_history: VecDeque<String>,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub safety_mode: Option<crate::runtime::SafetyMode>,
#[serde(default)]
pub plan: Option<crate::domain::PlanState>,
#[serde(default)]
pub last_token_usage: Option<crate::domain::TokenUsageTotals>,
#[serde(default)]
pub cumulative_token_usage: crate::domain::TokenUsageTotals,
#[serde(default)]
pub context_usage: Option<crate::domain::ContextUsageSnapshot>,
#[serde(default)]
pub forked_from: Option<String>,
#[serde(default)]
pub parent_session: Option<String>,
#[serde(default)]
pub cli_version: Option<String>,
#[serde(default)]
pub git_sha: Option<String>,
#[serde(default)]
pub tasks: crate::domain::TaskStore,
}
pub fn detect_git_branch(dir: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!branch.is_empty() && branch != "HEAD").then_some(branch)
}
pub fn detect_git_sha(dir: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!sha.is_empty()).then_some(sha)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationMeta {
pub id: String,
pub title: String,
pub updated_at: DateTime<Local>,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub message_count: usize,
#[serde(default)]
pub forked_from: Option<String>,
}
impl ConversationMeta {
fn from_history(h: &ConversationHistory) -> Self {
Self {
id: h.id.clone(),
title: h.title.clone(),
updated_at: h.updated_at,
git_branch: h.git_branch.clone(),
message_count: h.messages.len(),
forked_from: h.forked_from.clone(),
}
}
}
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,
compactions: Vec::new(),
input_history: VecDeque::new(),
git_branch: None,
safety_mode: None,
plan: None,
last_token_usage: None,
cumulative_token_usage: crate::domain::TokenUsageTotals::default(),
context_usage: None,
forked_from: None,
parent_session: None,
cli_version: None,
git_sha: None,
tasks: crate::domain::TaskStore::default(),
}
}
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 {
project_dir: PathBuf,
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 {
project_dir: project_dir.as_ref().to_path_buf(),
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)?;
if conversation.messages.is_empty() {
return Ok(());
}
let filename = format!("{}.json", conversation.id);
let path = self.conversations_dir.join(filename);
let mut value = match strip_persisted_screenshots(&conversation.messages) {
Some(sanitized) => {
let mut stripped = conversation.clone();
stripped.messages = sanitized;
serde_json::to_value(&stripped)?
},
None => serde_json::to_value(conversation)?,
};
crate::utils::redact_json(&mut value);
let json = serde_json::to_string_pretty(&value)?;
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_with_mode(&sibling, json.as_bytes(), 0o600)?;
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_with_mode(&path, json.as_bytes(), 0o600)?;
self.record_stamp(&conversation.id, &path);
let meta = ConversationMeta::from_history(conversation);
if let Ok(meta_json) = serde_json::to_string(&meta) {
let meta_path = self
.conversations_dir
.join(format!("{}.meta", conversation.id));
let _ = crate::runtime::write_atomic_with_mode(&meta_path, meta_json.as_bytes(), 0o600);
}
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 mut value = match strip_persisted_screenshots(&archive.messages) {
Some(sanitized) => {
let mut stripped = archive.clone();
stripped.messages = sanitized;
serde_json::to_value(&stripped)?
},
None => serde_json::to_value(archive)?,
};
crate::utils::redact_json(&mut value);
let json = serde_json::to_string_pretty(&value)?;
crate::runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
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;
}
if conv.messages.is_empty() {
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)
&& !conv.messages.is_empty()
{
conversations.push(conv);
}
}
}
conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
Ok(conversations)
}
pub fn list_conversation_metas(&self) -> Result<Vec<ConversationMeta>> {
let mut metas = Vec::new();
let mut seen = std::collections::HashSet::new();
let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
return Ok(metas);
};
let paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
for path in &paths {
if path.extension().is_some_and(|e| e == "meta")
&& let Ok(raw) = fs::read_to_string(path)
&& let Ok(meta) = serde_json::from_str::<ConversationMeta>(&raw)
&& meta.message_count > 0
{
seen.insert(meta.id.clone());
metas.push(meta);
}
}
for path in &paths {
if path.extension().is_some_and(|e| e == "json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& !seen.contains(stem)
&& let Ok(json) = read_conversation_capped(path)
&& let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
&& !conv.messages.is_empty()
{
metas.push(ConversationMeta::from_history(&conv));
}
}
metas.sort_by_key(|m| std::cmp::Reverse(m.updated_at));
Ok(metas)
}
pub fn delete_conversation(&self, id: &str) -> Result<()> {
validate_conversation_id(id)?;
let path = self.conversations_dir.join(format!("{}.json", id));
if path.exists() {
fs::remove_file(path)?;
}
let _ = fs::remove_file(self.conversations_dir.join(format!("{}.meta", id)));
let _ = crate::session::scratchpad::remove(&self.project_dir, id);
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::*;
fn touched(project: &str) -> ConversationHistory {
let mut c = ConversationHistory::new(project.into(), "m".into(), Local::now());
c.add_messages(&[ChatMessage::user("hi")], Local::now());
c
}
#[test]
fn legacy_conversation_json_without_git_branch_deserializes() {
let json = r#"{
"id": "20260101_120000_001",
"title": "Legacy session",
"messages": [],
"model_name": "ollama/test",
"project_path": "/tmp/proj",
"created_at": "2026-01-01T12:00:00-05:00",
"updated_at": "2026-01-01T12:00:00-05:00",
"total_tokens": null
}"#;
let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
assert!(conv.git_branch.is_none());
assert_eq!(conv.title, "Legacy session");
let mut fresh =
ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
fresh.git_branch = Some("feature/x".to_string());
let round: ConversationHistory =
serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
}
#[test]
fn legacy_json_defaults_session_state_fields() {
let json = r#"{
"id": "20260101_120000_002",
"title": "Old",
"messages": [],
"model_name": "m",
"project_path": "/tmp/proj",
"created_at": "2026-01-01T12:00:00-05:00",
"updated_at": "2026-01-01T12:00:00-05:00",
"total_tokens": null
}"#;
let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
assert_eq!(conv.safety_mode, None);
assert_eq!(
conv.cumulative_token_usage,
crate::domain::TokenUsageTotals::default()
);
assert!(conv.last_token_usage.is_none());
assert!(conv.context_usage.is_none());
assert!(conv.tasks.tasks.is_empty());
assert_eq!(conv.tasks.next_id, 0);
}
#[test]
fn tasks_round_trip_through_conversation_json() {
let mut fresh = touched("/tmp/proj");
fresh.tasks.create(
vec![crate::domain::TaskSpec {
subject: "wire broker".into(),
active_form: "wiring broker".into(),
description: Some("through ExecContext".into()),
in_progress: true,
}],
crate::domain::TaskOrigin::Model,
crate::domain::Stamp {
now_epoch: 42,
run_tokens: 7,
},
);
let round: ConversationHistory =
serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
assert_eq!(round.tasks, fresh.tasks);
assert_eq!(round.tasks.tasks[0].started_at, Some(42));
}
#[test]
fn session_state_round_trips_through_json() {
let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
conv.safety_mode = Some(crate::runtime::SafetyMode::FullAccess);
conv.cumulative_token_usage = crate::domain::TokenUsageTotals {
prompt_tokens: 777,
..Default::default()
};
let round: ConversationHistory =
serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
assert_eq!(
round.safety_mode,
Some(crate::runtime::SafetyMode::FullAccess)
);
assert_eq!(round.cumulative_token_usage.total_tokens(), 777);
}
#[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 {
project_dir: dir.clone(),
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 saved_conversation_redacts_secrets_and_is_owner_only() {
let dir = std::env::temp_dir().join(format!("mermaid_conv_redact_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let _ = fs::create_dir_all(&dir);
let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
conv.messages = vec![
ChatMessage::user("read .env"),
ChatMessage::assistant("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
];
let store = ConversationManager {
project_dir: dir.clone(),
conversations_dir: dir.clone(),
compactions_dir: dir.clone(),
seen: Arc::new(Mutex::new(HashMap::new())),
};
store.save_conversation(&conv).expect("save");
let path = dir.join(format!("{}.json", conv.id));
let raw = fs::read_to_string(&path).expect("read");
assert!(
!raw.contains("sk-abcdefghijklmnop1234"),
"secret leaked to the conversation store: {raw}"
);
assert!(
raw.contains("[REDACTED]"),
"expected redaction marker: {raw}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"conversation file must be owner-only, got {mode:o}"
);
}
assert!(conv.messages[1].content.contains("sk-abcdefghijklmnop1234"));
let _ = fs::remove_dir_all(&dir);
}
#[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 sidecar_powers_metadata_listing() {
let dir = std::env::temp_dir().join("mermaid_test_meta_sidecar");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let mut conv = ConversationHistory::new("/tmp/proj".into(), "model".into(), Local::now());
conv.title = "My session".into();
conv.add_messages(
&[ChatMessage::user("hi"), ChatMessage::user("there")],
Local::now(),
);
manager.save_conversation(&conv).unwrap();
assert!(
manager
.conversations_dir()
.join(format!("{}.meta", conv.id))
.exists()
);
let metas = manager.list_conversation_metas().unwrap();
assert_eq!(metas.len(), 1);
assert_eq!(metas[0].id, conv.id);
assert_eq!(metas[0].title, "My session");
assert_eq!(metas[0].message_count, 2);
manager.delete_conversation(&conv.id).unwrap();
assert!(
!manager
.conversations_dir()
.join(format!("{}.meta", conv.id))
.exists()
);
assert!(manager.list_conversation_metas().unwrap().is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn metadata_listing_falls_back_to_full_parse_without_sidecar() {
let dir = std::env::temp_dir().join("mermaid_test_meta_fallback");
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("hi")], Local::now());
manager.save_conversation(&conv).unwrap();
fs::remove_file(
manager
.conversations_dir()
.join(format!("{}.meta", conv.id)),
)
.unwrap();
let metas = manager.list_conversation_metas().unwrap();
assert_eq!(metas.len(), 1, "falls back to parsing the .json");
assert_eq!(metas[0].message_count, 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn lineage_fields_default_on_old_sessions() {
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}"#;
let conv: ConversationHistory = serde_json::from_str(json).unwrap();
assert!(conv.git_sha.is_none());
assert!(conv.cli_version.is_none());
assert!(conv.forked_from.is_none());
assert!(conv.parent_session.is_none());
}
#[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 = touched("/tmp");
std::thread::sleep(std::time::Duration::from_millis(10));
let conv2 = touched("/tmp");
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 = touched("/tmp");
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 = touched("/tmp");
manager.save_conversation(&conv1).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let conv2 = touched("/tmp");
manager.save_conversation(&conv2).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let conv3 = touched("/tmp");
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 = touched("/tmp");
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 = touched("/tmp");
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 empty_session_is_not_saved() {
let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
manager.save_conversation(&conv).unwrap();
assert!(
manager.list_conversations().unwrap().is_empty(),
"empty session must not be listed"
);
assert!(
manager.load_last_conversation().unwrap().is_none(),
"empty session must not be --continue-able"
);
conv.add_messages(&[ChatMessage::user("hi")], Local::now());
manager.save_conversation(&conv).unwrap();
assert_eq!(manager.list_conversations().unwrap().len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn resume_paths_skip_pre_existing_empty_files() {
let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
let _ = fs::remove_dir_all(&dir);
let manager = ConversationManager::new(&dir).unwrap();
let real = touched("/tmp");
manager.save_conversation(&real).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
let path = manager
.conversations_dir()
.join(format!("{}.json", empty.id));
fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
let list = manager.list_conversations().unwrap();
assert_eq!(list.len(), 1, "the empty file must not be listed");
assert_eq!(list[0].id, real.id);
assert_eq!(
manager.load_last_conversation().unwrap().unwrap().id,
real.id,
"--continue must skip the newer empty file"
);
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);
}
}