1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use chrono::Utc;
5use rusqlite::{params, Connection};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use std::str::FromStr;
10
11use crate::store::memory::Memory;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum EntityType {
17 Concept,
18 Person,
19 Library,
20 Technology,
21 Framework,
22 FilePath,
23 Url,
24 Command,
25 Configuration,
26 Workflow,
27 Convention,
28 Architecture,
29}
30
31impl std::fmt::Display for EntityType {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 let s = match self {
34 EntityType::Concept => "concept",
35 EntityType::Person => "person",
36 EntityType::Library => "library",
37 EntityType::Technology => "technology",
38 EntityType::Framework => "framework",
39 EntityType::FilePath => "file_path",
40 EntityType::Url => "url",
41 EntityType::Command => "command",
42 EntityType::Configuration => "configuration",
43 EntityType::Workflow => "workflow",
44 EntityType::Convention => "convention",
45 EntityType::Architecture => "architecture",
46 };
47 write!(f, "{}", s)
48 }
49}
50
51impl std::str::FromStr for EntityType {
52 type Err = crate::error::MnemeError;
53
54 fn from_str(s: &str) -> Result<Self, Self::Err> {
55 match s.to_lowercase().as_str() {
56 "concept" => Ok(EntityType::Concept),
57 "person" => Ok(EntityType::Person),
58 "library" => Ok(EntityType::Library),
59 "technology" => Ok(EntityType::Technology),
60 "framework" => Ok(EntityType::Framework),
61 "file_path" => Ok(EntityType::FilePath),
62 "url" => Ok(EntityType::Url),
63 "command" => Ok(EntityType::Command),
64 "configuration" => Ok(EntityType::Configuration),
65 "workflow" => Ok(EntityType::Workflow),
66 "convention" => Ok(EntityType::Convention),
67 "architecture" => Ok(EntityType::Architecture),
68 other => Err(crate::error::MnemeError::InvalidMemoryType(
69 other.to_string(),
70 )),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct MemoryEntity {
78 pub id: i64,
79 pub memory_id: Uuid,
80 pub entity_name: String,
81 pub entity_type: EntityType,
82 pub confidence: f32,
83 pub context: Option<String>,
84 pub created_at: chrono::DateTime<Utc>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EntityLink {
90 pub id: i64,
91 pub entity_name: String,
92 pub entity_type: EntityType,
93 pub source_memory_id: Uuid,
94 pub target_memory_id: Uuid,
95 pub link_strength: f32,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct EntitySearchResult {
101 pub entity: MemoryEntity,
102 pub memory_title: String,
103 pub memory_type: String,
104 pub memory_importance: String,
105}
106
107#[derive(Debug, Clone)]
109pub struct EntityMatch {
110 pub entity_name: String,
111 pub entity_type: EntityType,
112 pub score: f32,
113}
114
115pub struct EntityStore {
117 conn: Arc<Mutex<Connection>>,
118}
119
120impl EntityStore {
121 pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
122 Self { conn }
123 }
124
125 pub fn extract_and_save(&self, memory: &Memory) -> crate::error::Result<Vec<MemoryEntity>> {
128 let entities = Self::extract_entities(memory);
129 let saved = self.save_entities(memory.id, &entities)?;
130
131 for entity in &saved {
133 self.create_links_for_entity(&entity.entity_name, &entity.entity_type, memory.id)?;
134 }
135
136 Ok(saved)
137 }
138
139 pub fn extract_entities_from_text(text: &str) -> Vec<(String, String, f32)> {
142 let memory = Memory {
144 id: uuid::Uuid::nil(),
145 project: String::new(),
146 scope: crate::store::memory::Scope::Project,
147 title: String::new(),
148 content: text.to_string(),
149 what: None,
150 why: None,
151 context: None,
152 learned: None,
153 memory_type: crate::store::memory::MemoryType::Note,
154 importance: crate::store::memory::Importance::Medium,
155 tags: Vec::new(),
156 topic_key: None,
157 access_count: 0,
158 revision_count: 0,
159 duplicate_count: 0,
160 normalized_hash: None,
161 created_at: chrono::DateTime::UNIX_EPOCH,
162 updated_at: chrono::DateTime::UNIX_EPOCH,
163 last_accessed_at: None,
164 last_seen_at: None,
165 deleted_at: None,
166 deprecated_at: None,
167 deprecated_reason: None,
168 supersedes_id: None,
169 context_inject_count: 0,
170 origin_peer: None,
171 is_encrypted: false,
172 encrypted_for: None,
173 valid_from: None,
174 valid_until: None,
175 provenance: None,
176 };
177 Self::extract_entities(&memory)
178 .into_iter()
179 .map(|(name, etype, conf, _ctx)| (name, etype.to_string(), conf))
180 .collect()
181 }
182
183 pub fn extract_entities(memory: &Memory) -> Vec<(String, EntityType, f32, Option<String>)> {
184 let mut entities: HashMap<String, (EntityType, f32, Option<String>)> = HashMap::new();
185 let content = &memory.content;
186 let _text = content.to_lowercase();
187
188 for url in Self::find_urls(content) {
190 let entry = entities
191 .entry(url.clone())
192 .or_insert_with(|| (EntityType::Url, 0.5, None));
193 entry.1 = (entry.1 + 1.0).min(1.0);
194 }
195
196 for path in Self::find_file_paths(content) {
198 let entry = entities
199 .entry(path.clone())
200 .or_insert_with(|| (EntityType::FilePath, 0.5, None));
201 entry.1 = (entry.1 + 1.0).min(1.0);
202 }
203
204 for tech in Self::find_technologies(content) {
206 let entry = entities
207 .entry(tech.clone())
208 .or_insert_with(|| (EntityType::Technology, 0.5, None));
209 entry.1 = (entry.1 + 1.0).min(1.0);
210 }
211
212 for dep in Self::find_dependencies(content) {
214 let entry = entities
215 .entry(dep.clone())
216 .or_insert_with(|| (EntityType::Library, 0.6, None));
217 entry.1 = (entry.1 + 1.0).min(1.0);
218 }
219
220 for field in [&memory.what, &memory.why, &memory.context, &memory.learned] {
222 if let Some(field_text) = field {
223 for concept in Self::find_key_concepts(field_text) {
224 let entry = entities.entry(concept.clone()).or_insert_with(|| {
225 (
226 EntityType::Concept,
227 0.4,
228 Some(field_text[..field_text.len().min(100)].to_string()),
229 )
230 });
231 entry.1 = (entry.1 + 0.5).min(1.0);
232 if entry.2.is_none() {
234 entry.2 = Some(field_text[..field_text.len().min(100)].to_string());
235 }
236 }
237 }
238 }
239
240 let _title_lower = memory.title.to_lowercase();
242 if matches!(
243 memory.memory_type,
244 crate::store::memory::MemoryType::Architecture
245 ) {
246 for concept in Self::find_key_concepts(&memory.title) {
247 let entry = entities.entry(concept.clone()).or_insert_with(|| {
248 (
249 EntityType::Architecture,
250 0.7,
251 Some(memory.title[..memory.title.len().min(100)].to_string()),
252 )
253 });
254 entry.1 = (entry.1 + 0.5).min(1.0);
255 }
256 }
257
258 entities
260 .into_iter()
261 .filter(|(_, (_, confidence, _))| *confidence >= 0.3)
262 .map(|(name, (etype, conf, ctx))| (name, etype, conf, ctx))
263 .collect()
264 }
265
266 fn find_urls(content: &str) -> Vec<String> {
267 let mut urls = Vec::new();
269 for word in content.split_whitespace() {
270 if word.starts_with("http://") || word.starts_with("https://") {
271 let clean = word.trim_end_matches(['.', ',', ')', ']', '>']);
272 if !clean.is_empty() {
273 urls.push(clean.to_string());
274 }
275 }
276 }
277 urls
278 }
279
280 fn find_file_paths(content: &str) -> Vec<String> {
281 let mut paths = Vec::new();
282 for line in content.lines() {
283 let line = line.trim();
284 if line.contains('/') && !line.starts_with("http") && !line.starts_with('#') {
286 let has_ext = line.contains('.') && line.len() - line.rfind('.').unwrap() <= 6;
288 let has_src = line.starts_with("src/") || line.contains("/src/");
289 let is_abs = line.starts_with('/');
290 if has_ext || has_src || is_abs {
291 let clean = line.trim_end_matches(['.', ',', ')', ']']);
292 if !clean.is_empty() && clean.len() > 3 {
293 paths.push(clean.to_string());
294 }
295 }
296 }
297 }
298 paths
299 }
300
301 fn find_technologies(content: &str) -> Vec<String> {
302 let mut techs = Vec::new();
303 let known_techs = [
304 "rust",
305 "python",
306 "typescript",
307 "javascript",
308 "go",
309 "react",
310 "vue",
311 "angular",
312 "node",
313 "deno",
314 "bun",
315 "sqlite",
316 "postgresql",
317 "mysql",
318 "redis",
319 "mongodb",
320 "docker",
321 "kubernetes",
322 "aws",
323 "gcp",
324 "azure",
325 "terraform",
326 "ansible",
327 "graphql",
328 "rest",
329 "grpc",
330 "websocket",
331 "tcp",
332 "udp",
333 "http",
334 "linux",
335 "macos",
336 "windows",
337 "nixos",
338 "ubuntu",
339 "debian",
340 "alpine",
341 "git",
342 "github",
343 "gitlab",
344 "ci/cd",
345 "github actions",
346 "llm",
347 "gpt",
348 "claude",
349 "gemini",
350 "openai",
351 "anthropic",
352 "ollama",
353 "mcp",
354 "api",
355 "sdk",
356 "cli",
357 "tui",
358 "gui",
359 "wasm",
360 "webassembly",
361 "docker compose",
362 "nginx",
363 "caddy",
364 "tokio",
365 "axum",
366 "actix",
367 "rocket",
368 "diesel",
369 "sqlx",
370 "seaorm",
371 "serde",
372 "clap",
373 "ratatui",
374 "crossterm",
375 "egui",
376 "tauri",
377 ];
378 let lower = content.to_lowercase();
379 for tech in &known_techs {
380 if lower.contains(tech) {
382 for window in lower.split_whitespace() {
384 let clean = window.trim_matches(|c: char| {
385 !c.is_alphanumeric() && c != '-' && c != '/' && c != '.'
386 });
387 let clean_lower = clean.to_lowercase();
388 if clean_lower == *tech
389 || clean_lower.starts_with(&format!("{}-", tech))
390 || clean_lower.starts_with(&format!("{}_", tech))
391 {
392 techs.push(clean.to_string());
393 break;
394 }
395 }
396 }
397 }
398 techs
399 }
400
401 fn find_dependencies(content: &str) -> Vec<String> {
402 let mut deps = Vec::new();
403 let dep_indicators = [
405 "dependency:",
406 "crate:",
407 "package:",
408 "library:",
409 "npm:",
410 "gem:",
411 "cargo:",
412 ];
413 let text_lower = content.to_lowercase();
414 for indicator in &dep_indicators {
415 if let Some(pos) = text_lower.find(indicator) {
416 let after = &text_lower[pos + indicator.len()..];
417 let dep_name = after
418 .split_whitespace()
419 .next()
420 .map(|s| {
421 s.trim_matches(|c: char| {
422 c == '`' || c == '"' || c == '\'' || c == ',' || c == '.'
423 })
424 })
425 .unwrap_or("")
426 .to_string();
427 if dep_name.len() > 2 {
428 deps.push(dep_name);
429 }
430 }
431 }
432 for line in content.lines() {
434 let line = line.trim();
435 if line.contains(" = ") && (line.contains('"') || line.contains('\'')) {
436 if let Some(eq_pos) = line.find(" = ") {
437 let name = line[..eq_pos].trim().to_string();
438 if name.len() > 2 && !name.starts_with('#') {
439 deps.push(name);
440 }
441 }
442 }
443 }
444 deps
445 }
446
447 fn find_key_concepts(text: &str) -> Vec<String> {
448 let mut concepts = Vec::new();
449 for word in text.split_whitespace() {
451 let clean = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
452 if clean.is_empty() || clean.len() < 4 {
453 continue;
454 }
455 let upper_count = clean.chars().filter(|c| c.is_uppercase()).count();
457 if upper_count >= 1 && clean.len() >= 4 && clean != clean.to_lowercase() {
458 concepts.push(clean.to_string());
459 }
460 if clean == clean.to_uppercase() && clean.contains('_') {
462 concepts.push(clean.to_string());
463 }
464 }
465 concepts
466 }
467
468 pub fn save_entities(
470 &self,
471 memory_id: Uuid,
472 entities: &[(String, EntityType, f32, Option<String>)],
473 ) -> crate::error::Result<Vec<MemoryEntity>> {
474 let conn = self
475 .conn
476 .lock()
477 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
478 let mut saved = Vec::new();
479
480 for (name, etype, confidence, context) in entities {
481 let now = Utc::now().to_rfc3339();
482 conn.execute(
483 "INSERT OR IGNORE INTO memory_entities (memory_id, entity_name, entity_type, confidence, context, created_at)
484 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
485 params![
486 memory_id.to_string(),
487 name,
488 etype.to_string(),
489 confidence,
490 context.as_deref(),
491 now,
492 ],
493 )?;
494
495 let id = conn.last_insert_rowid();
496 saved.push(MemoryEntity {
497 id,
498 memory_id,
499 entity_name: name.clone(),
500 entity_type: etype.clone(),
501 confidence: *confidence,
502 context: context.clone(),
503 created_at: Utc::now(),
504 });
505 }
506
507 Ok(saved)
508 }
509
510 fn create_links_for_entity(
512 &self,
513 entity_name: &str,
514 entity_type: &EntityType,
515 source_memory_id: Uuid,
516 ) -> crate::error::Result<u32> {
517 let conn = self
518 .conn
519 .lock()
520 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
521
522 let mut stmt = conn.prepare(
524 "SELECT memory_id FROM memory_entities
525 WHERE entity_name = ?1 AND entity_type = ?2 AND memory_id != ?3
526 GROUP BY memory_id",
527 )?;
528
529 let rows = stmt.query_map(
530 params![
531 entity_name,
532 entity_type.to_string(),
533 source_memory_id.to_string()
534 ],
535 |row| row.get::<_, String>(0),
536 )?;
537
538 let mut link_count = 0u32;
539 for row in rows {
540 let target_id_str: String = row?;
541 if let Ok(_target_id) = Uuid::parse_str(&target_id_str) {
542 let count: u32 = conn
544 .query_row(
545 "SELECT COUNT(*) FROM memory_entities
546 WHERE entity_name = ?1 AND entity_type = ?2
547 AND memory_id IN (?3, ?4)",
548 params![
549 entity_name,
550 entity_type.to_string(),
551 source_memory_id.to_string(),
552 target_id_str,
553 ],
554 |row| row.get(0),
555 )
556 .unwrap_or(1);
557
558 let strength = (count as f32).min(5.0) / 5.0; conn.execute(
561 "INSERT OR REPLACE INTO entity_links (entity_name, entity_type, source_memory_id, target_memory_id, link_strength, created_at)
562 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
563 params![
564 entity_name,
565 entity_type.to_string(),
566 source_memory_id.to_string(),
567 target_id_str,
568 strength,
569 Utc::now().to_rfc3339(),
570 ],
571 )?;
572 link_count += 1;
573 }
574 }
575
576 Ok(link_count)
577 }
578
579 pub fn search_entities(
581 &self,
582 query: &str,
583 entity_type: Option<&EntityType>,
584 limit: u32,
585 ) -> crate::error::Result<Vec<EntitySearchResult>> {
586 let conn = self
587 .conn
588 .lock()
589 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
590
591 let like_pattern = format!("%{}%", query);
592 let limit_i64 = limit as i64;
593
594 let sql = if let Some(_etype) = entity_type {
595 "SELECT e.id, e.memory_id, e.entity_name, e.entity_type, e.confidence, e.context, e.created_at,
596 m.title, m.memory_type, m.importance
597 FROM memory_entities e
598 JOIN memories m ON m.id = e.memory_id
599 WHERE e.entity_name LIKE ?1 AND e.entity_type = ?2 AND m.deleted_at IS NULL
600 ORDER BY e.confidence DESC, LENGTH(e.entity_name) ASC
601 LIMIT ?3".to_string()
602 } else {
603 "SELECT e.id, e.memory_id, e.entity_name, e.entity_type, e.confidence, e.context, e.created_at,
604 m.title, m.memory_type, m.importance
605 FROM memory_entities e
606 JOIN memories m ON m.id = e.memory_id
607 WHERE e.entity_name LIKE ?1 AND m.deleted_at IS NULL
608 ORDER BY e.confidence DESC, LENGTH(e.entity_name) ASC
609 LIMIT ?2".to_string()
610 };
611
612 let mut stmt = conn.prepare(&sql)?;
613 let rows: Vec<EntitySearchResult>;
614
615 if let Some(etype) = entity_type {
616 rows = stmt
617 .query_map(params![like_pattern, etype.to_string(), limit_i64], |row| {
618 Ok(EntitySearchResult {
619 entity: MemoryEntity {
620 id: row.get(0)?,
621 memory_id: Uuid::parse_str(&row.get::<_, String>(1)?).map_err(|e| {
622 rusqlite::Error::FromSqlConversionFailure(
623 1,
624 rusqlite::types::Type::Text,
625 Box::new(e),
626 )
627 })?,
628 entity_name: row.get(2)?,
629 entity_type: EntityType::from_str(&row.get::<_, String>(3)?).map_err(
630 |e| {
631 rusqlite::Error::FromSqlConversionFailure(
632 3,
633 rusqlite::types::Type::Text,
634 Box::new(e),
635 )
636 },
637 )?,
638 confidence: row.get(4)?,
639 context: row.get(5)?,
640 created_at: chrono::DateTime::parse_from_rfc3339(
641 &row.get::<_, String>(6)?,
642 )
643 .map_err(|e| {
644 rusqlite::Error::FromSqlConversionFailure(
645 6,
646 rusqlite::types::Type::Text,
647 Box::new(e),
648 )
649 })?
650 .with_timezone(&Utc),
651 },
652 memory_title: row.get(7)?,
653 memory_type: row.get(8)?,
654 memory_importance: row.get(9)?,
655 })
656 })?
657 .collect::<Result<Vec<_>, _>>()?;
658 } else {
659 rows = stmt
660 .query_map(params![like_pattern, limit_i64], |row| {
661 Ok(EntitySearchResult {
662 entity: MemoryEntity {
663 id: row.get(0)?,
664 memory_id: Uuid::parse_str(&row.get::<_, String>(1)?).map_err(|e| {
665 rusqlite::Error::FromSqlConversionFailure(
666 1,
667 rusqlite::types::Type::Text,
668 Box::new(e),
669 )
670 })?,
671 entity_name: row.get(2)?,
672 entity_type: EntityType::from_str(&row.get::<_, String>(3)?).map_err(
673 |e| {
674 rusqlite::Error::FromSqlConversionFailure(
675 3,
676 rusqlite::types::Type::Text,
677 Box::new(e),
678 )
679 },
680 )?,
681 confidence: row.get(4)?,
682 context: row.get(5)?,
683 created_at: chrono::DateTime::parse_from_rfc3339(
684 &row.get::<_, String>(6)?,
685 )
686 .map_err(|e| {
687 rusqlite::Error::FromSqlConversionFailure(
688 6,
689 rusqlite::types::Type::Text,
690 Box::new(e),
691 )
692 })?
693 .with_timezone(&Utc),
694 },
695 memory_title: row.get(7)?,
696 memory_type: row.get(8)?,
697 memory_importance: row.get(9)?,
698 })
699 })?
700 .collect::<Result<Vec<_>, _>>()?;
701 }
702
703 Ok(rows)
704 }
705
706 pub fn get_memory_entities(&self, memory_id: Uuid) -> crate::error::Result<Vec<MemoryEntity>> {
708 let conn = self
709 .conn
710 .lock()
711 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
712 let mut stmt = conn.prepare(
713 "SELECT id, memory_id, entity_name, entity_type, confidence, context, created_at
714 FROM memory_entities WHERE memory_id = ?1 ORDER BY confidence DESC",
715 )?;
716
717 let rows = stmt.query_map(params![memory_id.to_string()], |row| {
718 Ok(MemoryEntity {
719 id: row.get(0)?,
720 memory_id: Uuid::parse_str(&row.get::<_, String>(1)?).map_err(|e| {
721 rusqlite::Error::FromSqlConversionFailure(
722 1,
723 rusqlite::types::Type::Text,
724 Box::new(e),
725 )
726 })?,
727 entity_name: row.get(2)?,
728 entity_type: EntityType::from_str(&row.get::<_, String>(3)?).map_err(|e| {
729 rusqlite::Error::FromSqlConversionFailure(
730 3,
731 rusqlite::types::Type::Text,
732 Box::new(e),
733 )
734 })?,
735 confidence: row.get(4)?,
736 context: row.get(5)?,
737 created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(6)?)
738 .map_err(|e| {
739 rusqlite::Error::FromSqlConversionFailure(
740 6,
741 rusqlite::types::Type::Text,
742 Box::new(e),
743 )
744 })?
745 .with_timezone(&Utc),
746 })
747 })?;
748
749 let mut entities = Vec::new();
750 for row in rows {
751 entities.push(row?);
752 }
753 Ok(entities)
754 }
755
756 pub fn get_memory_links(
758 &self,
759 memory_id: Uuid,
760 limit: u32,
761 ) -> crate::error::Result<Vec<(EntityLink, String)>> {
762 let conn = self
763 .conn
764 .lock()
765 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
766 let mut stmt = conn.prepare(
767 "SELECT l.id, l.entity_name, l.entity_type, l.source_memory_id, l.target_memory_id,
768 l.link_strength, m.title
769 FROM entity_links l
770 JOIN memories m ON m.id = l.target_memory_id
771 WHERE l.source_memory_id = ?1 AND m.deleted_at IS NULL
772 ORDER BY l.link_strength DESC
773 LIMIT ?2",
774 )?;
775
776 let limit_i64 = limit as i64;
777 let rows = stmt.query_map(params![memory_id.to_string(), limit_i64], |row| {
778 let link = EntityLink {
779 id: row.get(0)?,
780 entity_name: row.get(1)?,
781 entity_type: EntityType::from_str(&row.get::<_, String>(2)?).map_err(|e| {
782 rusqlite::Error::FromSqlConversionFailure(
783 2,
784 rusqlite::types::Type::Text,
785 Box::new(e),
786 )
787 })?,
788 source_memory_id: Uuid::parse_str(&row.get::<_, String>(3)?).map_err(|e| {
789 rusqlite::Error::FromSqlConversionFailure(
790 3,
791 rusqlite::types::Type::Text,
792 Box::new(e),
793 )
794 })?,
795 target_memory_id: Uuid::parse_str(&row.get::<_, String>(4)?).map_err(|e| {
796 rusqlite::Error::FromSqlConversionFailure(
797 4,
798 rusqlite::types::Type::Text,
799 Box::new(e),
800 )
801 })?,
802 link_strength: row.get(5)?,
803 };
804 let target_title: String = row.get(6)?;
805 Ok((link, target_title))
806 })?;
807
808 let mut results = Vec::new();
809 for row in rows {
810 results.push(row?);
811 }
812 Ok(results)
813 }
814
815 pub fn frequent_entities(
817 &self,
818 project: &str,
819 limit: u32,
820 ) -> crate::error::Result<Vec<(String, EntityType, u32)>> {
821 let conn = self
822 .conn
823 .lock()
824 .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
825 let mut stmt = conn.prepare(
826 "SELECT e.entity_name, e.entity_type, COUNT(DISTINCT e.memory_id) as memory_count
827 FROM memory_entities e
828 JOIN memories m ON m.id = e.memory_id
829 WHERE m.project = ?1 AND m.deleted_at IS NULL
830 GROUP BY e.entity_name, e.entity_type
831 ORDER BY memory_count DESC
832 LIMIT ?2",
833 )?;
834
835 let limit_i64 = limit as i64;
836 let rows = stmt.query_map(params![project, limit_i64], |row| {
837 let entity_type = EntityType::from_str(&row.get::<_, String>(1)?).map_err(|e| {
838 rusqlite::Error::FromSqlConversionFailure(
839 1,
840 rusqlite::types::Type::Text,
841 Box::new(e),
842 )
843 })?;
844 Ok((row.get::<_, String>(0)?, entity_type, row.get::<_, u32>(2)?))
845 })?;
846
847 let mut results = Vec::new();
848 for row in rows {
849 results.push(row?);
850 }
851 Ok(results)
852 }
853}