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