Skip to main content

ryo_storage/storage/
index.rs

1//! Session metadata index.
2//!
3//! Maintains a lightweight index of all stored sessions for fast queries
4//! without loading full session files.
5
6use crate::txlog::TxLog;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11/// Metadata for a stored session.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SessionMeta {
14    /// Unique session identifier.
15    pub session_id: String,
16    /// Project path this session operated on.
17    pub project_path: PathBuf,
18    /// When the session started (ISO 8601).
19    pub started_at: String,
20    /// When the session ended (ISO 8601), if applicable.
21    pub ended_at: Option<String>,
22    /// Number of entries in the session.
23    pub entry_count: usize,
24    /// Number of mutations applied.
25    pub mutation_count: usize,
26    /// Number of files modified.
27    pub files_modified: usize,
28    /// Total changes made.
29    pub total_changes: usize,
30    /// Optional session name/description.
31    pub name: Option<String>,
32    /// Tags for categorization.
33    #[serde(default)]
34    pub tags: Vec<String>,
35}
36
37impl SessionMeta {
38    /// Create metadata from a TxLog.
39    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    /// Add a name to this session.
57    pub fn with_name(mut self, name: impl Into<String>) -> Self {
58        self.name = Some(name.into());
59        self
60    }
61
62    /// Add tags to this session.
63    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
64        self.tags = tags;
65        self
66    }
67
68    /// Check if this session matches a project path.
69    pub fn matches_project(&self, path: &Path) -> bool {
70        self.project_path == path
71    }
72}
73
74/// Index of all stored sessions.
75#[derive(Debug, Clone, Default, Serialize)]
76pub struct SessionIndex {
77    /// All sessions, keyed by session ID.
78    sessions: HashMap<String, SessionMeta>,
79    /// Sessions grouped by project path (for fast lookup).
80    #[serde(skip)]
81    by_project: HashMap<PathBuf, Vec<String>>,
82    /// Index version for future migrations.
83    #[serde(default = "default_version")]
84    version: u32,
85}
86
87fn default_version() -> u32 {
88    1
89}
90
91impl SessionIndex {
92    /// Create a new empty index.
93    pub fn new() -> Self {
94        Self {
95            sessions: HashMap::new(),
96            by_project: HashMap::new(),
97            version: 1,
98        }
99    }
100
101    /// Add a session to the index.
102    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    /// Remove a session from the index.
115    pub fn remove(&mut self, session_id: &str) -> Option<SessionMeta> {
116        if let Some(meta) = self.sessions.remove(session_id) {
117            // Remove from project index
118            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    /// Get a session by ID.
131    pub fn get(&self, session_id: &str) -> Option<&SessionMeta> {
132        self.sessions.get(session_id)
133    }
134
135    /// List all sessions, sorted by start time (newest first).
136    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    /// Get sessions for a specific project.
143    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    /// Get the most recent session.
154    pub fn latest(&self) -> Option<&SessionMeta> {
155        self.list().into_iter().next()
156    }
157
158    /// Get the most recent session for a project.
159    pub fn latest_for_project(&self, project_path: &Path) -> Option<&SessionMeta> {
160        self.by_project(project_path).into_iter().next()
161    }
162
163    /// Get all unique project paths.
164    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    /// Count total sessions.
177    pub fn count(&self) -> usize {
178        self.sessions.len()
179    }
180
181    /// Count sessions for a project.
182    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    /// Cleanup old sessions, keeping only the N most recent per project.
190    ///
191    /// Returns the list of session IDs that were removed.
192    pub fn cleanup(&mut self, keep_per_project: usize) -> Vec<String> {
193        let mut to_remove = Vec::new();
194
195        // Group by project and find sessions to remove
196        let projects: Vec<_> = self.projects().into_iter().cloned().collect();
197
198        for project in projects {
199            let mut sessions = self.by_project(&project);
200            // Already sorted newest first
201            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        // Remove from index
209        for session_id in &to_remove {
210            self.remove(session_id);
211        }
212
213        to_remove
214    }
215
216    /// Rebuild the by_project index (call after deserialization).
217    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    /// Search sessions by tags.
228    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    /// Search sessions by name pattern.
236    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
250// Custom deserialize to rebuild project index
251impl<'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"); // Newest first
326    }
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        // Should keep 2 per project, remove oldest
340        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        // Project index should be rebuilt
369        assert_eq!(restored.by_project(Path::new("/project/a")).len(), 1);
370    }
371}