1use crate::txlog::TxLog;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SessionMeta {
14 pub session_id: String,
16 pub project_path: PathBuf,
18 pub started_at: String,
20 pub ended_at: Option<String>,
22 pub entry_count: usize,
24 pub mutation_count: usize,
26 pub files_modified: usize,
28 pub total_changes: usize,
30 pub name: Option<String>,
32 #[serde(default)]
34 pub tags: Vec<String>,
35}
36
37impl SessionMeta {
38 pub fn from_log(log: &TxLog) -> Self {
40 let summary = log.summary();
41
42 Self {
43 session_id: log.session_id.clone(),
44 project_path: PathBuf::from(&log.project_path),
45 started_at: log.started_at.clone(),
46 ended_at: log.ended_at.clone(),
47 entry_count: log.entries().len(),
48 mutation_count: summary.total_mutations,
49 files_modified: summary.files_modified,
50 total_changes: summary.total_changes,
51 name: None,
52 tags: Vec::new(),
53 }
54 }
55
56 pub fn with_name(mut self, name: impl Into<String>) -> Self {
58 self.name = Some(name.into());
59 self
60 }
61
62 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
64 self.tags = tags;
65 self
66 }
67
68 pub fn matches_project(&self, path: &Path) -> bool {
70 self.project_path == path
71 }
72}
73
74#[derive(Debug, Clone, Default, Serialize)]
76pub struct SessionIndex {
77 sessions: HashMap<String, SessionMeta>,
79 #[serde(skip)]
81 by_project: HashMap<PathBuf, Vec<String>>,
82 #[serde(default = "default_version")]
84 version: u32,
85}
86
87fn default_version() -> u32 {
88 1
89}
90
91impl SessionIndex {
92 pub fn new() -> Self {
94 Self {
95 sessions: HashMap::new(),
96 by_project: HashMap::new(),
97 version: 1,
98 }
99 }
100
101 pub fn add(&mut self, meta: SessionMeta) {
103 let session_id = meta.session_id.clone();
104 let project_path = meta.project_path.clone();
105
106 self.sessions.insert(session_id.clone(), meta);
107
108 self.by_project
109 .entry(project_path)
110 .or_default()
111 .push(session_id);
112 }
113
114 pub fn remove(&mut self, session_id: &str) -> Option<SessionMeta> {
116 if let Some(meta) = self.sessions.remove(session_id) {
117 if let Some(project_sessions) = self.by_project.get_mut(&meta.project_path) {
119 project_sessions.retain(|id| id != session_id);
120 if project_sessions.is_empty() {
121 self.by_project.remove(&meta.project_path);
122 }
123 }
124 Some(meta)
125 } else {
126 None
127 }
128 }
129
130 pub fn get(&self, session_id: &str) -> Option<&SessionMeta> {
132 self.sessions.get(session_id)
133 }
134
135 pub fn list(&self) -> Vec<&SessionMeta> {
137 let mut sessions: Vec<_> = self.sessions.values().collect();
138 sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
139 sessions
140 }
141
142 pub fn by_project(&self, project_path: &Path) -> Vec<&SessionMeta> {
144 let mut sessions: Vec<_> = self
145 .sessions
146 .values()
147 .filter(|m| m.matches_project(project_path))
148 .collect();
149 sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
150 sessions
151 }
152
153 pub fn latest(&self) -> Option<&SessionMeta> {
155 self.list().into_iter().next()
156 }
157
158 pub fn latest_for_project(&self, project_path: &Path) -> Option<&SessionMeta> {
160 self.by_project(project_path).into_iter().next()
161 }
162
163 pub fn projects(&self) -> Vec<&PathBuf> {
165 let mut paths: Vec<_> = self
166 .sessions
167 .values()
168 .map(|m| &m.project_path)
169 .collect::<std::collections::HashSet<_>>()
170 .into_iter()
171 .collect();
172 paths.sort();
173 paths
174 }
175
176 pub fn count(&self) -> usize {
178 self.sessions.len()
179 }
180
181 pub fn count_for_project(&self, project_path: &Path) -> usize {
183 self.sessions
184 .values()
185 .filter(|m| m.matches_project(project_path))
186 .count()
187 }
188
189 pub fn cleanup(&mut self, keep_per_project: usize) -> Vec<String> {
193 let mut to_remove = Vec::new();
194
195 let projects: Vec<_> = self.projects().into_iter().cloned().collect();
197
198 for project in projects {
199 let mut sessions = self.by_project(&project);
200 if sessions.len() > keep_per_project {
202 for meta in sessions.drain(keep_per_project..) {
203 to_remove.push(meta.session_id.clone());
204 }
205 }
206 }
207
208 for session_id in &to_remove {
210 self.remove(session_id);
211 }
212
213 to_remove
214 }
215
216 pub fn rebuild_project_index(&mut self) {
218 self.by_project.clear();
219 for (session_id, meta) in &self.sessions {
220 self.by_project
221 .entry(meta.project_path.clone())
222 .or_default()
223 .push(session_id.clone());
224 }
225 }
226
227 pub fn by_tags(&self, tags: &[String]) -> Vec<&SessionMeta> {
229 self.sessions
230 .values()
231 .filter(|m| tags.iter().any(|t| m.tags.contains(t)))
232 .collect()
233 }
234
235 pub fn by_name_contains(&self, pattern: &str) -> Vec<&SessionMeta> {
237 let pattern_lower = pattern.to_lowercase();
238 self.sessions
239 .values()
240 .filter(|m| {
241 m.name
242 .as_ref()
243 .map(|n| n.to_lowercase().contains(&pattern_lower))
244 .unwrap_or(false)
245 })
246 .collect()
247 }
248}
249
250impl<'de> serde::de::Deserialize<'de> for SessionIndex {
252 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
253 where
254 D: serde::de::Deserializer<'de>,
255 {
256 #[derive(Deserialize)]
257 struct IndexData {
258 sessions: HashMap<String, SessionMeta>,
259 #[serde(default = "default_version")]
260 version: u32,
261 }
262
263 let data = IndexData::deserialize(deserializer)?;
264 let mut index = SessionIndex {
265 sessions: data.sessions,
266 by_project: HashMap::new(),
267 version: data.version,
268 };
269 index.rebuild_project_index();
270 Ok(index)
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 fn create_test_meta(id: &str, project: &str, time: &str) -> SessionMeta {
279 SessionMeta {
280 session_id: id.to_string(),
281 project_path: PathBuf::from(project),
282 started_at: time.to_string(),
283 ended_at: None,
284 entry_count: 10,
285 mutation_count: 5,
286 files_modified: 3,
287 total_changes: 15,
288 name: None,
289 tags: Vec::new(),
290 }
291 }
292
293 #[test]
294 fn test_add_and_get() {
295 let mut index = SessionIndex::new();
296 let meta = create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z");
297 index.add(meta);
298
299 assert!(index.get("s1").is_some());
300 assert!(index.get("s2").is_none());
301 }
302
303 #[test]
304 fn test_list_sorted() {
305 let mut index = SessionIndex::new();
306 index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
307 index.add(create_test_meta("s2", "/project/a", "2024-01-02T10:00:00Z"));
308 index.add(create_test_meta("s3", "/project/a", "2024-01-01T15:00:00Z"));
309
310 let list = index.list();
311 assert_eq!(list[0].session_id, "s2");
312 assert_eq!(list[1].session_id, "s3");
313 assert_eq!(list[2].session_id, "s1");
314 }
315
316 #[test]
317 fn test_by_project() {
318 let mut index = SessionIndex::new();
319 index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
320 index.add(create_test_meta("s2", "/project/b", "2024-01-02T10:00:00Z"));
321 index.add(create_test_meta("s3", "/project/a", "2024-01-03T10:00:00Z"));
322
323 let proj_a = index.by_project(Path::new("/project/a"));
324 assert_eq!(proj_a.len(), 2);
325 assert_eq!(proj_a[0].session_id, "s3"); }
327
328 #[test]
329 fn test_cleanup() {
330 let mut index = SessionIndex::new();
331 index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
332 index.add(create_test_meta("s2", "/project/a", "2024-01-02T10:00:00Z"));
333 index.add(create_test_meta("s3", "/project/a", "2024-01-03T10:00:00Z"));
334 index.add(create_test_meta("s4", "/project/b", "2024-01-01T10:00:00Z"));
335 index.add(create_test_meta("s5", "/project/b", "2024-01-02T10:00:00Z"));
336
337 let removed = index.cleanup(2);
338
339 assert_eq!(removed.len(), 1);
341 assert!(removed.contains(&"s1".to_string()));
342 assert_eq!(index.count(), 4);
343 }
344
345 #[test]
346 fn test_remove() {
347 let mut index = SessionIndex::new();
348 index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
349
350 let removed = index.remove("s1");
351 assert!(removed.is_some());
352 assert!(index.get("s1").is_none());
353 }
354
355 #[test]
356 fn test_serialization_roundtrip() {
357 let mut index = SessionIndex::new();
358 index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
359 index.add(create_test_meta("s2", "/project/b", "2024-01-02T10:00:00Z"));
360
361 let json = serde_json::to_string(&index).unwrap();
362 let restored: SessionIndex = serde_json::from_str(&json).unwrap();
363
364 assert_eq!(restored.count(), 2);
365 assert!(restored.get("s1").is_some());
366 assert!(restored.get("s2").is_some());
367
368 assert_eq!(restored.by_project(Path::new("/project/a")).len(), 1);
370 }
371}