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