mermaid_cli/session/
index.rs1use std::path::Path;
13
14use mermaid_domain::ConversationHistory;
15use mermaid_runtime::{NewSession, RuntimeStore};
16
17use super::ConversationManager;
18
19#[must_use]
22pub fn session_row(conversations_dir: &Path, snapshot: &ConversationHistory) -> NewSession {
23 let conversation_path = conversations_dir.join(format!("{}.json", snapshot.id));
24 NewSession {
25 id: Some(snapshot.id.clone()),
26 project_path: snapshot.project_path.clone(),
30 model_id: snapshot.model_name.clone(),
31 title: Some(snapshot.title.clone()),
32 conversation_path: Some(conversation_path.display().to_string()),
33 total_tokens: Some(
36 i64::try_from(snapshot.cumulative_token_usage.total_tokens()).unwrap_or(i64::MAX),
37 ),
38 }
39}
40
41#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
43pub struct SessionIndexReport {
44 pub projects: usize,
46 pub backfilled: usize,
48 pub pruned: usize,
50}
51
52pub fn rebuild_session_index(store: &RuntimeStore) -> anyhow::Result<SessionIndexReport> {
63 let mut report = SessionIndexReport::default();
64 for project in store.known_project_paths()? {
65 let project_dir = Path::new(&project);
66 if !project_dir.is_dir() {
67 continue;
68 }
69 report.projects += 1;
70 let manager = match ConversationManager::new(project_dir) {
71 Ok(manager) => manager,
72 Err(error) => {
73 tracing::warn!(project = %project, %error, "session index: cannot open project");
74 continue;
75 },
76 };
77 let on_disk: Vec<String> = match manager.list_conversation_metas() {
78 Ok(metas) => metas.into_iter().map(|meta| meta.id).collect(),
79 Err(error) => {
80 tracing::warn!(project = %project, %error, "session index: cannot list sessions");
81 continue;
82 },
83 };
84 let indexed = store.sessions().ids_for_project(&project)?;
85 for id in &on_disk {
86 if indexed.contains(id) {
87 continue;
88 }
89 let snapshot = match manager.load_conversation(id) {
90 Ok(snapshot) => snapshot,
91 Err(error) => {
92 tracing::warn!(project = %project, session = %id, %error, "session index: cannot load session");
93 continue;
94 },
95 };
96 store
97 .sessions()
98 .upsert(session_row(manager.conversations_dir(), &snapshot))?;
99 report.backfilled += 1;
100 }
101 for id in indexed {
102 if on_disk.contains(&id) {
103 continue;
104 }
105 if store.sessions().delete(&id)? {
106 report.pruned += 1;
107 }
108 }
109 }
110 Ok(report)
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use chrono::Local;
117 use mermaid_model::models::ChatMessage;
118 use mermaid_runtime::NewTask;
119
120 fn temp_store(name: &str) -> RuntimeStore {
121 let dir = std::env::temp_dir().join(format!(
122 "mermaid_session_index_{name}_{}",
123 std::process::id()
124 ));
125 let _ = std::fs::remove_dir_all(&dir);
126 std::fs::create_dir_all(&dir).unwrap();
127 RuntimeStore::open(dir.join("runtime.sqlite3")).unwrap()
128 }
129
130 fn temp_project(name: &str) -> std::path::PathBuf {
131 let dir = std::env::temp_dir().join(format!(
132 "mermaid_session_index_proj_{name}_{}",
133 std::process::id()
134 ));
135 let _ = std::fs::remove_dir_all(&dir);
136 std::fs::create_dir_all(&dir).unwrap();
137 dir
138 }
139
140 fn saved_session(project: &std::path::Path) -> (ConversationManager, ConversationHistory) {
143 let manager = ConversationManager::new(project).unwrap();
144 let mut conv = ConversationHistory::new(
145 project.display().to_string(),
146 "test/model".into(),
147 Local::now(),
148 );
149 conv.add_messages(&[ChatMessage::user("hi")], Local::now());
150 manager.append_session_events(&conv, &[]).unwrap();
154 (manager, conv)
155 }
156
157 #[test]
161 fn rebuild_backfills_a_session_that_has_files_but_no_row() {
162 let store = temp_store("backfill");
163 let project = temp_project("backfill");
164 let (manager, conv) = saved_session(&project);
165 assert!(
166 !manager
167 .conversations_dir()
168 .join(format!("{}.json", conv.id))
169 .exists()
170 );
171 store
172 .tasks()
173 .create(NewTask::new(
174 "t",
175 project.display().to_string(),
176 "test/model",
177 ))
178 .unwrap();
179 assert!(store.sessions().get(&conv.id).unwrap().is_none());
180
181 let report = rebuild_session_index(&store).unwrap();
182 assert_eq!(report.backfilled, 1, "{report:?}");
183 assert_eq!(report.pruned, 0);
184 let row = store
185 .sessions()
186 .get(&conv.id)
187 .unwrap()
188 .expect("backfilled row");
189 assert_eq!(row.model_id, "test/model");
190 assert_eq!(row.project_path, project.display().to_string());
191 let _ = std::fs::remove_dir_all(&project);
192 }
193
194 #[test]
195 fn rebuild_prunes_a_row_whose_files_are_gone() {
196 let store = temp_store("prune");
197 let project = temp_project("prune");
198 store
199 .sessions()
200 .upsert(NewSession {
201 id: Some("20260101_000000_gone".to_string()),
202 project_path: project.display().to_string(),
203 model_id: "m".to_string(),
204 title: None,
205 conversation_path: None,
206 total_tokens: None,
207 })
208 .unwrap();
209 let report = rebuild_session_index(&store).unwrap();
210 assert_eq!(report.pruned, 1, "{report:?}");
211 assert!(
212 store
213 .sessions()
214 .get("20260101_000000_gone")
215 .unwrap()
216 .is_none()
217 );
218 let _ = std::fs::remove_dir_all(&project);
219 }
220
221 #[test]
222 fn rebuild_leaves_rows_of_unreachable_projects_alone() {
223 let store = temp_store("unreachable");
224 store
225 .sessions()
226 .upsert(NewSession {
227 id: Some("20260101_000000_far".to_string()),
228 project_path: "/definitely/not/mounted/here".to_string(),
229 model_id: "m".to_string(),
230 title: None,
231 conversation_path: None,
232 total_tokens: None,
233 })
234 .unwrap();
235 let report = rebuild_session_index(&store).unwrap();
236 assert_eq!(report, SessionIndexReport::default());
237 assert!(
238 store
239 .sessions()
240 .get("20260101_000000_far")
241 .unwrap()
242 .is_some()
243 );
244 }
245
246 #[test]
247 fn session_row_is_the_single_index_mapping() {
248 let project = temp_project("row");
249 let (manager, conv) = saved_session(&project);
250 let row = session_row(manager.conversations_dir(), &conv);
251 assert_eq!(row.id.as_deref(), Some(conv.id.as_str()));
252 assert_eq!(row.project_path, conv.project_path);
253 assert_eq!(row.model_id, "test/model");
254 assert_eq!(row.title.as_deref(), Some(conv.title.as_str()));
255 assert!(
256 row.conversation_path
257 .unwrap()
258 .ends_with(&format!("{}.json", conv.id))
259 );
260 let _ = std::fs::remove_dir_all(&project);
261 }
262}