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    /// Start a new session.
342    pub fn start_session(&self, id: &str, namespace: Option<&str>) -> Result<(), CodememError> {
343        let conn = self.conn();
344        let now = chrono::Utc::now().timestamp();
345        conn.execute(
346            "INSERT OR IGNORE INTO sessions (id, namespace, started_at) VALUES (?1, ?2, ?3)",
347            params![id, namespace, now],
348        )
349        .map_err(|e| CodememError::Storage(e.to_string()))?;
350        Ok(())
351    }
352
353    /// End a session by setting ended_at and optionally a summary.
354    pub fn end_session(&self, id: &str, summary: Option<&str>) -> Result<(), CodememError> {
355        let conn = self.conn();
356        let now = chrono::Utc::now().timestamp();
357        conn.execute(
358            "UPDATE sessions SET ended_at = ?1, summary = ?2 WHERE id = ?3",
359            params![now, summary, id],
360        )
361        .map_err(|e| CodememError::Storage(e.to_string()))?;
362        Ok(())
363    }
364
365    /// List sessions, optionally filtered by namespace.
366    pub fn list_sessions(&self, namespace: Option<&str>) -> Result<Vec<Session>, CodememError> {
367        self.list_sessions_with_limit(namespace, usize::MAX)
368    }
369
370    /// List sessions with a limit.
371    pub(crate) fn list_sessions_with_limit(
372        &self,
373        namespace: Option<&str>,
374        limit: usize,
375    ) -> Result<Vec<Session>, CodememError> {
376        let conn = self.conn();
377        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";
378        let sql_all = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions ORDER BY started_at DESC LIMIT ?1";
379
380        let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Session> {
381            let started_ts: i64 = row.get(2)?;
382            let ended_ts: Option<i64> = row.get(3)?;
383            Ok(Session {
384                id: row.get(0)?,
385                namespace: row.get(1)?,
386                started_at: chrono::DateTime::from_timestamp(started_ts, 0)
387                    .unwrap_or_default()
388                    .with_timezone(&chrono::Utc),
389                ended_at: ended_ts.and_then(|ts| {
390                    chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.with_timezone(&chrono::Utc))
391                }),
392                memory_count: row.get::<_, i64>(4).unwrap_or(0) as u32,
393                summary: row.get(5)?,
394            })
395        };
396
397        if let Some(ns) = namespace {
398            let mut stmt = conn
399                .prepare(sql_with_ns)
400                .map_err(|e| CodememError::Storage(e.to_string()))?;
401            let rows = stmt
402                .query_map(params![ns, limit as i64], map_row)
403                .map_err(|e| CodememError::Storage(e.to_string()))?;
404            rows.collect::<Result<Vec<_>, _>>()
405                .map_err(|e| CodememError::Storage(e.to_string()))
406        } else {
407            let mut stmt = conn
408                .prepare(sql_all)
409                .map_err(|e| CodememError::Storage(e.to_string()))?;
410            let rows = stmt
411                .query_map(params![limit as i64], map_row)
412                .map_err(|e| CodememError::Storage(e.to_string()))?;
413            rows.collect::<Result<Vec<_>, _>>()
414                .map_err(|e| CodememError::Storage(e.to_string()))
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use crate::Storage;
422    use codemem_core::{MemoryNode, MemoryType};
423    use std::collections::HashMap;
424
425    fn test_memory_with_metadata(
426        content: &str,
427        tool: &str,
428        extra: HashMap<String, serde_json::Value>,
429    ) -> MemoryNode {
430        let now = chrono::Utc::now();
431        let mut metadata = extra;
432        metadata.insert(
433            "tool".to_string(),
434            serde_json::Value::String(tool.to_string()),
435        );
436        MemoryNode {
437            id: uuid::Uuid::new_v4().to_string(),
438            content: content.to_string(),
439            memory_type: MemoryType::Context,
440            importance: 0.5,
441            confidence: 1.0,
442            access_count: 0,
443            content_hash: Storage::content_hash(content),
444            tags: vec![],
445            metadata,
446            namespace: None,
447            created_at: now,
448            updated_at: now,
449            last_accessed_at: now,
450        }
451    }
452
453    #[test]
454    fn stats() {
455        let storage = Storage::open_in_memory().unwrap();
456        let stats = storage.stats().unwrap();
457        assert_eq!(stats.memory_count, 0);
458    }
459
460    #[test]
461    fn get_repeated_searches_groups_by_pattern() {
462        let storage = Storage::open_in_memory().unwrap();
463
464        for i in 0..3 {
465            let mut extra = HashMap::new();
466            extra.insert(
467                "pattern".to_string(),
468                serde_json::Value::String("error".to_string()),
469            );
470            let mem =
471                test_memory_with_metadata(&format!("grep search {i} for error"), "Grep", extra);
472            storage.insert_memory(&mem).unwrap();
473        }
474
475        let mut extra = HashMap::new();
476        extra.insert(
477            "pattern".to_string(),
478            serde_json::Value::String("*.rs".to_string()),
479        );
480        let mem = test_memory_with_metadata("glob search for rs files", "Glob", extra);
481        storage.insert_memory(&mem).unwrap();
482
483        let results = storage.get_repeated_searches(2, None).unwrap();
484        assert_eq!(results.len(), 1);
485        assert_eq!(results[0].0, "error");
486        assert_eq!(results[0].1, 3);
487        assert_eq!(results[0].2.len(), 3);
488
489        let results = storage.get_repeated_searches(1, None).unwrap();
490        assert_eq!(results.len(), 2);
491    }
492
493    #[test]
494    fn get_file_hotspots_groups_by_file_path() {
495        let storage = Storage::open_in_memory().unwrap();
496
497        for i in 0..4 {
498            let mut extra = HashMap::new();
499            extra.insert(
500                "file_path".to_string(),
501                serde_json::Value::String("src/main.rs".to_string()),
502            );
503            let mem =
504                test_memory_with_metadata(&format!("read main.rs attempt {i}"), "Read", extra);
505            storage.insert_memory(&mem).unwrap();
506        }
507
508        let mut extra = HashMap::new();
509        extra.insert(
510            "file_path".to_string(),
511            serde_json::Value::String("src/lib.rs".to_string()),
512        );
513        let mem = test_memory_with_metadata("read lib.rs", "Read", extra);
514        storage.insert_memory(&mem).unwrap();
515
516        let results = storage.get_file_hotspots(3, None).unwrap();
517        assert_eq!(results.len(), 1);
518        assert_eq!(results[0].0, "src/main.rs");
519        assert_eq!(results[0].1, 4);
520    }
521
522    #[test]
523    fn get_tool_usage_stats_counts_by_tool() {
524        let storage = Storage::open_in_memory().unwrap();
525
526        for i in 0..5 {
527            let mem = test_memory_with_metadata(&format!("read file {i}"), "Read", HashMap::new());
528            storage.insert_memory(&mem).unwrap();
529        }
530        for i in 0..3 {
531            let mem =
532                test_memory_with_metadata(&format!("grep search {i}"), "Grep", HashMap::new());
533            storage.insert_memory(&mem).unwrap();
534        }
535        let mem = test_memory_with_metadata("edit file", "Edit", HashMap::new());
536        storage.insert_memory(&mem).unwrap();
537
538        let stats = storage.get_tool_usage_stats(None).unwrap();
539        assert_eq!(stats.get("Read"), Some(&5));
540        assert_eq!(stats.get("Grep"), Some(&3));
541        assert_eq!(stats.get("Edit"), Some(&1));
542    }
543
544    #[test]
545    fn get_decision_chains_groups_edits_by_file() {
546        let storage = Storage::open_in_memory().unwrap();
547
548        for i in 0..3 {
549            let mut extra = HashMap::new();
550            extra.insert(
551                "file_path".to_string(),
552                serde_json::Value::String("src/main.rs".to_string()),
553            );
554            let mem = test_memory_with_metadata(&format!("edit main.rs {i}"), "Edit", extra);
555            storage.insert_memory(&mem).unwrap();
556        }
557
558        let mut extra = HashMap::new();
559        extra.insert(
560            "file_path".to_string(),
561            serde_json::Value::String("src/new.rs".to_string()),
562        );
563        let mem = test_memory_with_metadata("write new.rs", "Write", extra);
564        storage.insert_memory(&mem).unwrap();
565
566        let results = storage.get_decision_chains(2, None).unwrap();
567        assert_eq!(results.len(), 1);
568        assert_eq!(results[0].0, "src/main.rs");
569        assert_eq!(results[0].1, 3);
570    }
571
572    #[test]
573    fn pattern_queries_empty_db() {
574        let storage = Storage::open_in_memory().unwrap();
575
576        let searches = storage.get_repeated_searches(1, None).unwrap();
577        assert!(searches.is_empty());
578
579        let hotspots = storage.get_file_hotspots(1, None).unwrap();
580        assert!(hotspots.is_empty());
581
582        let stats = storage.get_tool_usage_stats(None).unwrap();
583        assert!(stats.is_empty());
584
585        let chains = storage.get_decision_chains(1, None).unwrap();
586        assert!(chains.is_empty());
587    }
588
589    #[test]
590    fn pattern_queries_with_namespace_filter() {
591        let storage = Storage::open_in_memory().unwrap();
592
593        for i in 0..3 {
594            let mut extra = HashMap::new();
595            extra.insert(
596                "pattern".to_string(),
597                serde_json::Value::String("error".to_string()),
598            );
599            let mut mem = test_memory_with_metadata(&format!("ns-a grep {i}"), "Grep", extra);
600            mem.namespace = Some("project-a".to_string());
601            storage.insert_memory(&mem).unwrap();
602        }
603
604        for i in 0..2 {
605            let mut extra = HashMap::new();
606            extra.insert(
607                "pattern".to_string(),
608                serde_json::Value::String("error".to_string()),
609            );
610            let mut mem = test_memory_with_metadata(&format!("ns-b grep {i}"), "Grep", extra);
611            mem.namespace = Some("project-b".to_string());
612            storage.insert_memory(&mem).unwrap();
613        }
614
615        let results = storage.get_repeated_searches(1, None).unwrap();
616        assert_eq!(results.len(), 1);
617        assert_eq!(results[0].1, 5);
618
619        let results = storage.get_repeated_searches(1, Some("project-a")).unwrap();
620        assert_eq!(results.len(), 1);
621        assert_eq!(results[0].1, 3);
622    }
623
624    // ── Session Management Tests ────────────────────────────────────────
625
626    #[test]
627    fn session_lifecycle() {
628        let storage = Storage::open_in_memory().unwrap();
629
630        storage.start_session("sess-1", Some("my-project")).unwrap();
631
632        let sessions = storage.list_sessions(Some("my-project")).unwrap();
633        assert_eq!(sessions.len(), 1);
634        assert_eq!(sessions[0].id, "sess-1");
635        assert_eq!(sessions[0].namespace, Some("my-project".to_string()));
636        assert!(sessions[0].ended_at.is_none());
637
638        storage
639            .end_session("sess-1", Some("Explored the codebase"))
640            .unwrap();
641
642        let sessions = storage.list_sessions(None).unwrap();
643        assert_eq!(sessions.len(), 1);
644        assert!(sessions[0].ended_at.is_some());
645        assert_eq!(
646            sessions[0].summary,
647            Some("Explored the codebase".to_string())
648        );
649    }
650
651    #[test]
652    fn list_sessions_filters_by_namespace() {
653        let storage = Storage::open_in_memory().unwrap();
654
655        storage.start_session("sess-a", Some("project-a")).unwrap();
656        storage.start_session("sess-b", Some("project-b")).unwrap();
657        storage.start_session("sess-c", None).unwrap();
658
659        let all = storage.list_sessions(None).unwrap();
660        assert_eq!(all.len(), 3);
661
662        let proj_a = storage.list_sessions(Some("project-a")).unwrap();
663        assert_eq!(proj_a.len(), 1);
664        assert_eq!(proj_a[0].id, "sess-a");
665    }
666
667    #[test]
668    fn start_session_ignores_duplicate() {
669        let storage = Storage::open_in_memory().unwrap();
670        storage.start_session("sess-1", Some("ns")).unwrap();
671        storage.start_session("sess-1", Some("ns")).unwrap();
672
673        let sessions = storage.list_sessions(None).unwrap();
674        assert_eq!(sessions.len(), 1);
675    }
676}