use std::path::Path;
use mermaid_domain::ConversationHistory;
use mermaid_runtime::{NewSession, RuntimeStore};
use super::ConversationManager;
#[must_use]
pub fn session_row(conversations_dir: &Path, snapshot: &ConversationHistory) -> NewSession {
let conversation_path = conversations_dir.join(format!("{}.json", snapshot.id));
NewSession {
id: Some(snapshot.id.clone()),
project_path: snapshot.project_path.clone(),
model_id: snapshot.model_name.clone(),
title: Some(snapshot.title.clone()),
conversation_path: Some(conversation_path.display().to_string()),
total_tokens: Some(
i64::try_from(snapshot.cumulative_token_usage.total_tokens()).unwrap_or(i64::MAX),
),
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SessionIndexReport {
pub projects: usize,
pub backfilled: usize,
pub pruned: usize,
}
pub fn rebuild_session_index(store: &RuntimeStore) -> anyhow::Result<SessionIndexReport> {
let mut report = SessionIndexReport::default();
for project in store.known_project_paths()? {
let project_dir = Path::new(&project);
if !project_dir.is_dir() {
continue;
}
report.projects += 1;
let manager = match ConversationManager::new(project_dir) {
Ok(manager) => manager,
Err(error) => {
tracing::warn!(project = %project, %error, "session index: cannot open project");
continue;
},
};
let on_disk: Vec<String> = match manager.list_conversation_metas() {
Ok(metas) => metas.into_iter().map(|meta| meta.id).collect(),
Err(error) => {
tracing::warn!(project = %project, %error, "session index: cannot list sessions");
continue;
},
};
let indexed = store.sessions().ids_for_project(&project)?;
for id in &on_disk {
if indexed.contains(id) {
continue;
}
let snapshot = match manager.load_conversation(id) {
Ok(snapshot) => snapshot,
Err(error) => {
tracing::warn!(project = %project, session = %id, %error, "session index: cannot load session");
continue;
},
};
store
.sessions()
.upsert(session_row(manager.conversations_dir(), &snapshot))?;
report.backfilled += 1;
}
for id in indexed {
if on_disk.contains(&id) {
continue;
}
if store.sessions().delete(&id)? {
report.pruned += 1;
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
use mermaid_model::models::ChatMessage;
use mermaid_runtime::NewTask;
fn temp_store(name: &str) -> RuntimeStore {
let dir = std::env::temp_dir().join(format!(
"mermaid_session_index_{name}_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
RuntimeStore::open(dir.join("runtime.sqlite3")).unwrap()
}
fn temp_project(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"mermaid_session_index_proj_{name}_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn saved_session(project: &std::path::Path) -> (ConversationManager, ConversationHistory) {
let manager = ConversationManager::new(project).unwrap();
let mut conv = ConversationHistory::new(
project.display().to_string(),
"test/model".into(),
Local::now(),
);
conv.add_messages(&[ChatMessage::user("hi")], Local::now());
manager.append_session_events(&conv, &[]).unwrap();
(manager, conv)
}
#[test]
fn rebuild_backfills_a_session_that_has_files_but_no_row() {
let store = temp_store("backfill");
let project = temp_project("backfill");
let (manager, conv) = saved_session(&project);
assert!(
!manager
.conversations_dir()
.join(format!("{}.json", conv.id))
.exists()
);
store
.tasks()
.create(NewTask::new(
"t",
project.display().to_string(),
"test/model",
))
.unwrap();
assert!(store.sessions().get(&conv.id).unwrap().is_none());
let report = rebuild_session_index(&store).unwrap();
assert_eq!(report.backfilled, 1, "{report:?}");
assert_eq!(report.pruned, 0);
let row = store
.sessions()
.get(&conv.id)
.unwrap()
.expect("backfilled row");
assert_eq!(row.model_id, "test/model");
assert_eq!(row.project_path, project.display().to_string());
let _ = std::fs::remove_dir_all(&project);
}
#[test]
fn rebuild_prunes_a_row_whose_files_are_gone() {
let store = temp_store("prune");
let project = temp_project("prune");
store
.sessions()
.upsert(NewSession {
id: Some("20260101_000000_gone".to_string()),
project_path: project.display().to_string(),
model_id: "m".to_string(),
title: None,
conversation_path: None,
total_tokens: None,
})
.unwrap();
let report = rebuild_session_index(&store).unwrap();
assert_eq!(report.pruned, 1, "{report:?}");
assert!(
store
.sessions()
.get("20260101_000000_gone")
.unwrap()
.is_none()
);
let _ = std::fs::remove_dir_all(&project);
}
#[test]
fn rebuild_leaves_rows_of_unreachable_projects_alone() {
let store = temp_store("unreachable");
store
.sessions()
.upsert(NewSession {
id: Some("20260101_000000_far".to_string()),
project_path: "/definitely/not/mounted/here".to_string(),
model_id: "m".to_string(),
title: None,
conversation_path: None,
total_tokens: None,
})
.unwrap();
let report = rebuild_session_index(&store).unwrap();
assert_eq!(report, SessionIndexReport::default());
assert!(
store
.sessions()
.get("20260101_000000_far")
.unwrap()
.is_some()
);
}
#[test]
fn session_row_is_the_single_index_mapping() {
let project = temp_project("row");
let (manager, conv) = saved_session(&project);
let row = session_row(manager.conversations_dir(), &conv);
assert_eq!(row.id.as_deref(), Some(conv.id.as_str()));
assert_eq!(row.project_path, conv.project_path);
assert_eq!(row.model_id, "test/model");
assert_eq!(row.title.as_deref(), Some(conv.title.as_str()));
assert!(
row.conversation_path
.unwrap()
.ends_with(&format!("{}.json", conv.id))
);
let _ = std::fs::remove_dir_all(&project);
}
}