Skip to main content

codemem_storage/
lib.rs

1//! codemem-storage: SQLite persistence layer for Codemem.
2//!
3//! Uses rusqlite with bundled SQLite, WAL mode, and embedded schema.
4
5use codemem_core::{
6    CodememError, Edge, GraphNode, MemoryNode, MemoryType, NodeKind, RelationshipType,
7};
8use rusqlite::{params, Connection, OptionalExtension};
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11use std::path::Path;
12
13const SCHEMA: &str = include_str!("schema.sql");
14
15/// SQLite-backed storage for Codemem memories, embeddings, and graph data.
16pub struct Storage {
17    conn: Connection,
18}
19
20impl Storage {
21    /// Open (or create) an Codemem database at the given path.
22    pub fn open(path: &Path) -> Result<Self, CodememError> {
23        let conn = Connection::open(path).map_err(|e| CodememError::Storage(e.to_string()))?;
24
25        // WAL mode for concurrent reads
26        conn.pragma_update(None, "journal_mode", "WAL")
27            .map_err(|e| CodememError::Storage(e.to_string()))?;
28        // 64MB cache
29        conn.pragma_update(None, "cache_size", -64000i64)
30            .map_err(|e| CodememError::Storage(e.to_string()))?;
31        // Foreign keys ON
32        conn.pragma_update(None, "foreign_keys", "ON")
33            .map_err(|e| CodememError::Storage(e.to_string()))?;
34        // NORMAL sync (good balance of safety vs speed)
35        conn.pragma_update(None, "synchronous", "NORMAL")
36            .map_err(|e| CodememError::Storage(e.to_string()))?;
37        // 256MB mmap for faster reads
38        conn.pragma_update(None, "mmap_size", 268435456i64)
39            .map_err(|e| CodememError::Storage(e.to_string()))?;
40        // Temp tables in memory
41        conn.pragma_update(None, "temp_store", "MEMORY")
42            .map_err(|e| CodememError::Storage(e.to_string()))?;
43        // 5s busy timeout
44        conn.busy_timeout(std::time::Duration::from_secs(5))
45            .map_err(|e| CodememError::Storage(e.to_string()))?;
46
47        // Apply schema
48        conn.execute_batch(SCHEMA)
49            .map_err(|e| CodememError::Storage(e.to_string()))?;
50
51        Ok(Self { conn })
52    }
53
54    /// Open an in-memory database (for testing).
55    pub fn open_in_memory() -> Result<Self, CodememError> {
56        let conn =
57            Connection::open_in_memory().map_err(|e| CodememError::Storage(e.to_string()))?;
58        conn.pragma_update(None, "foreign_keys", "ON")
59            .map_err(|e| CodememError::Storage(e.to_string()))?;
60        conn.execute_batch(SCHEMA)
61            .map_err(|e| CodememError::Storage(e.to_string()))?;
62        Ok(Self { conn })
63    }
64
65    /// Compute SHA-256 hash of content for deduplication.
66    pub fn content_hash(content: &str) -> String {
67        let mut hasher = Sha256::new();
68        hasher.update(content.as_bytes());
69        format!("{:x}", hasher.finalize())
70    }
71
72    // ── Memory CRUD ─────────────────────────────────────────────────────
73
74    /// Insert a new memory. Returns Err(Duplicate) if content hash already exists.
75    pub fn insert_memory(&self, memory: &MemoryNode) -> Result<(), CodememError> {
76        // Check dedup
77        let existing: Option<String> = self
78            .conn
79            .query_row(
80                "SELECT id FROM memories WHERE content_hash = ?1",
81                params![memory.content_hash],
82                |row| row.get(0),
83            )
84            .optional()
85            .map_err(|e| CodememError::Storage(e.to_string()))?;
86
87        if let Some(_existing_id) = existing {
88            return Err(CodememError::Duplicate(memory.content_hash.clone()));
89        }
90
91        let tags_json = serde_json::to_string(&memory.tags)?;
92        let metadata_json = serde_json::to_string(&memory.metadata)?;
93
94        self.conn
95            .execute(
96                "INSERT INTO memories (id, content, memory_type, importance, confidence, access_count, content_hash, tags, metadata, namespace, created_at, updated_at, last_accessed_at)
97                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
98                params![
99                    memory.id,
100                    memory.content,
101                    memory.memory_type.to_string(),
102                    memory.importance,
103                    memory.confidence,
104                    memory.access_count,
105                    memory.content_hash,
106                    tags_json,
107                    metadata_json,
108                    memory.namespace,
109                    memory.created_at.timestamp(),
110                    memory.updated_at.timestamp(),
111                    memory.last_accessed_at.timestamp(),
112                ],
113            )
114            .map_err(|e| CodememError::Storage(e.to_string()))?;
115
116        Ok(())
117    }
118
119    /// Get a memory by ID. Updates access_count and last_accessed_at.
120    pub fn get_memory(&self, id: &str) -> Result<Option<MemoryNode>, CodememError> {
121        // Bump access count first
122        let updated = self
123            .conn
124            .execute(
125                "UPDATE memories SET access_count = access_count + 1, last_accessed_at = ?1 WHERE id = ?2",
126                params![chrono::Utc::now().timestamp(), id],
127            )
128            .map_err(|e| CodememError::Storage(e.to_string()))?;
129
130        if updated == 0 {
131            return Ok(None);
132        }
133
134        let result = self
135            .conn
136            .query_row(
137                "SELECT id, content, memory_type, importance, confidence, access_count, content_hash, tags, metadata, namespace, created_at, updated_at, last_accessed_at FROM memories WHERE id = ?1",
138                params![id],
139                |row| {
140                    Ok(MemoryRow {
141                        id: row.get(0)?,
142                        content: row.get(1)?,
143                        memory_type: row.get(2)?,
144                        importance: row.get(3)?,
145                        confidence: row.get(4)?,
146                        access_count: row.get(5)?,
147                        content_hash: row.get(6)?,
148                        tags: row.get(7)?,
149                        metadata: row.get(8)?,
150                        namespace: row.get(9)?,
151                        created_at: row.get(10)?,
152                        updated_at: row.get(11)?,
153                        last_accessed_at: row.get(12)?,
154                    })
155                },
156            )
157            .optional()
158            .map_err(|e| CodememError::Storage(e.to_string()))?;
159
160        match result {
161            Some(row) => Ok(Some(row.into_memory_node()?)),
162            None => Ok(None),
163        }
164    }
165
166    /// Update a memory's content and re-hash.
167    pub fn update_memory(
168        &self,
169        id: &str,
170        content: &str,
171        importance: Option<f64>,
172    ) -> Result<(), CodememError> {
173        let hash = Self::content_hash(content);
174        let now = chrono::Utc::now().timestamp();
175
176        let mut sql =
177            "UPDATE memories SET content = ?1, content_hash = ?2, updated_at = ?3".to_string();
178        if importance.is_some() {
179            sql.push_str(", importance = ?4");
180        }
181        sql.push_str(" WHERE id = ?5");
182
183        if let Some(imp) = importance {
184            self.conn
185                .execute(&sql, params![content, hash, now, imp, id])
186                .map_err(|e| CodememError::Storage(e.to_string()))?;
187        } else {
188            // Re-bind without importance param
189            self.conn
190                .execute(
191                    "UPDATE memories SET content = ?1, content_hash = ?2, updated_at = ?3 WHERE id = ?4",
192                    params![content, hash, now, id],
193                )
194                .map_err(|e| CodememError::Storage(e.to_string()))?;
195        }
196
197        Ok(())
198    }
199
200    /// Delete a memory by ID.
201    pub fn delete_memory(&self, id: &str) -> Result<bool, CodememError> {
202        let rows = self
203            .conn
204            .execute("DELETE FROM memories WHERE id = ?1", params![id])
205            .map_err(|e| CodememError::Storage(e.to_string()))?;
206        Ok(rows > 0)
207    }
208
209    /// List all memory IDs.
210    pub fn list_memory_ids(&self) -> Result<Vec<String>, CodememError> {
211        let mut stmt = self
212            .conn
213            .prepare("SELECT id FROM memories ORDER BY created_at DESC")
214            .map_err(|e| CodememError::Storage(e.to_string()))?;
215
216        let ids = stmt
217            .query_map([], |row| row.get(0))
218            .map_err(|e| CodememError::Storage(e.to_string()))?
219            .collect::<Result<Vec<String>, _>>()
220            .map_err(|e| CodememError::Storage(e.to_string()))?;
221
222        Ok(ids)
223    }
224
225    /// List memory IDs scoped to a specific namespace.
226    pub fn list_memory_ids_for_namespace(
227        &self,
228        namespace: &str,
229    ) -> Result<Vec<String>, CodememError> {
230        let mut stmt = self
231            .conn
232            .prepare("SELECT id FROM memories WHERE namespace = ?1 ORDER BY created_at DESC")
233            .map_err(|e| CodememError::Storage(e.to_string()))?;
234
235        let ids = stmt
236            .query_map(params![namespace], |row| row.get(0))
237            .map_err(|e| CodememError::Storage(e.to_string()))?
238            .collect::<Result<Vec<String>, _>>()
239            .map_err(|e| CodememError::Storage(e.to_string()))?;
240
241        Ok(ids)
242    }
243
244    /// List all distinct namespaces.
245    pub fn list_namespaces(&self) -> Result<Vec<String>, CodememError> {
246        let mut stmt = self
247            .conn
248            .prepare(
249                "SELECT DISTINCT namespace FROM (
250                    SELECT namespace FROM memories WHERE namespace IS NOT NULL
251                    UNION
252                    SELECT namespace FROM graph_nodes WHERE namespace IS NOT NULL
253                ) ORDER BY namespace",
254            )
255            .map_err(|e| CodememError::Storage(e.to_string()))?;
256
257        let namespaces = stmt
258            .query_map([], |row| row.get(0))
259            .map_err(|e| CodememError::Storage(e.to_string()))?
260            .collect::<Result<Vec<String>, _>>()
261            .map_err(|e| CodememError::Storage(e.to_string()))?;
262
263        Ok(namespaces)
264    }
265
266    /// Get memory count.
267    pub fn memory_count(&self) -> Result<usize, CodememError> {
268        let count: i64 = self
269            .conn
270            .query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))
271            .map_err(|e| CodememError::Storage(e.to_string()))?;
272        Ok(count as usize)
273    }
274
275    // ── Embedding Storage ───────────────────────────────────────────────
276
277    /// Store an embedding for a memory.
278    pub fn store_embedding(&self, memory_id: &str, embedding: &[f32]) -> Result<(), CodememError> {
279        let blob: Vec<u8> = embedding.iter().flat_map(|f| f.to_le_bytes()).collect();
280
281        self.conn
282            .execute(
283                "INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding) VALUES (?1, ?2)",
284                params![memory_id, blob],
285            )
286            .map_err(|e| CodememError::Storage(e.to_string()))?;
287
288        Ok(())
289    }
290
291    /// Get an embedding by memory ID.
292    pub fn get_embedding(&self, memory_id: &str) -> Result<Option<Vec<f32>>, CodememError> {
293        let blob: Option<Vec<u8>> = self
294            .conn
295            .query_row(
296                "SELECT embedding FROM memory_embeddings WHERE memory_id = ?1",
297                params![memory_id],
298                |row| row.get(0),
299            )
300            .optional()
301            .map_err(|e| CodememError::Storage(e.to_string()))?;
302
303        match blob {
304            Some(bytes) => {
305                let floats: Vec<f32> = bytes
306                    .chunks_exact(4)
307                    .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
308                    .collect();
309                Ok(Some(floats))
310            }
311            None => Ok(None),
312        }
313    }
314
315    // ── Graph Node Storage ──────────────────────────────────────────────
316
317    /// Insert a graph node.
318    pub fn insert_graph_node(&self, node: &GraphNode) -> Result<(), CodememError> {
319        let payload_json = serde_json::to_string(&node.payload)?;
320
321        self.conn
322            .execute(
323                "INSERT OR REPLACE INTO graph_nodes (id, kind, label, payload, centrality, memory_id, namespace)
324                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
325                params![
326                    node.id,
327                    node.kind.to_string(),
328                    node.label,
329                    payload_json,
330                    node.centrality,
331                    node.memory_id,
332                    node.namespace,
333                ],
334            )
335            .map_err(|e| CodememError::Storage(e.to_string()))?;
336
337        Ok(())
338    }
339
340    /// Get a graph node by ID.
341    pub fn get_graph_node(&self, id: &str) -> Result<Option<GraphNode>, CodememError> {
342        self.conn
343            .query_row(
344                "SELECT id, kind, label, payload, centrality, memory_id, namespace FROM graph_nodes WHERE id = ?1",
345                params![id],
346                |row| {
347                    let kind_str: String = row.get(1)?;
348                    let payload_str: String = row.get(3)?;
349                    Ok((
350                        row.get::<_, String>(0)?,
351                        kind_str,
352                        row.get::<_, String>(2)?,
353                        payload_str,
354                        row.get::<_, f64>(4)?,
355                        row.get::<_, Option<String>>(5)?,
356                        row.get::<_, Option<String>>(6)?,
357                    ))
358                },
359            )
360            .optional()
361            .map_err(|e| CodememError::Storage(e.to_string()))?
362            .map(|(id, kind_str, label, payload_str, centrality, memory_id, namespace)| {
363                let kind: NodeKind = kind_str.parse().map_err(|e: CodememError| CodememError::Storage(e.to_string()))?;
364                let payload: HashMap<String, serde_json::Value> =
365                    serde_json::from_str(&payload_str).unwrap_or_default();
366                Ok(GraphNode {
367                    id,
368                    kind,
369                    label,
370                    payload,
371                    centrality,
372                    memory_id,
373                    namespace,
374                })
375            })
376            .transpose()
377    }
378
379    /// Delete a graph node by ID.
380    pub fn delete_graph_node(&self, id: &str) -> Result<bool, CodememError> {
381        let rows = self
382            .conn
383            .execute("DELETE FROM graph_nodes WHERE id = ?1", params![id])
384            .map_err(|e| CodememError::Storage(e.to_string()))?;
385        Ok(rows > 0)
386    }
387
388    /// Get all graph nodes.
389    pub fn all_graph_nodes(&self) -> Result<Vec<GraphNode>, CodememError> {
390        let mut stmt = self
391            .conn
392            .prepare("SELECT id, kind, label, payload, centrality, memory_id, namespace FROM graph_nodes")
393            .map_err(|e| CodememError::Storage(e.to_string()))?;
394
395        let nodes = stmt
396            .query_map([], |row| {
397                let kind_str: String = row.get(1)?;
398                let payload_str: String = row.get(3)?;
399                Ok((
400                    row.get::<_, String>(0)?,
401                    kind_str,
402                    row.get::<_, String>(2)?,
403                    payload_str,
404                    row.get::<_, f64>(4)?,
405                    row.get::<_, Option<String>>(5)?,
406                    row.get::<_, Option<String>>(6)?,
407                ))
408            })
409            .map_err(|e| CodememError::Storage(e.to_string()))?
410            .filter_map(|r| r.ok())
411            .filter_map(
412                |(id, kind_str, label, payload_str, centrality, memory_id, namespace)| {
413                    let kind: NodeKind = kind_str.parse().ok()?;
414                    let payload: HashMap<String, serde_json::Value> =
415                        serde_json::from_str(&payload_str).unwrap_or_default();
416                    Some(GraphNode {
417                        id,
418                        kind,
419                        label,
420                        payload,
421                        centrality,
422                        memory_id,
423                        namespace,
424                    })
425                },
426            )
427            .collect();
428
429        Ok(nodes)
430    }
431
432    // ── Graph Edge Storage ──────────────────────────────────────────────
433
434    /// Insert a graph edge.
435    pub fn insert_graph_edge(&self, edge: &Edge) -> Result<(), CodememError> {
436        let props_json = serde_json::to_string(&edge.properties)?;
437
438        self.conn
439            .execute(
440                "INSERT OR REPLACE INTO graph_edges (id, src, dst, relationship, weight, properties, created_at)
441                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
442                params![
443                    edge.id,
444                    edge.src,
445                    edge.dst,
446                    edge.relationship.to_string(),
447                    edge.weight,
448                    props_json,
449                    edge.created_at.timestamp(),
450                ],
451            )
452            .map_err(|e| CodememError::Storage(e.to_string()))?;
453
454        Ok(())
455    }
456
457    /// Get all edges from or to a node.
458    pub fn get_edges_for_node(&self, node_id: &str) -> Result<Vec<Edge>, CodememError> {
459        let mut stmt = self
460            .conn
461            .prepare(
462                "SELECT id, src, dst, relationship, weight, properties, created_at FROM graph_edges WHERE src = ?1 OR dst = ?1",
463            )
464            .map_err(|e| CodememError::Storage(e.to_string()))?;
465
466        let edges = stmt
467            .query_map(params![node_id], |row| {
468                let rel_str: String = row.get(3)?;
469                let props_str: String = row.get(5)?;
470                let created_ts: i64 = row.get(6)?;
471                Ok((
472                    row.get::<_, String>(0)?,
473                    row.get::<_, String>(1)?,
474                    row.get::<_, String>(2)?,
475                    rel_str,
476                    row.get::<_, f64>(4)?,
477                    props_str,
478                    created_ts,
479                ))
480            })
481            .map_err(|e| CodememError::Storage(e.to_string()))?
482            .filter_map(|r| r.ok())
483            .filter_map(|(id, src, dst, rel_str, weight, props_str, created_ts)| {
484                let relationship: RelationshipType = rel_str.parse().ok()?;
485                let properties: HashMap<String, serde_json::Value> =
486                    serde_json::from_str(&props_str).unwrap_or_default();
487                let created_at =
488                    chrono::DateTime::from_timestamp(created_ts, 0)?.with_timezone(&chrono::Utc);
489                Some(Edge {
490                    id,
491                    src,
492                    dst,
493                    relationship,
494                    weight,
495                    properties,
496                    created_at,
497                })
498            })
499            .collect();
500
501        Ok(edges)
502    }
503
504    /// Get all graph edges.
505    pub fn all_graph_edges(&self) -> Result<Vec<Edge>, CodememError> {
506        let mut stmt = self
507            .conn
508            .prepare("SELECT id, src, dst, relationship, weight, properties, created_at FROM graph_edges")
509            .map_err(|e| CodememError::Storage(e.to_string()))?;
510
511        let edges = stmt
512            .query_map([], |row| {
513                let rel_str: String = row.get(3)?;
514                let props_str: String = row.get(5)?;
515                let created_ts: i64 = row.get(6)?;
516                Ok((
517                    row.get::<_, String>(0)?,
518                    row.get::<_, String>(1)?,
519                    row.get::<_, String>(2)?,
520                    rel_str,
521                    row.get::<_, f64>(4)?,
522                    props_str,
523                    created_ts,
524                ))
525            })
526            .map_err(|e| CodememError::Storage(e.to_string()))?
527            .filter_map(|r| r.ok())
528            .filter_map(|(id, src, dst, rel_str, weight, props_str, created_ts)| {
529                let relationship: RelationshipType = rel_str.parse().ok()?;
530                let properties: HashMap<String, serde_json::Value> =
531                    serde_json::from_str(&props_str).unwrap_or_default();
532                let created_at =
533                    chrono::DateTime::from_timestamp(created_ts, 0)?.with_timezone(&chrono::Utc);
534                Some(Edge {
535                    id,
536                    src,
537                    dst,
538                    relationship,
539                    weight,
540                    properties,
541                    created_at,
542                })
543            })
544            .collect();
545
546        Ok(edges)
547    }
548
549    /// Delete all graph edges connected to a node (as src or dst).
550    pub fn delete_graph_edges_for_node(&self, node_id: &str) -> Result<usize, CodememError> {
551        let rows = self
552            .conn
553            .execute(
554                "DELETE FROM graph_edges WHERE src = ?1 OR dst = ?1",
555                params![node_id],
556            )
557            .map_err(|e| CodememError::Storage(e.to_string()))?;
558        Ok(rows)
559    }
560
561    /// Get all graph edges where both src and dst nodes belong to the given namespace.
562    pub fn graph_edges_for_namespace(&self, namespace: &str) -> Result<Vec<Edge>, CodememError> {
563        let mut stmt = self
564            .conn
565            .prepare(
566                "SELECT e.id, e.src, e.dst, e.relationship, e.weight, e.properties, e.created_at
567                 FROM graph_edges e
568                 INNER JOIN graph_nodes gs ON e.src = gs.id
569                 INNER JOIN graph_nodes gd ON e.dst = gd.id
570                 WHERE gs.namespace = ?1 AND gd.namespace = ?1",
571            )
572            .map_err(|e| CodememError::Storage(e.to_string()))?;
573
574        let edges = stmt
575            .query_map(params![namespace], |row| {
576                let rel_str: String = row.get(3)?;
577                let props_str: String = row.get(5)?;
578                let created_ts: i64 = row.get(6)?;
579                Ok((
580                    row.get::<_, String>(0)?,
581                    row.get::<_, String>(1)?,
582                    row.get::<_, String>(2)?,
583                    rel_str,
584                    row.get::<_, f64>(4)?,
585                    props_str,
586                    created_ts,
587                ))
588            })
589            .map_err(|e| CodememError::Storage(e.to_string()))?
590            .filter_map(|r| r.ok())
591            .filter_map(|(id, src, dst, rel_str, weight, props_str, created_ts)| {
592                let relationship: RelationshipType = rel_str.parse().ok()?;
593                let properties: HashMap<String, serde_json::Value> =
594                    serde_json::from_str(&props_str).unwrap_or_default();
595                let created_at =
596                    chrono::DateTime::from_timestamp(created_ts, 0)?.with_timezone(&chrono::Utc);
597                Some(Edge {
598                    id,
599                    src,
600                    dst,
601                    relationship,
602                    weight,
603                    properties,
604                    created_at,
605                })
606            })
607            .collect();
608
609        Ok(edges)
610    }
611
612    /// Delete a graph edge by ID.
613    pub fn delete_graph_edge(&self, id: &str) -> Result<bool, CodememError> {
614        let rows = self
615            .conn
616            .execute("DELETE FROM graph_edges WHERE id = ?1", params![id])
617            .map_err(|e| CodememError::Storage(e.to_string()))?;
618        Ok(rows > 0)
619    }
620
621    // ── Stats ───────────────────────────────────────────────────────────
622
623    /// Get database statistics.
624    pub fn stats(&self) -> Result<StorageStats, CodememError> {
625        let memory_count = self.memory_count()?;
626
627        let embedding_count: i64 = self
628            .conn
629            .query_row("SELECT COUNT(*) FROM memory_embeddings", [], |row| {
630                row.get(0)
631            })
632            .map_err(|e| CodememError::Storage(e.to_string()))?;
633
634        let node_count: i64 = self
635            .conn
636            .query_row("SELECT COUNT(*) FROM graph_nodes", [], |row| row.get(0))
637            .map_err(|e| CodememError::Storage(e.to_string()))?;
638
639        let edge_count: i64 = self
640            .conn
641            .query_row("SELECT COUNT(*) FROM graph_edges", [], |row| row.get(0))
642            .map_err(|e| CodememError::Storage(e.to_string()))?;
643
644        Ok(StorageStats {
645            memory_count,
646            embedding_count: embedding_count as usize,
647            node_count: node_count as usize,
648            edge_count: edge_count as usize,
649        })
650    }
651
652    // ── Consolidation Log ──────────────────────────────────────────────
653
654    /// Record a consolidation run.
655    pub fn insert_consolidation_log(
656        &self,
657        cycle_type: &str,
658        affected_count: usize,
659    ) -> Result<(), CodememError> {
660        let now = chrono::Utc::now().timestamp();
661        self.conn
662            .execute(
663                "INSERT INTO consolidation_log (cycle_type, run_at, affected_count) VALUES (?1, ?2, ?3)",
664                params![cycle_type, now, affected_count as i64],
665            )
666            .map_err(|e| CodememError::Storage(e.to_string()))?;
667        Ok(())
668    }
669
670    /// Get the last consolidation run for each cycle type.
671    pub fn last_consolidation_runs(&self) -> Result<Vec<ConsolidationLogEntry>, CodememError> {
672        let mut stmt = self
673            .conn
674            .prepare(
675                "SELECT cycle_type, run_at, affected_count FROM consolidation_log
676                 WHERE id IN (
677                     SELECT id FROM consolidation_log c2
678                     WHERE c2.cycle_type = consolidation_log.cycle_type
679                     ORDER BY run_at DESC LIMIT 1
680                 )
681                 GROUP BY cycle_type
682                 ORDER BY cycle_type",
683            )
684            .map_err(|e| CodememError::Storage(e.to_string()))?;
685
686        let entries = stmt
687            .query_map([], |row| {
688                Ok(ConsolidationLogEntry {
689                    cycle_type: row.get(0)?,
690                    run_at: row.get(1)?,
691                    affected_count: row.get::<_, i64>(2)? as usize,
692                })
693            })
694            .map_err(|e| CodememError::Storage(e.to_string()))?
695            .collect::<Result<Vec<_>, _>>()
696            .map_err(|e| CodememError::Storage(e.to_string()))?;
697
698        Ok(entries)
699    }
700
701    /// Get a reference to the underlying connection (for advanced use).
702    pub fn connection(&self) -> &Connection {
703        &self.conn
704    }
705
706    // ── Pattern Detection Queries ───────────────────────────────────────
707
708    /// Find repeated search patterns (Grep/Glob) by extracting the "pattern" field
709    /// from memory metadata JSON. Returns (pattern, count, memory_ids) tuples where
710    /// count >= min_count, ordered by count descending.
711    pub fn get_repeated_searches(
712        &self,
713        min_count: usize,
714        namespace: Option<&str>,
715    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
716        // Use json_extract to pull the "pattern" field from the metadata JSON column.
717        // Filter to memories whose metadata contains a "tool" of "Grep" or "Glob".
718        let sql = if namespace.is_some() {
719            "SELECT json_extract(metadata, '$.pattern') AS pat,
720                    COUNT(*) AS cnt,
721                    GROUP_CONCAT(id, ',') AS ids
722             FROM memories
723             WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
724               AND pat IS NOT NULL
725               AND namespace = ?1
726             GROUP BY pat
727             HAVING cnt >= ?2
728             ORDER BY cnt DESC"
729        } else {
730            "SELECT json_extract(metadata, '$.pattern') AS pat,
731                    COUNT(*) AS cnt,
732                    GROUP_CONCAT(id, ',') AS ids
733             FROM memories
734             WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
735               AND pat IS NOT NULL
736             GROUP BY pat
737             HAVING cnt >= ?1
738             ORDER BY cnt DESC"
739        };
740
741        let mut stmt = self
742            .conn
743            .prepare(sql)
744            .map_err(|e| CodememError::Storage(e.to_string()))?;
745
746        let rows = if let Some(ns) = namespace {
747            stmt.query_map(params![ns, min_count as i64], |row| {
748                Ok((
749                    row.get::<_, String>(0)?,
750                    row.get::<_, i64>(1)?,
751                    row.get::<_, String>(2)?,
752                ))
753            })
754            .map_err(|e| CodememError::Storage(e.to_string()))?
755            .collect::<Result<Vec<_>, _>>()
756            .map_err(|e| CodememError::Storage(e.to_string()))?
757        } else {
758            stmt.query_map(params![min_count as i64], |row| {
759                Ok((
760                    row.get::<_, String>(0)?,
761                    row.get::<_, i64>(1)?,
762                    row.get::<_, String>(2)?,
763                ))
764            })
765            .map_err(|e| CodememError::Storage(e.to_string()))?
766            .collect::<Result<Vec<_>, _>>()
767            .map_err(|e| CodememError::Storage(e.to_string()))?
768        };
769
770        Ok(rows
771            .into_iter()
772            .map(|(pat, cnt, ids_str)| {
773                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
774                (pat, cnt as usize, ids)
775            })
776            .collect())
777    }
778
779    /// Find file hotspots by extracting the "file_path" field from memory metadata.
780    /// Returns (file_path, count, memory_ids) tuples where count >= min_count,
781    /// ordered by count descending.
782    pub fn get_file_hotspots(
783        &self,
784        min_count: usize,
785        namespace: Option<&str>,
786    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
787        let sql = if namespace.is_some() {
788            "SELECT json_extract(metadata, '$.file_path') AS fp,
789                    COUNT(*) AS cnt,
790                    GROUP_CONCAT(id, ',') AS ids
791             FROM memories
792             WHERE fp IS NOT NULL
793               AND namespace = ?1
794             GROUP BY fp
795             HAVING cnt >= ?2
796             ORDER BY cnt DESC"
797        } else {
798            "SELECT json_extract(metadata, '$.file_path') AS fp,
799                    COUNT(*) AS cnt,
800                    GROUP_CONCAT(id, ',') AS ids
801             FROM memories
802             WHERE fp IS NOT NULL
803             GROUP BY fp
804             HAVING cnt >= ?1
805             ORDER BY cnt DESC"
806        };
807
808        let mut stmt = self
809            .conn
810            .prepare(sql)
811            .map_err(|e| CodememError::Storage(e.to_string()))?;
812
813        let rows = if let Some(ns) = namespace {
814            stmt.query_map(params![ns, min_count as i64], |row| {
815                Ok((
816                    row.get::<_, String>(0)?,
817                    row.get::<_, i64>(1)?,
818                    row.get::<_, String>(2)?,
819                ))
820            })
821            .map_err(|e| CodememError::Storage(e.to_string()))?
822            .collect::<Result<Vec<_>, _>>()
823            .map_err(|e| CodememError::Storage(e.to_string()))?
824        } else {
825            stmt.query_map(params![min_count as i64], |row| {
826                Ok((
827                    row.get::<_, String>(0)?,
828                    row.get::<_, i64>(1)?,
829                    row.get::<_, String>(2)?,
830                ))
831            })
832            .map_err(|e| CodememError::Storage(e.to_string()))?
833            .collect::<Result<Vec<_>, _>>()
834            .map_err(|e| CodememError::Storage(e.to_string()))?
835        };
836
837        Ok(rows
838            .into_iter()
839            .map(|(fp, cnt, ids_str)| {
840                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
841                (fp, cnt as usize, ids)
842            })
843            .collect())
844    }
845
846    /// Get tool usage statistics from memory metadata.
847    /// Returns a map of tool_name -> count, ordered by count descending.
848    pub fn get_tool_usage_stats(
849        &self,
850        namespace: Option<&str>,
851    ) -> Result<HashMap<String, usize>, CodememError> {
852        let sql = if namespace.is_some() {
853            "SELECT json_extract(metadata, '$.tool') AS tool,
854                    COUNT(*) AS cnt
855             FROM memories
856             WHERE tool IS NOT NULL
857               AND namespace = ?1
858             GROUP BY tool
859             ORDER BY cnt DESC"
860        } else {
861            "SELECT json_extract(metadata, '$.tool') AS tool,
862                    COUNT(*) AS cnt
863             FROM memories
864             WHERE tool IS NOT NULL
865             GROUP BY tool
866             ORDER BY cnt DESC"
867        };
868
869        let mut stmt = self
870            .conn
871            .prepare(sql)
872            .map_err(|e| CodememError::Storage(e.to_string()))?;
873
874        let rows = if let Some(ns) = namespace {
875            stmt.query_map(params![ns], |row| {
876                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
877            })
878            .map_err(|e| CodememError::Storage(e.to_string()))?
879            .collect::<Result<Vec<_>, _>>()
880            .map_err(|e| CodememError::Storage(e.to_string()))?
881        } else {
882            stmt.query_map([], |row| {
883                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
884            })
885            .map_err(|e| CodememError::Storage(e.to_string()))?
886            .collect::<Result<Vec<_>, _>>()
887            .map_err(|e| CodememError::Storage(e.to_string()))?
888        };
889
890        Ok(rows
891            .into_iter()
892            .map(|(tool, cnt)| (tool, cnt as usize))
893            .collect())
894    }
895
896    /// Find decision chains: files with multiple Edit/Write memories over time.
897    /// Returns (file_path, count, memory_ids) tuples ordered by count descending.
898    pub fn get_decision_chains(
899        &self,
900        min_count: usize,
901        namespace: Option<&str>,
902    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
903        let sql = if namespace.is_some() {
904            "SELECT json_extract(metadata, '$.file_path') AS fp,
905                    COUNT(*) AS cnt,
906                    GROUP_CONCAT(id, ',') AS ids
907             FROM memories
908             WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
909               AND fp IS NOT NULL
910               AND namespace = ?1
911             GROUP BY fp
912             HAVING cnt >= ?2
913             ORDER BY cnt DESC"
914        } else {
915            "SELECT json_extract(metadata, '$.file_path') AS fp,
916                    COUNT(*) AS cnt,
917                    GROUP_CONCAT(id, ',') AS ids
918             FROM memories
919             WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
920               AND fp IS NOT NULL
921             GROUP BY fp
922             HAVING cnt >= ?1
923             ORDER BY cnt DESC"
924        };
925
926        let mut stmt = self
927            .conn
928            .prepare(sql)
929            .map_err(|e| CodememError::Storage(e.to_string()))?;
930
931        let rows = if let Some(ns) = namespace {
932            stmt.query_map(params![ns, min_count as i64], |row| {
933                Ok((
934                    row.get::<_, String>(0)?,
935                    row.get::<_, i64>(1)?,
936                    row.get::<_, String>(2)?,
937                ))
938            })
939            .map_err(|e| CodememError::Storage(e.to_string()))?
940            .collect::<Result<Vec<_>, _>>()
941            .map_err(|e| CodememError::Storage(e.to_string()))?
942        } else {
943            stmt.query_map(params![min_count as i64], |row| {
944                Ok((
945                    row.get::<_, String>(0)?,
946                    row.get::<_, i64>(1)?,
947                    row.get::<_, String>(2)?,
948                ))
949            })
950            .map_err(|e| CodememError::Storage(e.to_string()))?
951            .collect::<Result<Vec<_>, _>>()
952            .map_err(|e| CodememError::Storage(e.to_string()))?
953        };
954
955        Ok(rows
956            .into_iter()
957            .map(|(fp, cnt, ids_str)| {
958                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
959                (fp, cnt as usize, ids)
960            })
961            .collect())
962    }
963
964    // ── Session Management ─────────────────────────────────────────────
965
966    /// Ensure session_id column exists on memories table.
967    pub fn ensure_session_column(&self) -> Result<(), CodememError> {
968        // Check if column exists by attempting a query that references it
969        let has_col: bool = self
970            .conn
971            .prepare("SELECT session_id FROM memories LIMIT 0")
972            .is_ok();
973        if !has_col {
974            self.conn
975                .execute_batch("ALTER TABLE memories ADD COLUMN session_id TEXT;")
976                .map_err(|e| CodememError::Storage(e.to_string()))?;
977        }
978        Ok(())
979    }
980
981    /// Start a new session. Inserts a row into the sessions table.
982    pub fn start_session(&self, id: &str, namespace: Option<&str>) -> Result<(), CodememError> {
983        let now = chrono::Utc::now().timestamp();
984        self.conn
985            .execute(
986                "INSERT OR IGNORE INTO sessions (id, namespace, started_at) VALUES (?1, ?2, ?3)",
987                params![id, namespace, now],
988            )
989            .map_err(|e| CodememError::Storage(e.to_string()))?;
990        Ok(())
991    }
992
993    /// End a session by setting ended_at and optionally a summary.
994    pub fn end_session(&self, id: &str, summary: Option<&str>) -> Result<(), CodememError> {
995        let now = chrono::Utc::now().timestamp();
996        self.conn
997            .execute(
998                "UPDATE sessions SET ended_at = ?1, summary = ?2 WHERE id = ?3",
999                params![now, summary, id],
1000            )
1001            .map_err(|e| CodememError::Storage(e.to_string()))?;
1002        Ok(())
1003    }
1004
1005    /// List sessions, optionally filtered by namespace.
1006    pub fn list_sessions(
1007        &self,
1008        namespace: Option<&str>,
1009    ) -> Result<Vec<codemem_core::Session>, CodememError> {
1010        let sql_with_ns = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions WHERE namespace = ?1 ORDER BY started_at DESC";
1011        let sql_all = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions ORDER BY started_at DESC";
1012
1013        let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<codemem_core::Session> {
1014            let started_ts: i64 = row.get(2)?;
1015            let ended_ts: Option<i64> = row.get(3)?;
1016            Ok(codemem_core::Session {
1017                id: row.get(0)?,
1018                namespace: row.get(1)?,
1019                started_at: chrono::DateTime::from_timestamp(started_ts, 0)
1020                    .unwrap_or_default()
1021                    .with_timezone(&chrono::Utc),
1022                ended_at: ended_ts.and_then(|ts| {
1023                    chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.with_timezone(&chrono::Utc))
1024                }),
1025                memory_count: row.get::<_, i64>(4).unwrap_or(0) as u32,
1026                summary: row.get(5)?,
1027            })
1028        };
1029
1030        if let Some(ns) = namespace {
1031            let mut stmt = self
1032                .conn
1033                .prepare(sql_with_ns)
1034                .map_err(|e| CodememError::Storage(e.to_string()))?;
1035            let rows = stmt
1036                .query_map(params![ns], map_row)
1037                .map_err(|e| CodememError::Storage(e.to_string()))?;
1038            rows.collect::<Result<Vec<_>, _>>()
1039                .map_err(|e| CodememError::Storage(e.to_string()))
1040        } else {
1041            let mut stmt = self
1042                .conn
1043                .prepare(sql_all)
1044                .map_err(|e| CodememError::Storage(e.to_string()))?;
1045            let rows = stmt
1046                .query_map([], map_row)
1047                .map_err(|e| CodememError::Storage(e.to_string()))?;
1048            rows.collect::<Result<Vec<_>, _>>()
1049                .map_err(|e| CodememError::Storage(e.to_string()))
1050        }
1051    }
1052}
1053
1054/// Database statistics.
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1056pub struct StorageStats {
1057    pub memory_count: usize,
1058    pub embedding_count: usize,
1059    pub node_count: usize,
1060    pub edge_count: usize,
1061}
1062
1063/// A single consolidation log entry.
1064#[derive(Debug, Clone)]
1065pub struct ConsolidationLogEntry {
1066    pub cycle_type: String,
1067    pub run_at: i64,
1068    pub affected_count: usize,
1069}
1070
1071use serde::{Deserialize, Serialize};
1072
1073/// Internal row struct for memory deserialization.
1074struct MemoryRow {
1075    id: String,
1076    content: String,
1077    memory_type: String,
1078    importance: f64,
1079    confidence: f64,
1080    access_count: i64,
1081    content_hash: String,
1082    tags: String,
1083    metadata: String,
1084    namespace: Option<String>,
1085    created_at: i64,
1086    updated_at: i64,
1087    last_accessed_at: i64,
1088}
1089
1090impl MemoryRow {
1091    fn into_memory_node(self) -> Result<MemoryNode, CodememError> {
1092        let memory_type: MemoryType = self.memory_type.parse()?;
1093        let tags: Vec<String> = serde_json::from_str(&self.tags).unwrap_or_default();
1094        let metadata: HashMap<String, serde_json::Value> =
1095            serde_json::from_str(&self.metadata).unwrap_or_default();
1096
1097        let created_at = chrono::DateTime::from_timestamp(self.created_at, 0)
1098            .unwrap_or_default()
1099            .with_timezone(&chrono::Utc);
1100        let updated_at = chrono::DateTime::from_timestamp(self.updated_at, 0)
1101            .unwrap_or_default()
1102            .with_timezone(&chrono::Utc);
1103        let last_accessed_at = chrono::DateTime::from_timestamp(self.last_accessed_at, 0)
1104            .unwrap_or_default()
1105            .with_timezone(&chrono::Utc);
1106
1107        Ok(MemoryNode {
1108            id: self.id,
1109            content: self.content,
1110            memory_type,
1111            importance: self.importance,
1112            confidence: self.confidence,
1113            access_count: self.access_count as u32,
1114            content_hash: self.content_hash,
1115            tags,
1116            metadata,
1117            namespace: self.namespace,
1118            created_at,
1119            updated_at,
1120            last_accessed_at,
1121        })
1122    }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127    use super::*;
1128    use chrono::Utc;
1129
1130    fn test_memory() -> MemoryNode {
1131        let now = Utc::now();
1132        let content = "Test memory content";
1133        MemoryNode {
1134            id: uuid::Uuid::new_v4().to_string(),
1135            content: content.to_string(),
1136            memory_type: MemoryType::Context,
1137            importance: 0.7,
1138            confidence: 1.0,
1139            access_count: 0,
1140            content_hash: Storage::content_hash(content),
1141            tags: vec!["test".to_string()],
1142            metadata: HashMap::new(),
1143            namespace: None,
1144            created_at: now,
1145            updated_at: now,
1146            last_accessed_at: now,
1147        }
1148    }
1149
1150    fn test_memory_with_metadata(
1151        content: &str,
1152        tool: &str,
1153        extra: HashMap<String, serde_json::Value>,
1154    ) -> MemoryNode {
1155        let now = Utc::now();
1156        let mut metadata = extra;
1157        metadata.insert(
1158            "tool".to_string(),
1159            serde_json::Value::String(tool.to_string()),
1160        );
1161        MemoryNode {
1162            id: uuid::Uuid::new_v4().to_string(),
1163            content: content.to_string(),
1164            memory_type: MemoryType::Context,
1165            importance: 0.5,
1166            confidence: 1.0,
1167            access_count: 0,
1168            content_hash: Storage::content_hash(content),
1169            tags: vec![],
1170            metadata,
1171            namespace: None,
1172            created_at: now,
1173            updated_at: now,
1174            last_accessed_at: now,
1175        }
1176    }
1177
1178    #[test]
1179    fn insert_and_get_memory() {
1180        let storage = Storage::open_in_memory().unwrap();
1181        let memory = test_memory();
1182        storage.insert_memory(&memory).unwrap();
1183
1184        let retrieved = storage.get_memory(&memory.id).unwrap().unwrap();
1185        assert_eq!(retrieved.id, memory.id);
1186        assert_eq!(retrieved.content, memory.content);
1187        assert_eq!(retrieved.access_count, 1); // bumped on get
1188    }
1189
1190    #[test]
1191    fn dedup_by_content_hash() {
1192        let storage = Storage::open_in_memory().unwrap();
1193        let m1 = test_memory();
1194        storage.insert_memory(&m1).unwrap();
1195
1196        let mut m2 = test_memory();
1197        m2.id = uuid::Uuid::new_v4().to_string();
1198        m2.content_hash = m1.content_hash.clone(); // same hash
1199
1200        assert!(matches!(
1201            storage.insert_memory(&m2),
1202            Err(CodememError::Duplicate(_))
1203        ));
1204    }
1205
1206    #[test]
1207    fn delete_memory() {
1208        let storage = Storage::open_in_memory().unwrap();
1209        let memory = test_memory();
1210        storage.insert_memory(&memory).unwrap();
1211        assert!(storage.delete_memory(&memory.id).unwrap());
1212        assert!(storage.get_memory(&memory.id).unwrap().is_none());
1213    }
1214
1215    #[test]
1216    fn store_and_get_embedding() {
1217        let storage = Storage::open_in_memory().unwrap();
1218        let memory = test_memory();
1219        storage.insert_memory(&memory).unwrap();
1220
1221        let embedding: Vec<f32> = (0..768).map(|i| i as f32 / 768.0).collect();
1222        storage.store_embedding(&memory.id, &embedding).unwrap();
1223
1224        let retrieved = storage.get_embedding(&memory.id).unwrap().unwrap();
1225        assert_eq!(retrieved.len(), 768);
1226        assert!((retrieved[0] - embedding[0]).abs() < f32::EPSILON);
1227    }
1228
1229    #[test]
1230    fn graph_node_crud() {
1231        let storage = Storage::open_in_memory().unwrap();
1232        let node = GraphNode {
1233            id: "file:src/main.rs".to_string(),
1234            kind: NodeKind::File,
1235            label: "src/main.rs".to_string(),
1236            payload: HashMap::new(),
1237            centrality: 0.0,
1238            memory_id: None,
1239            namespace: None,
1240        };
1241
1242        storage.insert_graph_node(&node).unwrap();
1243        let retrieved = storage.get_graph_node(&node.id).unwrap().unwrap();
1244        assert_eq!(retrieved.kind, NodeKind::File);
1245        assert!(storage.delete_graph_node(&node.id).unwrap());
1246    }
1247
1248    #[test]
1249    fn stats() {
1250        let storage = Storage::open_in_memory().unwrap();
1251        let stats = storage.stats().unwrap();
1252        assert_eq!(stats.memory_count, 0);
1253    }
1254
1255    // ── Pattern Detection Query Tests ───────────────────────────────────
1256
1257    #[test]
1258    fn get_repeated_searches_groups_by_pattern() {
1259        let storage = Storage::open_in_memory().unwrap();
1260
1261        // Insert 3 Grep memories with pattern "error"
1262        for i in 0..3 {
1263            let mut extra = HashMap::new();
1264            extra.insert(
1265                "pattern".to_string(),
1266                serde_json::Value::String("error".to_string()),
1267            );
1268            let mem =
1269                test_memory_with_metadata(&format!("grep search {i} for error"), "Grep", extra);
1270            storage.insert_memory(&mem).unwrap();
1271        }
1272
1273        // Insert 1 Glob memory with pattern "*.rs"
1274        let mut extra = HashMap::new();
1275        extra.insert(
1276            "pattern".to_string(),
1277            serde_json::Value::String("*.rs".to_string()),
1278        );
1279        let mem = test_memory_with_metadata("glob search for rs files", "Glob", extra);
1280        storage.insert_memory(&mem).unwrap();
1281
1282        // min_count=2: only "error" should appear
1283        let results = storage.get_repeated_searches(2, None).unwrap();
1284        assert_eq!(results.len(), 1);
1285        assert_eq!(results[0].0, "error");
1286        assert_eq!(results[0].1, 3);
1287        assert_eq!(results[0].2.len(), 3);
1288
1289        // min_count=1: both should appear
1290        let results = storage.get_repeated_searches(1, None).unwrap();
1291        assert_eq!(results.len(), 2);
1292    }
1293
1294    #[test]
1295    fn get_file_hotspots_groups_by_file_path() {
1296        let storage = Storage::open_in_memory().unwrap();
1297
1298        // Insert 4 memories referencing src/main.rs
1299        for i in 0..4 {
1300            let mut extra = HashMap::new();
1301            extra.insert(
1302                "file_path".to_string(),
1303                serde_json::Value::String("src/main.rs".to_string()),
1304            );
1305            let mem =
1306                test_memory_with_metadata(&format!("read main.rs attempt {i}"), "Read", extra);
1307            storage.insert_memory(&mem).unwrap();
1308        }
1309
1310        // Insert 1 memory for a different file
1311        let mut extra = HashMap::new();
1312        extra.insert(
1313            "file_path".to_string(),
1314            serde_json::Value::String("src/lib.rs".to_string()),
1315        );
1316        let mem = test_memory_with_metadata("read lib.rs", "Read", extra);
1317        storage.insert_memory(&mem).unwrap();
1318
1319        let results = storage.get_file_hotspots(3, None).unwrap();
1320        assert_eq!(results.len(), 1);
1321        assert_eq!(results[0].0, "src/main.rs");
1322        assert_eq!(results[0].1, 4);
1323    }
1324
1325    #[test]
1326    fn get_tool_usage_stats_counts_by_tool() {
1327        let storage = Storage::open_in_memory().unwrap();
1328
1329        // Insert various tool memories
1330        for i in 0..5 {
1331            let mem = test_memory_with_metadata(&format!("read file {i}"), "Read", HashMap::new());
1332            storage.insert_memory(&mem).unwrap();
1333        }
1334        for i in 0..3 {
1335            let mem =
1336                test_memory_with_metadata(&format!("grep search {i}"), "Grep", HashMap::new());
1337            storage.insert_memory(&mem).unwrap();
1338        }
1339        let mem = test_memory_with_metadata("edit file", "Edit", HashMap::new());
1340        storage.insert_memory(&mem).unwrap();
1341
1342        let stats = storage.get_tool_usage_stats(None).unwrap();
1343        assert_eq!(stats.get("Read"), Some(&5));
1344        assert_eq!(stats.get("Grep"), Some(&3));
1345        assert_eq!(stats.get("Edit"), Some(&1));
1346    }
1347
1348    #[test]
1349    fn get_decision_chains_groups_edits_by_file() {
1350        let storage = Storage::open_in_memory().unwrap();
1351
1352        // 3 edits to the same file
1353        for i in 0..3 {
1354            let mut extra = HashMap::new();
1355            extra.insert(
1356                "file_path".to_string(),
1357                serde_json::Value::String("src/main.rs".to_string()),
1358            );
1359            let mem = test_memory_with_metadata(&format!("edit main.rs {i}"), "Edit", extra);
1360            storage.insert_memory(&mem).unwrap();
1361        }
1362
1363        // 1 Write to a different file
1364        let mut extra = HashMap::new();
1365        extra.insert(
1366            "file_path".to_string(),
1367            serde_json::Value::String("src/new.rs".to_string()),
1368        );
1369        let mem = test_memory_with_metadata("write new.rs", "Write", extra);
1370        storage.insert_memory(&mem).unwrap();
1371
1372        let results = storage.get_decision_chains(2, None).unwrap();
1373        assert_eq!(results.len(), 1);
1374        assert_eq!(results[0].0, "src/main.rs");
1375        assert_eq!(results[0].1, 3);
1376    }
1377
1378    #[test]
1379    fn pattern_queries_empty_db() {
1380        let storage = Storage::open_in_memory().unwrap();
1381
1382        let searches = storage.get_repeated_searches(1, None).unwrap();
1383        assert!(searches.is_empty());
1384
1385        let hotspots = storage.get_file_hotspots(1, None).unwrap();
1386        assert!(hotspots.is_empty());
1387
1388        let stats = storage.get_tool_usage_stats(None).unwrap();
1389        assert!(stats.is_empty());
1390
1391        let chains = storage.get_decision_chains(1, None).unwrap();
1392        assert!(chains.is_empty());
1393    }
1394
1395    #[test]
1396    fn pattern_queries_with_namespace_filter() {
1397        let storage = Storage::open_in_memory().unwrap();
1398
1399        // Insert memories in namespace "project-a"
1400        for i in 0..3 {
1401            let mut extra = HashMap::new();
1402            extra.insert(
1403                "pattern".to_string(),
1404                serde_json::Value::String("error".to_string()),
1405            );
1406            let mut mem = test_memory_with_metadata(&format!("ns-a grep {i}"), "Grep", extra);
1407            mem.namespace = Some("project-a".to_string());
1408            storage.insert_memory(&mem).unwrap();
1409        }
1410
1411        // Insert memories in namespace "project-b"
1412        for i in 0..2 {
1413            let mut extra = HashMap::new();
1414            extra.insert(
1415                "pattern".to_string(),
1416                serde_json::Value::String("error".to_string()),
1417            );
1418            let mut mem = test_memory_with_metadata(&format!("ns-b grep {i}"), "Grep", extra);
1419            mem.namespace = Some("project-b".to_string());
1420            storage.insert_memory(&mem).unwrap();
1421        }
1422
1423        // Without namespace: 5 total
1424        let results = storage.get_repeated_searches(1, None).unwrap();
1425        assert_eq!(results.len(), 1);
1426        assert_eq!(results[0].1, 5);
1427
1428        // With namespace "project-a": only 3
1429        let results = storage.get_repeated_searches(1, Some("project-a")).unwrap();
1430        assert_eq!(results.len(), 1);
1431        assert_eq!(results[0].1, 3);
1432    }
1433
1434    // ── Session Management Tests ────────────────────────────────────────
1435
1436    #[test]
1437    fn session_lifecycle() {
1438        let storage = Storage::open_in_memory().unwrap();
1439
1440        // Start a session
1441        storage.start_session("sess-1", Some("my-project")).unwrap();
1442
1443        // List sessions
1444        let sessions = storage.list_sessions(Some("my-project")).unwrap();
1445        assert_eq!(sessions.len(), 1);
1446        assert_eq!(sessions[0].id, "sess-1");
1447        assert_eq!(sessions[0].namespace, Some("my-project".to_string()));
1448        assert!(sessions[0].ended_at.is_none());
1449
1450        // End the session
1451        storage
1452            .end_session("sess-1", Some("Explored the codebase"))
1453            .unwrap();
1454
1455        let sessions = storage.list_sessions(None).unwrap();
1456        assert_eq!(sessions.len(), 1);
1457        assert!(sessions[0].ended_at.is_some());
1458        assert_eq!(
1459            sessions[0].summary,
1460            Some("Explored the codebase".to_string())
1461        );
1462    }
1463
1464    #[test]
1465    fn ensure_session_column_idempotent() {
1466        let storage = Storage::open_in_memory().unwrap();
1467        // Should succeed even when called multiple times
1468        storage.ensure_session_column().unwrap();
1469        storage.ensure_session_column().unwrap();
1470    }
1471
1472    #[test]
1473    fn list_sessions_filters_by_namespace() {
1474        let storage = Storage::open_in_memory().unwrap();
1475
1476        storage.start_session("sess-a", Some("project-a")).unwrap();
1477        storage.start_session("sess-b", Some("project-b")).unwrap();
1478        storage.start_session("sess-c", None).unwrap();
1479
1480        let all = storage.list_sessions(None).unwrap();
1481        assert_eq!(all.len(), 3);
1482
1483        let proj_a = storage.list_sessions(Some("project-a")).unwrap();
1484        assert_eq!(proj_a.len(), 1);
1485        assert_eq!(proj_a[0].id, "sess-a");
1486    }
1487
1488    #[test]
1489    fn start_session_ignores_duplicate() {
1490        let storage = Storage::open_in_memory().unwrap();
1491        storage.start_session("sess-1", Some("ns")).unwrap();
1492        // Second call with same ID should be ignored (INSERT OR IGNORE)
1493        storage.start_session("sess-1", Some("ns")).unwrap();
1494
1495        let sessions = storage.list_sessions(None).unwrap();
1496        assert_eq!(sessions.len(), 1);
1497    }
1498}