Skip to main content

codemem_storage/
queries.rs

1//! Stats, consolidation, pattern queries, and session management on Storage.
2
3use crate::Storage;
4use codemem_core::{CodememError, ConsolidationLogEntry, Session, StorageStats};
5use rusqlite::params;
6use std::collections::HashMap;
7
8impl Storage {
9    // ── Health / Diagnostics ────────────────────────────────────────────
10
11    /// Run SQLite `PRAGMA integrity_check`. Returns `true` if the database is OK.
12    pub fn integrity_check(&self) -> Result<bool, CodememError> {
13        let conn = self.conn();
14        let result: String = conn
15            .query_row("PRAGMA integrity_check", [], |row| row.get(0))
16            .map_err(|e| CodememError::Storage(e.to_string()))?;
17        Ok(result == "ok")
18    }
19
20    /// Return the current schema version (max applied migration number).
21    pub fn schema_version(&self) -> Result<u32, CodememError> {
22        let conn = self.conn();
23        let version: u32 = conn
24            .query_row(
25                "SELECT COALESCE(MAX(version), 0) FROM schema_version",
26                [],
27                |row| row.get(0),
28            )
29            .map_err(|e| CodememError::Storage(e.to_string()))?;
30        Ok(version)
31    }
32
33    // ── Stats ───────────────────────────────────────────────────────────
34
35    /// Get database statistics.
36    pub fn stats(&self) -> Result<StorageStats, CodememError> {
37        let memory_count = self.memory_count()?;
38        let conn = self.conn();
39
40        let embedding_count: i64 = conn
41            .query_row("SELECT COUNT(*) FROM memory_embeddings", [], |row| {
42                row.get(0)
43            })
44            .map_err(|e| CodememError::Storage(e.to_string()))?;
45
46        let node_count: i64 = conn
47            .query_row("SELECT COUNT(*) FROM graph_nodes", [], |row| row.get(0))
48            .map_err(|e| CodememError::Storage(e.to_string()))?;
49
50        let edge_count: i64 = conn
51            .query_row("SELECT COUNT(*) FROM graph_edges", [], |row| row.get(0))
52            .map_err(|e| CodememError::Storage(e.to_string()))?;
53
54        Ok(StorageStats {
55            memory_count,
56            embedding_count: embedding_count as usize,
57            node_count: node_count as usize,
58            edge_count: edge_count as usize,
59        })
60    }
61
62    // ── Consolidation Log ──────────────────────────────────────────────
63
64    /// Record a consolidation run.
65    pub fn insert_consolidation_log(
66        &self,
67        cycle_type: &str,
68        affected_count: usize,
69    ) -> Result<(), CodememError> {
70        let conn = self.conn();
71        let now = chrono::Utc::now().timestamp();
72        conn.execute(
73            "INSERT INTO consolidation_log (cycle_type, run_at, affected_count) VALUES (?1, ?2, ?3)",
74            params![cycle_type, now, affected_count as i64],
75        )
76        .map_err(|e| CodememError::Storage(e.to_string()))?;
77        Ok(())
78    }
79
80    /// Get the last consolidation run for each cycle type.
81    pub fn last_consolidation_runs(&self) -> Result<Vec<ConsolidationLogEntry>, CodememError> {
82        let conn = self.conn();
83        let mut stmt = conn
84            .prepare(
85                "SELECT cycle_type, run_at, affected_count FROM consolidation_log
86                 WHERE id IN (
87                     SELECT id FROM consolidation_log c2
88                     WHERE c2.cycle_type = consolidation_log.cycle_type
89                     ORDER BY run_at DESC LIMIT 1
90                 )
91                 GROUP BY cycle_type
92                 ORDER BY cycle_type",
93            )
94            .map_err(|e| CodememError::Storage(e.to_string()))?;
95
96        let entries = stmt
97            .query_map([], |row| {
98                Ok(ConsolidationLogEntry {
99                    cycle_type: row.get(0)?,
100                    run_at: row.get(1)?,
101                    affected_count: row.get::<_, i64>(2)? as usize,
102                })
103            })
104            .map_err(|e| CodememError::Storage(e.to_string()))?
105            .collect::<Result<Vec<_>, _>>()
106            .map_err(|e| CodememError::Storage(e.to_string()))?;
107
108        Ok(entries)
109    }
110
111    // ── Pattern Detection Queries ───────────────────────────────────────
112
113    /// Find repeated search patterns (Grep/Glob) by extracting the "pattern" field
114    /// from memory metadata JSON. Returns (pattern, count, memory_ids) tuples where
115    /// count >= min_count, ordered by count descending.
116    pub fn get_repeated_searches(
117        &self,
118        min_count: usize,
119        namespace: Option<&str>,
120    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
121        let conn = self.conn();
122        let sql = if namespace.is_some() {
123            "SELECT json_extract(metadata, '$.pattern') AS pat,
124                    COUNT(*) AS cnt,
125                    GROUP_CONCAT(id, ',') AS ids
126             FROM memories
127             WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
128               AND pat IS NOT NULL
129               AND namespace = ?1
130             GROUP BY pat
131             HAVING cnt >= ?2
132             ORDER BY cnt DESC"
133        } else {
134            "SELECT json_extract(metadata, '$.pattern') AS pat,
135                    COUNT(*) AS cnt,
136                    GROUP_CONCAT(id, ',') AS ids
137             FROM memories
138             WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
139               AND pat IS NOT NULL
140             GROUP BY pat
141             HAVING cnt >= ?1
142             ORDER BY cnt DESC"
143        };
144
145        let mut stmt = conn
146            .prepare(sql)
147            .map_err(|e| CodememError::Storage(e.to_string()))?;
148
149        let rows = if let Some(ns) = namespace {
150            stmt.query_map(params![ns, min_count as i64], |row| {
151                Ok((
152                    row.get::<_, String>(0)?,
153                    row.get::<_, i64>(1)?,
154                    row.get::<_, String>(2)?,
155                ))
156            })
157            .map_err(|e| CodememError::Storage(e.to_string()))?
158            .collect::<Result<Vec<_>, _>>()
159            .map_err(|e| CodememError::Storage(e.to_string()))?
160        } else {
161            stmt.query_map(params![min_count as i64], |row| {
162                Ok((
163                    row.get::<_, String>(0)?,
164                    row.get::<_, i64>(1)?,
165                    row.get::<_, String>(2)?,
166                ))
167            })
168            .map_err(|e| CodememError::Storage(e.to_string()))?
169            .collect::<Result<Vec<_>, _>>()
170            .map_err(|e| CodememError::Storage(e.to_string()))?
171        };
172
173        Ok(rows
174            .into_iter()
175            .map(|(pat, cnt, ids_str)| {
176                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
177                (pat, cnt as usize, ids)
178            })
179            .collect())
180    }
181
182    /// Find file hotspots by extracting the "file_path" field from memory metadata.
183    pub fn get_file_hotspots(
184        &self,
185        min_count: usize,
186        namespace: Option<&str>,
187    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
188        let conn = self.conn();
189        let sql = if namespace.is_some() {
190            "SELECT json_extract(metadata, '$.file_path') AS fp,
191                    COUNT(*) AS cnt,
192                    GROUP_CONCAT(id, ',') AS ids
193             FROM memories
194             WHERE fp IS NOT NULL
195               AND namespace = ?1
196             GROUP BY fp
197             HAVING cnt >= ?2
198             ORDER BY cnt DESC"
199        } else {
200            "SELECT json_extract(metadata, '$.file_path') AS fp,
201                    COUNT(*) AS cnt,
202                    GROUP_CONCAT(id, ',') AS ids
203             FROM memories
204             WHERE fp IS NOT NULL
205             GROUP BY fp
206             HAVING cnt >= ?1
207             ORDER BY cnt DESC"
208        };
209
210        let mut stmt = conn
211            .prepare(sql)
212            .map_err(|e| CodememError::Storage(e.to_string()))?;
213
214        let rows = if let Some(ns) = namespace {
215            stmt.query_map(params![ns, min_count as i64], |row| {
216                Ok((
217                    row.get::<_, String>(0)?,
218                    row.get::<_, i64>(1)?,
219                    row.get::<_, String>(2)?,
220                ))
221            })
222            .map_err(|e| CodememError::Storage(e.to_string()))?
223            .collect::<Result<Vec<_>, _>>()
224            .map_err(|e| CodememError::Storage(e.to_string()))?
225        } else {
226            stmt.query_map(params![min_count as i64], |row| {
227                Ok((
228                    row.get::<_, String>(0)?,
229                    row.get::<_, i64>(1)?,
230                    row.get::<_, String>(2)?,
231                ))
232            })
233            .map_err(|e| CodememError::Storage(e.to_string()))?
234            .collect::<Result<Vec<_>, _>>()
235            .map_err(|e| CodememError::Storage(e.to_string()))?
236        };
237
238        Ok(rows
239            .into_iter()
240            .map(|(fp, cnt, ids_str)| {
241                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
242                (fp, cnt as usize, ids)
243            })
244            .collect())
245    }
246
247    /// Get tool usage statistics from memory metadata.
248    pub fn get_tool_usage_stats(
249        &self,
250        namespace: Option<&str>,
251    ) -> Result<HashMap<String, usize>, CodememError> {
252        let conn = self.conn();
253        let sql = if namespace.is_some() {
254            "SELECT json_extract(metadata, '$.tool') AS tool,
255                    COUNT(*) AS cnt
256             FROM memories
257             WHERE tool IS NOT NULL
258               AND namespace = ?1
259             GROUP BY tool
260             ORDER BY cnt DESC"
261        } else {
262            "SELECT json_extract(metadata, '$.tool') AS tool,
263                    COUNT(*) AS cnt
264             FROM memories
265             WHERE tool IS NOT NULL
266             GROUP BY tool
267             ORDER BY cnt DESC"
268        };
269
270        let mut stmt = conn
271            .prepare(sql)
272            .map_err(|e| CodememError::Storage(e.to_string()))?;
273
274        let rows = if let Some(ns) = namespace {
275            stmt.query_map(params![ns], |row| {
276                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
277            })
278            .map_err(|e| CodememError::Storage(e.to_string()))?
279            .collect::<Result<Vec<_>, _>>()
280            .map_err(|e| CodememError::Storage(e.to_string()))?
281        } else {
282            stmt.query_map([], |row| {
283                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
284            })
285            .map_err(|e| CodememError::Storage(e.to_string()))?
286            .collect::<Result<Vec<_>, _>>()
287            .map_err(|e| CodememError::Storage(e.to_string()))?
288        };
289
290        Ok(rows
291            .into_iter()
292            .map(|(tool, cnt)| (tool, cnt as usize))
293            .collect())
294    }
295
296    /// Find decision chains: files with multiple Edit/Write memories over time.
297    pub fn get_decision_chains(
298        &self,
299        min_count: usize,
300        namespace: Option<&str>,
301    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
302        let conn = self.conn();
303        let sql = if namespace.is_some() {
304            "SELECT json_extract(metadata, '$.file_path') AS fp,
305                    COUNT(*) AS cnt,
306                    GROUP_CONCAT(id, ',') AS ids
307             FROM memories
308             WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
309               AND fp IS NOT NULL
310               AND namespace = ?1
311             GROUP BY fp
312             HAVING cnt >= ?2
313             ORDER BY cnt DESC"
314        } else {
315            "SELECT json_extract(metadata, '$.file_path') AS fp,
316                    COUNT(*) AS cnt,
317                    GROUP_CONCAT(id, ',') AS ids
318             FROM memories
319             WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
320               AND fp IS NOT NULL
321             GROUP BY fp
322             HAVING cnt >= ?1
323             ORDER BY cnt DESC"
324        };
325
326        let mut stmt = conn
327            .prepare(sql)
328            .map_err(|e| CodememError::Storage(e.to_string()))?;
329
330        let rows = if let Some(ns) = namespace {
331            stmt.query_map(params![ns, min_count as i64], |row| {
332                Ok((
333                    row.get::<_, String>(0)?,
334                    row.get::<_, i64>(1)?,
335                    row.get::<_, String>(2)?,
336                ))
337            })
338            .map_err(|e| CodememError::Storage(e.to_string()))?
339            .collect::<Result<Vec<_>, _>>()
340            .map_err(|e| CodememError::Storage(e.to_string()))?
341        } else {
342            stmt.query_map(params![min_count as i64], |row| {
343                Ok((
344                    row.get::<_, String>(0)?,
345                    row.get::<_, i64>(1)?,
346                    row.get::<_, String>(2)?,
347                ))
348            })
349            .map_err(|e| CodememError::Storage(e.to_string()))?
350            .collect::<Result<Vec<_>, _>>()
351            .map_err(|e| CodememError::Storage(e.to_string()))?
352        };
353
354        Ok(rows
355            .into_iter()
356            .map(|(fp, cnt, ids_str)| {
357                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
358                (fp, cnt as usize, ids)
359            })
360            .collect())
361    }
362
363    // ── Session Management ─────────────────────────────────────────────
364
365    /// Start a new session.
366    pub fn start_session(&self, id: &str, namespace: Option<&str>) -> Result<(), CodememError> {
367        let conn = self.conn();
368        let now = chrono::Utc::now().timestamp();
369        conn.execute(
370            "INSERT OR IGNORE INTO sessions (id, namespace, started_at) VALUES (?1, ?2, ?3)",
371            params![id, namespace, now],
372        )
373        .map_err(|e| CodememError::Storage(e.to_string()))?;
374        Ok(())
375    }
376
377    /// End a session by setting ended_at and optionally a summary.
378    pub fn end_session(&self, id: &str, summary: Option<&str>) -> Result<(), CodememError> {
379        let conn = self.conn();
380        let now = chrono::Utc::now().timestamp();
381        conn.execute(
382            "UPDATE sessions SET ended_at = ?1, summary = ?2 WHERE id = ?3",
383            params![now, summary, id],
384        )
385        .map_err(|e| CodememError::Storage(e.to_string()))?;
386        Ok(())
387    }
388
389    /// List sessions, optionally filtered by namespace.
390    pub fn list_sessions(&self, namespace: Option<&str>) -> Result<Vec<Session>, CodememError> {
391        self.list_sessions_with_limit(namespace, usize::MAX)
392    }
393
394    /// List sessions with a limit.
395    pub(crate) fn list_sessions_with_limit(
396        &self,
397        namespace: Option<&str>,
398        limit: usize,
399    ) -> Result<Vec<Session>, CodememError> {
400        let conn = self.conn();
401        let sql_with_ns = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions WHERE namespace = ?1 ORDER BY started_at DESC LIMIT ?2";
402        let sql_all = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions ORDER BY started_at DESC LIMIT ?1";
403
404        let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Session> {
405            let started_ts: i64 = row.get(2)?;
406            let ended_ts: Option<i64> = row.get(3)?;
407            Ok(Session {
408                id: row.get(0)?,
409                namespace: row.get(1)?,
410                started_at: chrono::DateTime::from_timestamp(started_ts, 0)
411                    .unwrap_or_default()
412                    .with_timezone(&chrono::Utc),
413                ended_at: ended_ts.and_then(|ts| {
414                    chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.with_timezone(&chrono::Utc))
415                }),
416                memory_count: row.get::<_, i64>(4).unwrap_or(0) as u32,
417                summary: row.get(5)?,
418            })
419        };
420
421        if let Some(ns) = namespace {
422            let mut stmt = conn
423                .prepare(sql_with_ns)
424                .map_err(|e| CodememError::Storage(e.to_string()))?;
425            let rows = stmt
426                .query_map(params![ns, limit as i64], map_row)
427                .map_err(|e| CodememError::Storage(e.to_string()))?;
428            rows.collect::<Result<Vec<_>, _>>()
429                .map_err(|e| CodememError::Storage(e.to_string()))
430        } else {
431            let mut stmt = conn
432                .prepare(sql_all)
433                .map_err(|e| CodememError::Storage(e.to_string()))?;
434            let rows = stmt
435                .query_map(params![limit as i64], map_row)
436                .map_err(|e| CodememError::Storage(e.to_string()))?;
437            rows.collect::<Result<Vec<_>, _>>()
438                .map_err(|e| CodememError::Storage(e.to_string()))
439        }
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use crate::Storage;
446    use codemem_core::{MemoryNode, MemoryType};
447    use std::collections::HashMap;
448
449    fn test_memory_with_metadata(
450        content: &str,
451        tool: &str,
452        extra: HashMap<String, serde_json::Value>,
453    ) -> MemoryNode {
454        let now = chrono::Utc::now();
455        let mut metadata = extra;
456        metadata.insert(
457            "tool".to_string(),
458            serde_json::Value::String(tool.to_string()),
459        );
460        MemoryNode {
461            id: uuid::Uuid::new_v4().to_string(),
462            content: content.to_string(),
463            memory_type: MemoryType::Context,
464            importance: 0.5,
465            confidence: 1.0,
466            access_count: 0,
467            content_hash: Storage::content_hash(content),
468            tags: vec![],
469            metadata,
470            namespace: None,
471            created_at: now,
472            updated_at: now,
473            last_accessed_at: now,
474        }
475    }
476
477    #[test]
478    fn stats() {
479        let storage = Storage::open_in_memory().unwrap();
480        let stats = storage.stats().unwrap();
481        assert_eq!(stats.memory_count, 0);
482    }
483
484    #[test]
485    fn get_repeated_searches_groups_by_pattern() {
486        let storage = Storage::open_in_memory().unwrap();
487
488        for i in 0..3 {
489            let mut extra = HashMap::new();
490            extra.insert(
491                "pattern".to_string(),
492                serde_json::Value::String("error".to_string()),
493            );
494            let mem =
495                test_memory_with_metadata(&format!("grep search {i} for error"), "Grep", extra);
496            storage.insert_memory(&mem).unwrap();
497        }
498
499        let mut extra = HashMap::new();
500        extra.insert(
501            "pattern".to_string(),
502            serde_json::Value::String("*.rs".to_string()),
503        );
504        let mem = test_memory_with_metadata("glob search for rs files", "Glob", extra);
505        storage.insert_memory(&mem).unwrap();
506
507        let results = storage.get_repeated_searches(2, None).unwrap();
508        assert_eq!(results.len(), 1);
509        assert_eq!(results[0].0, "error");
510        assert_eq!(results[0].1, 3);
511        assert_eq!(results[0].2.len(), 3);
512
513        let results = storage.get_repeated_searches(1, None).unwrap();
514        assert_eq!(results.len(), 2);
515    }
516
517    #[test]
518    fn get_file_hotspots_groups_by_file_path() {
519        let storage = Storage::open_in_memory().unwrap();
520
521        for i in 0..4 {
522            let mut extra = HashMap::new();
523            extra.insert(
524                "file_path".to_string(),
525                serde_json::Value::String("src/main.rs".to_string()),
526            );
527            let mem =
528                test_memory_with_metadata(&format!("read main.rs attempt {i}"), "Read", extra);
529            storage.insert_memory(&mem).unwrap();
530        }
531
532        let mut extra = HashMap::new();
533        extra.insert(
534            "file_path".to_string(),
535            serde_json::Value::String("src/lib.rs".to_string()),
536        );
537        let mem = test_memory_with_metadata("read lib.rs", "Read", extra);
538        storage.insert_memory(&mem).unwrap();
539
540        let results = storage.get_file_hotspots(3, None).unwrap();
541        assert_eq!(results.len(), 1);
542        assert_eq!(results[0].0, "src/main.rs");
543        assert_eq!(results[0].1, 4);
544    }
545
546    #[test]
547    fn get_tool_usage_stats_counts_by_tool() {
548        let storage = Storage::open_in_memory().unwrap();
549
550        for i in 0..5 {
551            let mem = test_memory_with_metadata(&format!("read file {i}"), "Read", HashMap::new());
552            storage.insert_memory(&mem).unwrap();
553        }
554        for i in 0..3 {
555            let mem =
556                test_memory_with_metadata(&format!("grep search {i}"), "Grep", HashMap::new());
557            storage.insert_memory(&mem).unwrap();
558        }
559        let mem = test_memory_with_metadata("edit file", "Edit", HashMap::new());
560        storage.insert_memory(&mem).unwrap();
561
562        let stats = storage.get_tool_usage_stats(None).unwrap();
563        assert_eq!(stats.get("Read"), Some(&5));
564        assert_eq!(stats.get("Grep"), Some(&3));
565        assert_eq!(stats.get("Edit"), Some(&1));
566    }
567
568    #[test]
569    fn get_decision_chains_groups_edits_by_file() {
570        let storage = Storage::open_in_memory().unwrap();
571
572        for i in 0..3 {
573            let mut extra = HashMap::new();
574            extra.insert(
575                "file_path".to_string(),
576                serde_json::Value::String("src/main.rs".to_string()),
577            );
578            let mem = test_memory_with_metadata(&format!("edit main.rs {i}"), "Edit", extra);
579            storage.insert_memory(&mem).unwrap();
580        }
581
582        let mut extra = HashMap::new();
583        extra.insert(
584            "file_path".to_string(),
585            serde_json::Value::String("src/new.rs".to_string()),
586        );
587        let mem = test_memory_with_metadata("write new.rs", "Write", extra);
588        storage.insert_memory(&mem).unwrap();
589
590        let results = storage.get_decision_chains(2, None).unwrap();
591        assert_eq!(results.len(), 1);
592        assert_eq!(results[0].0, "src/main.rs");
593        assert_eq!(results[0].1, 3);
594    }
595
596    #[test]
597    fn pattern_queries_empty_db() {
598        let storage = Storage::open_in_memory().unwrap();
599
600        let searches = storage.get_repeated_searches(1, None).unwrap();
601        assert!(searches.is_empty());
602
603        let hotspots = storage.get_file_hotspots(1, None).unwrap();
604        assert!(hotspots.is_empty());
605
606        let stats = storage.get_tool_usage_stats(None).unwrap();
607        assert!(stats.is_empty());
608
609        let chains = storage.get_decision_chains(1, None).unwrap();
610        assert!(chains.is_empty());
611    }
612
613    #[test]
614    fn pattern_queries_with_namespace_filter() {
615        let storage = Storage::open_in_memory().unwrap();
616
617        for i in 0..3 {
618            let mut extra = HashMap::new();
619            extra.insert(
620                "pattern".to_string(),
621                serde_json::Value::String("error".to_string()),
622            );
623            let mut mem = test_memory_with_metadata(&format!("ns-a grep {i}"), "Grep", extra);
624            mem.namespace = Some("project-a".to_string());
625            storage.insert_memory(&mem).unwrap();
626        }
627
628        for i in 0..2 {
629            let mut extra = HashMap::new();
630            extra.insert(
631                "pattern".to_string(),
632                serde_json::Value::String("error".to_string()),
633            );
634            let mut mem = test_memory_with_metadata(&format!("ns-b grep {i}"), "Grep", extra);
635            mem.namespace = Some("project-b".to_string());
636            storage.insert_memory(&mem).unwrap();
637        }
638
639        let results = storage.get_repeated_searches(1, None).unwrap();
640        assert_eq!(results.len(), 1);
641        assert_eq!(results[0].1, 5);
642
643        let results = storage.get_repeated_searches(1, Some("project-a")).unwrap();
644        assert_eq!(results.len(), 1);
645        assert_eq!(results[0].1, 3);
646    }
647
648    // ── Session Management Tests ────────────────────────────────────────
649
650    #[test]
651    fn session_lifecycle() {
652        let storage = Storage::open_in_memory().unwrap();
653
654        storage.start_session("sess-1", Some("my-project")).unwrap();
655
656        let sessions = storage.list_sessions(Some("my-project")).unwrap();
657        assert_eq!(sessions.len(), 1);
658        assert_eq!(sessions[0].id, "sess-1");
659        assert_eq!(sessions[0].namespace, Some("my-project".to_string()));
660        assert!(sessions[0].ended_at.is_none());
661
662        storage
663            .end_session("sess-1", Some("Explored the codebase"))
664            .unwrap();
665
666        let sessions = storage.list_sessions(None).unwrap();
667        assert_eq!(sessions.len(), 1);
668        assert!(sessions[0].ended_at.is_some());
669        assert_eq!(
670            sessions[0].summary,
671            Some("Explored the codebase".to_string())
672        );
673    }
674
675    #[test]
676    fn list_sessions_filters_by_namespace() {
677        let storage = Storage::open_in_memory().unwrap();
678
679        storage.start_session("sess-a", Some("project-a")).unwrap();
680        storage.start_session("sess-b", Some("project-b")).unwrap();
681        storage.start_session("sess-c", None).unwrap();
682
683        let all = storage.list_sessions(None).unwrap();
684        assert_eq!(all.len(), 3);
685
686        let proj_a = storage.list_sessions(Some("project-a")).unwrap();
687        assert_eq!(proj_a.len(), 1);
688        assert_eq!(proj_a[0].id, "sess-a");
689    }
690
691    #[test]
692    fn start_session_ignores_duplicate() {
693        let storage = Storage::open_in_memory().unwrap();
694        storage.start_session("sess-1", Some("ns")).unwrap();
695        storage.start_session("sess-1", Some("ns")).unwrap();
696
697        let sessions = storage.list_sessions(None).unwrap();
698        assert_eq!(sessions.len(), 1);
699    }
700}