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