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