1use crate::embedder::f32_to_bytes;
8use crate::entity_type::EntityType;
9use crate::errors::AppError;
10use crate::parsers::normalize_entity_name;
11use crate::storage::utils::with_busy_retry;
12use rusqlite::{params, Connection};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Serialize, Deserialize, Clone)]
20#[serde(deny_unknown_fields)]
21pub struct NewEntity {
22 pub name: String,
23 #[serde(alias = "type")]
24 pub entity_type: EntityType,
25 pub description: Option<String>,
26}
27
28#[derive(Debug, Serialize, Deserialize, Clone)]
33#[serde(deny_unknown_fields)]
34pub struct NewRelationship {
35 #[serde(alias = "from")]
36 pub source: String,
37 #[serde(alias = "to")]
38 pub target: String,
39 #[serde(alias = "type")]
40 pub relation: String,
41 #[serde(alias = "weight")]
42 pub strength: f64,
43 pub description: Option<String>,
44}
45
46pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
55 if name.len() < 2 {
56 return Err(AppError::Validation(format!(
57 "entity name '{name}' must be at least 2 characters"
58 )));
59 }
60 if name.contains('\n') || name.contains('\r') {
61 return Err(AppError::Validation(
62 "entity name must not contain newline characters".to_string(),
63 ));
64 }
65 if name.chars().all(|c| c.is_ascii_digit()) {
69 return Err(AppError::Validation(format!(
70 "entity name '{name}' rejected: purely numeric names look like entity IDs — \
71 use --from-id/--to-id for ID-based linking, or pass a non-numeric name"
72 )));
73 }
74 if name.len() <= 4
75 && name
76 .chars()
77 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
78 {
79 return Err(AppError::Validation(format!(
80 "entity name '{name}' rejected: short ALL_CAPS names are typically NER noise"
81 )));
82 }
83 Ok(())
84}
85
86#[derive(Debug, Clone)]
88pub struct FuzzyEntityMatch {
89 pub id: i64,
90 pub name: String,
91 pub score: f64,
93}
94
95pub fn entity_name_similarity(query: &str, name: &str) -> f64 {
101 let q = query.trim().to_ascii_lowercase();
102 let n = name.trim().to_ascii_lowercase();
103 if q.is_empty() || n.is_empty() {
104 return 0.0;
105 }
106 if q == n {
107 return 1.0;
108 }
109 if n.starts_with(&q) {
111 let rest = &n[q.len()..];
112 if rest.is_empty()
113 || rest.starts_with('-')
114 || rest.starts_with('_')
115 || rest.starts_with(' ')
116 {
117 return 0.95;
118 }
119 return 0.88;
121 }
122 if q.starts_with(&n) && n.len() >= 3 {
123 return 0.80;
124 }
125 let first_token = n
126 .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
127 .next()
128 .unwrap_or(n.as_str());
129 if first_token == q {
130 return 0.92;
131 }
132 if n.contains(&q) && q.len() >= 3 {
133 return 0.82;
134 }
135 rapidfuzz::distance::jaro_winkler::normalized_similarity(q.chars(), n.chars())
136}
137
138pub fn suggest_entity_names(
143 conn: &Connection,
144 namespace: &str,
145 query: &str,
146 limit: usize,
147 min_score: f64,
148) -> Result<Vec<FuzzyEntityMatch>, AppError> {
149 let entities = list_entities(conn, Some(namespace))?;
150 let mut scored: Vec<FuzzyEntityMatch> = entities
151 .into_iter()
152 .filter_map(|e| {
153 let score = entity_name_similarity(query, &e.name);
154 if score >= min_score {
155 Some(FuzzyEntityMatch {
156 id: e.id,
157 name: e.name,
158 score,
159 })
160 } else {
161 None
162 }
163 })
164 .collect();
165 scored.sort_by(|a, b| {
166 b.score
167 .partial_cmp(&a.score)
168 .unwrap_or(std::cmp::Ordering::Equal)
169 .then_with(|| a.name.cmp(&b.name))
170 });
171 scored.truncate(limit.max(1));
172 Ok(scored)
173}
174
175pub fn resolve_entity_fuzzy(
184 conn: &Connection,
185 namespace: &str,
186 name: &str,
187 auto_fuzzy: bool,
188) -> Result<Option<(i64, String, bool)>, AppError> {
189 if let Some(id) = find_entity_id(conn, namespace, name)? {
190 return Ok(Some((id, name.to_string(), false)));
191 }
192 let normalized = crate::parsers::normalize_entity_name(name);
195 if normalized != name {
196 if let Some(id) = find_entity_id(conn, namespace, &normalized)? {
197 return Ok(Some((id, normalized, false)));
198 }
199 }
200 if !auto_fuzzy {
201 return Ok(None);
202 }
203 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.75)?;
204 if suggestions.is_empty() {
205 return Ok(None);
206 }
207 let top = &suggestions[0];
208 let clear_winner =
209 top.score >= 0.90 && (suggestions.len() == 1 || top.score - suggestions[1].score >= 0.05);
210 let single_strong = suggestions.len() == 1 && top.score >= 0.85;
211 if clear_winner || single_strong {
212 tracing::warn!(
213 target: "entities",
214 query = %name,
215 resolved = %top.name,
216 score = top.score,
217 "fuzzy entity resolution: exact match failed; using best candidate"
218 );
219 return Ok(Some((top.id, top.name.clone(), true)));
220 }
221 Ok(None)
222}
223
224pub fn entity_not_found_with_suggestions(
226 conn: &Connection,
227 namespace: &str,
228 name: &str,
229) -> AppError {
230 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.70).unwrap_or_default();
231 if suggestions.is_empty() {
232 return AppError::NotFound(format!(
233 "entity '{name}' not found in namespace '{namespace}'"
234 ));
235 }
236 let list: Vec<String> = suggestions
237 .iter()
238 .map(|s| format!("{} (score={:.2})", s.name, s.score))
239 .collect();
240 AppError::NotFound(format!(
241 "entity '{name}' not found in namespace '{namespace}'. Did you mean: {}? \
242 Re-run with --fuzzy to auto-resolve a clear match, or pass the canonical name.",
243 list.join(", ")
244 ))
245}
246
247pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
256 validate_entity_name(&e.name)?;
259 let normalized_name = normalize_entity_name(&e.name);
261 if normalized_name.chars().count() < 2 {
264 return Err(AppError::Validation(format!(
265 "entity name '{}' normalizes to '{}' which is too short (minimum 2 characters)",
266 e.name, normalized_name
267 )));
268 }
269 conn.execute(
270 "INSERT INTO entities (namespace, name, type, description)
271 VALUES (?1, ?2, ?3, ?4)
272 ON CONFLICT(namespace, name) DO UPDATE SET
273 type = excluded.type,
274 description = COALESCE(excluded.description, entities.description),
275 updated_at = unixepoch()",
276 params![namespace, normalized_name, e.entity_type, e.description],
277 )?;
278 let id: i64 = conn.query_row(
279 "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
280 params![namespace, normalized_name],
281 |r| r.get(0),
282 )?;
283 Ok(id)
284}
285
286pub fn upsert_entity_vec(
297 conn: &Connection,
298 entity_id: i64,
299 namespace: &str,
300 _entity_type: EntityType,
301 embedding: &[f32],
302 _name: &str,
303) -> Result<(), AppError> {
304 if embedding.is_empty() {
309 tracing::debug!(
310 entity_id,
311 "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
312 );
313 return Ok(());
314 }
315 let embedding_bytes = f32_to_bytes(embedding);
316 with_busy_retry(|| {
317 conn.execute(
318 "DELETE FROM entity_embeddings WHERE entity_id = ?1",
319 params![entity_id],
320 )?;
321 conn.execute(
322 "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
323 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
324 params![
325 entity_id,
326 namespace,
327 &embedding_bytes,
328 "llm-headless",
329 crate::constants::SQLITE_GRAPHRAG_VERSION,
330 crate::constants::embedding_dim() as i64,
331 ],
332 )?;
333 Ok(())
334 })
335}
336
337pub fn upsert_relationship(
346 conn: &Connection,
347 namespace: &str,
348 source_id: i64,
349 target_id: i64,
350 rel: &NewRelationship,
351) -> Result<i64, AppError> {
352 conn.execute(
353 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
354 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
355 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
356 weight = excluded.weight,
357 description = COALESCE(excluded.description, relationships.description)",
358 params![
359 namespace,
360 source_id,
361 target_id,
362 rel.relation,
363 rel.strength,
364 rel.description
365 ],
366 )?;
367 let id: i64 = conn.query_row(
368 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
369 params![source_id, target_id, rel.relation],
370 |r| r.get(0),
371 )?;
372 Ok(id)
373}
374
375pub fn link_memory_entity(
381 conn: &Connection,
382 memory_id: i64,
383 entity_id: i64,
384) -> Result<(), AppError> {
385 conn.execute(
386 "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
387 params![memory_id, entity_id],
388 )?;
389 Ok(())
390}
391
392pub fn link_memory_relationship(
398 conn: &Connection,
399 memory_id: i64,
400 rel_id: i64,
401) -> Result<(), AppError> {
402 conn.execute(
403 "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
404 params![memory_id, rel_id],
405 )?;
406 Ok(())
407}
408
409pub fn unlink_memory_entity(
419 conn: &Connection,
420 memory_id: i64,
421 entity_id: i64,
422) -> Result<u64, AppError> {
423 let affected = conn.execute(
424 "DELETE FROM memory_entities WHERE memory_id = ?1 AND entity_id = ?2",
425 params![memory_id, entity_id],
426 )?;
427 Ok(affected as u64)
428}
429
430pub fn clear_memory_graph_bindings(
440 conn: &Connection,
441 memory_id: i64,
442) -> Result<(u64, u64), AppError> {
443 let entities_removed = conn.execute(
444 "DELETE FROM memory_entities WHERE memory_id = ?1",
445 params![memory_id],
446 )? as u64;
447 let rels_removed = conn.execute(
448 "DELETE FROM memory_relationships WHERE memory_id = ?1",
449 params![memory_id],
450 )? as u64;
451 Ok((entities_removed, rels_removed))
452}
453
454pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
460 conn.execute(
461 "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
462 params![entity_id],
463 )?;
464 Ok(())
465}
466
467pub fn find_entity_id(
473 conn: &Connection,
474 namespace: &str,
475 name: &str,
476) -> Result<Option<i64>, AppError> {
477 let name = normalize_entity_name(name);
483 let mut stmt =
484 conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
485 match stmt.query_row(params![namespace, &name], |r| r.get::<_, i64>(0)) {
486 Ok(id) => Ok(Some(id)),
487 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
488 Err(e) => Err(AppError::Database(e)),
489 }
490}
491
492#[derive(Debug, Serialize)]
494pub struct RelationshipRow {
495 pub id: i64,
496 pub namespace: String,
497 pub source_id: i64,
498 pub target_id: i64,
499 pub relation: String,
500 pub weight: f64,
501 pub description: Option<String>,
502}
503
504pub fn find_relationship(
510 conn: &Connection,
511 source_id: i64,
512 target_id: i64,
513 relation: &str,
514) -> Result<Option<RelationshipRow>, AppError> {
515 let mut stmt = conn.prepare_cached(
516 "SELECT id, namespace, source_id, target_id, relation, weight, description
517 FROM relationships
518 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
519 )?;
520 match stmt.query_row(params![source_id, target_id, relation], |r| {
521 Ok(RelationshipRow {
522 id: r.get(0)?,
523 namespace: r.get(1)?,
524 source_id: r.get(2)?,
525 target_id: r.get(3)?,
526 relation: r.get(4)?,
527 weight: r.get(5)?,
528 description: r.get(6)?,
529 })
530 }) {
531 Ok(row) => Ok(Some(row)),
532 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
533 Err(e) => Err(AppError::Database(e)),
534 }
535}
536
537pub fn create_or_fetch_relationship(
545 conn: &Connection,
546 namespace: &str,
547 source_id: i64,
548 target_id: i64,
549 relation: &str,
550 weight: f64,
551 description: Option<&str>,
552) -> Result<(i64, bool), AppError> {
553 let existing = find_relationship(conn, source_id, target_id, relation)?;
555 if let Some(row) = existing {
556 if (row.weight - weight).abs() > f64::EPSILON {
557 conn.execute(
558 "UPDATE relationships SET weight = ?1 WHERE id = ?2",
559 params![weight, row.id],
560 )?;
561 }
562 return Ok((row.id, false));
563 }
564 conn.execute(
565 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
566 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
567 params![
568 namespace,
569 source_id,
570 target_id,
571 relation,
572 weight,
573 description
574 ],
575 )?;
576 let id: i64 = conn.query_row(
577 "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
578 params![source_id, target_id, relation],
579 |r| r.get(0),
580 )?;
581 Ok((id, true))
582}
583
584pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
590 conn.execute(
591 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
592 params![relationship_id],
593 )?;
594 conn.execute(
595 "DELETE FROM relationships WHERE id = ?1",
596 params![relationship_id],
597 )?;
598 Ok(())
599}
600
601pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
607 conn.execute(
608 "UPDATE entities
609 SET degree = (SELECT COUNT(*) FROM relationships
610 WHERE source_id = entities.id OR target_id = entities.id)
611 WHERE id = ?1",
612 params![entity_id],
613 )?;
614 Ok(())
615}
616
617#[derive(Debug, Serialize, Clone)]
619pub struct EntityNode {
620 pub id: i64,
621 pub name: String,
622 pub namespace: String,
623 pub kind: String,
624}
625
626pub fn list_entities(
632 conn: &Connection,
633 namespace: Option<&str>,
634) -> Result<Vec<EntityNode>, AppError> {
635 if let Some(ns) = namespace {
636 let mut stmt = conn.prepare_cached(
637 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
638 )?;
639 let rows = stmt
640 .query_map(params![ns], |r| {
641 Ok(EntityNode {
642 id: r.get(0)?,
643 name: r.get(1)?,
644 namespace: r.get(2)?,
645 kind: r.get(3)?,
646 })
647 })?
648 .collect::<Result<Vec<_>, _>>()?;
649 Ok(rows)
650 } else {
651 let mut stmt = conn.prepare_cached(
652 "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
653 )?;
654 let rows = stmt
655 .query_map([], |r| {
656 Ok(EntityNode {
657 id: r.get(0)?,
658 name: r.get(1)?,
659 namespace: r.get(2)?,
660 kind: r.get(3)?,
661 })
662 })?
663 .collect::<Result<Vec<_>, _>>()?;
664 Ok(rows)
665 }
666}
667
668pub fn list_relationships_by_namespace(
674 conn: &Connection,
675 namespace: Option<&str>,
676) -> Result<Vec<RelationshipRow>, AppError> {
677 if let Some(ns) = namespace {
678 let mut stmt = conn.prepare_cached(
679 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
680 FROM relationships r
681 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
682 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
683 ORDER BY r.id",
684 )?;
685 let rows = stmt
686 .query_map(params![ns], |r| {
687 Ok(RelationshipRow {
688 id: r.get(0)?,
689 namespace: r.get(1)?,
690 source_id: r.get(2)?,
691 target_id: r.get(3)?,
692 relation: r.get(4)?,
693 weight: r.get(5)?,
694 description: r.get(6)?,
695 })
696 })?
697 .collect::<Result<Vec<_>, _>>()?;
698 Ok(rows)
699 } else {
700 let mut stmt = conn.prepare_cached(
701 "SELECT id, namespace, source_id, target_id, relation, weight, description
702 FROM relationships ORDER BY id",
703 )?;
704 let rows = stmt
705 .query_map([], |r| {
706 Ok(RelationshipRow {
707 id: r.get(0)?,
708 namespace: r.get(1)?,
709 source_id: r.get(2)?,
710 target_id: r.get(3)?,
711 relation: r.get(4)?,
712 weight: r.get(5)?,
713 description: r.get(6)?,
714 })
715 })?
716 .collect::<Result<Vec<_>, _>>()?;
717 Ok(rows)
718 }
719}
720
721pub fn find_orphan_entity_ids(
727 conn: &Connection,
728 namespace: Option<&str>,
729) -> Result<Vec<i64>, AppError> {
730 if let Some(ns) = namespace {
731 let mut stmt = conn.prepare_cached(
732 "SELECT e.id FROM entities e
733 WHERE e.namespace = ?1
734 AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
735 AND NOT EXISTS (
736 SELECT 1 FROM relationships r
737 WHERE r.source_id = e.id OR r.target_id = e.id
738 )",
739 )?;
740 let ids = stmt
741 .query_map(params![ns], |r| r.get::<_, i64>(0))?
742 .collect::<Result<Vec<_>, _>>()?;
743 Ok(ids)
744 } else {
745 let mut stmt = conn.prepare_cached(
746 "SELECT e.id FROM entities e
747 WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
748 AND NOT EXISTS (
749 SELECT 1 FROM relationships r
750 WHERE r.source_id = e.id OR r.target_id = e.id
751 )",
752 )?;
753 let ids = stmt
754 .query_map([], |r| r.get::<_, i64>(0))?
755 .collect::<Result<Vec<_>, _>>()?;
756 Ok(ids)
757 }
758}
759
760pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
766 if entity_ids.is_empty() {
767 return Ok(0);
768 }
769 let mut removed = 0usize;
770 for id in entity_ids {
771 let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
773 let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
774 removed += affected;
775 }
776 Ok(removed)
777}
778
779pub fn count_relationships_by_relation(
788 conn: &Connection,
789 namespace: &str,
790 relation: &str,
791) -> Result<usize, AppError> {
792 let count: i64 = conn.query_row(
793 "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
794 params![namespace, relation],
795 |r| r.get(0),
796 )?;
797 Ok(count as usize)
798}
799
800pub fn list_entity_names_by_relation(
809 conn: &Connection,
810 namespace: &str,
811 relation: &str,
812) -> Result<Vec<String>, AppError> {
813 let mut stmt = conn.prepare_cached(
814 "SELECT DISTINCT e.name FROM entities e
815 INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
816 WHERE r.namespace = ?1 AND r.relation = ?2
817 ORDER BY e.name",
818 )?;
819 let names: Vec<String> = stmt
820 .query_map(params![namespace, relation], |row| row.get(0))?
821 .collect::<Result<Vec<_>, _>>()?;
822 Ok(names)
823}
824
825pub fn delete_relationships_by_relation(
836 conn: &Connection,
837 namespace: &str,
838 relation: &str,
839) -> Result<(usize, Vec<i64>), AppError> {
840 let mut stmt = conn.prepare_cached(
842 "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
843 UNION
844 SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
845 )?;
846 let entity_ids: Vec<i64> = stmt
847 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
848 .collect::<Result<Vec<_>, _>>()?;
849
850 let mut id_stmt =
852 conn.prepare_cached("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
853 let rel_ids: Vec<i64> = id_stmt
854 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
855 .collect::<Result<Vec<_>, _>>()?;
856
857 let mut total_deleted: usize = 0;
859 for chunk in rel_ids.chunks(1000) {
860 for &rel_id in chunk {
861 conn.execute(
862 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
863 params![rel_id],
864 )?;
865 let affected =
866 conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
867 total_deleted += affected;
868 }
869 }
870
871 for &eid in &entity_ids {
873 recalculate_degree(conn, eid)?;
874 }
875
876 Ok((total_deleted, entity_ids))
877}
878
879pub fn knn_search(
892 conn: &Connection,
893 embedding: &[f32],
894 namespace: &str,
895 k: usize,
896) -> Result<Vec<(i64, f32)>, AppError> {
897 if embedding.len() != crate::constants::embedding_dim() {
898 return Err(AppError::Embedding(format!(
899 "knn_search embedding has {} dims, expected {}",
900 embedding.len(),
901 crate::constants::embedding_dim()
902 )));
903 }
904 let mut stmt = conn.prepare_cached(
905 "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
906 )?;
907 let mut scored: Vec<(i64, f32)> = stmt
908 .query_map(params![namespace], |r| {
909 let id: i64 = r.get(0)?;
910 let bytes: Vec<u8> = r.get(1)?;
911 Ok((id, bytes))
912 })?
913 .filter_map(|row| {
914 row.ok().and_then(|(id, bytes)| {
915 let stored = crate::embedder::bytes_to_f32(&bytes);
916 if stored.len() != embedding.len() {
917 return None;
918 }
919 let score = crate::similarity::cosine_similarity(embedding, &stored);
920 Some((id, score))
921 })
922 })
923 .collect();
924 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
927 scored.truncate(k);
928 Ok(scored)
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934 use crate::constants::embedding_dim;
935 use crate::entity_type::EntityType;
936 use crate::storage::connection::register_vec_extension;
937 use rusqlite::Connection;
938 use tempfile::TempDir;
939
940 type TestResult = Result<(), Box<dyn std::error::Error>>;
941
942 fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
943 register_vec_extension();
944 let tmp = TempDir::new()?;
945 let db_path = tmp.path().join("test.db");
946 let mut conn = Connection::open(&db_path)?;
947 crate::migrations::runner().run(&mut conn)?;
948 Ok((tmp, conn))
949 }
950
951 fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
952 conn.execute(
953 "INSERT INTO memories (namespace, name, type, description, body, body_hash)
954 VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
955 [],
956 )?;
957 Ok(conn.last_insert_rowid())
958 }
959
960 fn new_entity_helper(name: &str) -> NewEntity {
961 NewEntity {
962 name: name.to_string(),
963 entity_type: EntityType::Project,
964 description: None,
965 }
966 }
967
968 fn embedding_zero() -> Vec<f32> {
969 vec![0.0f32; embedding_dim()]
970 }
971
972 #[test]
977 fn test_upsert_entity_creates_new() -> TestResult {
978 let (_tmp, conn) = setup_db()?;
979 let e = new_entity_helper("projeto-alpha");
980 let id = upsert_entity(&conn, "global", &e)?;
981 assert!(id > 0);
982 Ok(())
983 }
984
985 #[test]
986 fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
987 let (_tmp, conn) = setup_db()?;
988 let e = new_entity_helper("projeto-beta");
989 let id1 = upsert_entity(&conn, "global", &e)?;
990 let id2 = upsert_entity(&conn, "global", &e)?;
991 assert_eq!(id1, id2);
992 Ok(())
993 }
994
995 #[test]
996 fn test_upsert_entity_updates_description() -> TestResult {
997 let (_tmp, conn) = setup_db()?;
998 let e1 = new_entity_helper("projeto-gamma");
999 let id1 = upsert_entity(&conn, "global", &e1)?;
1000
1001 let e2 = NewEntity {
1002 name: "projeto-gamma".to_string(),
1003 entity_type: EntityType::Tool,
1004 description: Some("nova desc".to_string()),
1005 };
1006 let id2 = upsert_entity(&conn, "global", &e2)?;
1007 assert_eq!(id1, id2);
1008
1009 let desc: Option<String> = conn.query_row(
1010 "SELECT description FROM entities WHERE id = ?1",
1011 params![id1],
1012 |r| r.get(0),
1013 )?;
1014 assert_eq!(desc.as_deref(), Some("nova desc"));
1015 Ok(())
1016 }
1017
1018 #[test]
1019 fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
1020 let (_tmp, conn) = setup_db()?;
1021 let e = new_entity_helper("compartilhada");
1022 let id1 = upsert_entity(&conn, "ns1", &e)?;
1023 let id2 = upsert_entity(&conn, "ns2", &e)?;
1024 assert_ne!(id1, id2);
1025 Ok(())
1026 }
1027
1028 #[test]
1035 fn test_upsert_entity_vec_empty_embedding_skips_row() -> TestResult {
1036 let (_tmp, conn) = setup_db()?;
1037 let e = new_entity_helper("vec-vazia");
1038 let entity_id = upsert_entity(&conn, "global", &e)?;
1039
1040 upsert_entity_vec(
1041 &conn,
1042 entity_id,
1043 "global",
1044 EntityType::Project,
1045 &[],
1046 "vec-vazia",
1047 )?;
1048
1049 let count: i64 = conn.query_row(
1050 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1051 params![entity_id],
1052 |r| r.get(0),
1053 )?;
1054 assert_eq!(count, 0, "empty embedding must not persist a row");
1055 Ok(())
1056 }
1057
1058 #[test]
1060 #[serial_test::serial(env)]
1061 fn test_upsert_entity_vec_empty_embedding_preserves_existing_row() -> TestResult {
1062 let (_tmp, conn) = setup_db()?;
1063 let e = new_entity_helper("vec-preservada");
1064 let entity_id = upsert_entity(&conn, "global", &e)?;
1065 let emb = embedding_zero();
1066 upsert_entity_vec(
1067 &conn,
1068 entity_id,
1069 "global",
1070 EntityType::Project,
1071 &emb,
1072 "vec-preservada",
1073 )?;
1074
1075 upsert_entity_vec(
1076 &conn,
1077 entity_id,
1078 "global",
1079 EntityType::Project,
1080 &[],
1081 "vec-preservada",
1082 )?;
1083
1084 let count: i64 = conn.query_row(
1085 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1086 params![entity_id],
1087 |r| r.get(0),
1088 )?;
1089 assert_eq!(count, 1, "existing vector must survive an empty upsert");
1090 Ok(())
1091 }
1092
1093 #[test]
1094 #[serial_test::serial(env)]
1095 fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
1096 let (_tmp, conn) = setup_db()?;
1097 let e = new_entity_helper("vec-nova");
1098 let entity_id = upsert_entity(&conn, "global", &e)?;
1099 let emb = embedding_zero();
1100
1101 let result = upsert_entity_vec(
1102 &conn,
1103 entity_id,
1104 "global",
1105 EntityType::Project,
1106 &emb,
1107 "vec-nova",
1108 );
1109 assert!(result.is_ok(), "first insertion must succeed");
1110
1111 let count: i64 = conn.query_row(
1112 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1113 params![entity_id],
1114 |r| r.get(0),
1115 )?;
1116 assert_eq!(count, 1, "must have exactly one row after insertion");
1117 Ok(())
1118 }
1119
1120 #[test]
1121 #[serial_test::serial(env)]
1122 fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
1123 let (_tmp, conn) = setup_db()?;
1125 let e = new_entity_helper("vec-existente");
1126 let entity_id = upsert_entity(&conn, "global", &e)?;
1127 let emb = embedding_zero();
1128
1129 upsert_entity_vec(
1130 &conn,
1131 entity_id,
1132 "global",
1133 EntityType::Project,
1134 &emb,
1135 "vec-existente",
1136 )?;
1137
1138 let result = upsert_entity_vec(
1140 &conn,
1141 entity_id,
1142 "global",
1143 EntityType::Tool,
1144 &emb,
1145 "vec-existente",
1146 );
1147 assert!(
1148 result.is_ok(),
1149 "second insertion (replace) must succeed: {result:?}"
1150 );
1151
1152 let count: i64 = conn.query_row(
1153 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1154 params![entity_id],
1155 |r| r.get(0),
1156 )?;
1157 assert_eq!(count, 1, "must have exactly one row after replacement");
1158 Ok(())
1159 }
1160
1161 #[test]
1162 #[serial_test::serial(env)]
1163 fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
1164 let (_tmp, conn) = setup_db()?;
1165 let emb = embedding_zero();
1166
1167 for i in 0..3i64 {
1168 let nome = format!("ent-{i}");
1169 let e = new_entity_helper(&nome);
1170 let entity_id = upsert_entity(&conn, "global", &e)?;
1171 upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
1172 }
1173
1174 let count: i64 =
1175 conn.query_row("SELECT COUNT(*) FROM entity_embeddings", [], |r| r.get(0))?;
1176 assert_eq!(
1177 count, 3,
1178 "must have three distinct rows in entity_embeddings"
1179 );
1180 Ok(())
1181 }
1182
1183 #[test]
1188 fn test_find_entity_id_existing_returns_some() -> TestResult {
1189 let (_tmp, conn) = setup_db()?;
1190 let e = new_entity_helper("entidade-busca");
1191 let id_inserido = upsert_entity(&conn, "global", &e)?;
1192 let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
1193 assert_eq!(id_encontrado, Some(id_inserido));
1194 Ok(())
1195 }
1196
1197 #[test]
1198 fn test_find_entity_id_missing_returns_none() -> TestResult {
1199 let (_tmp, conn) = setup_db()?;
1200 let id = find_entity_id(&conn, "global", "nao-existe")?;
1201 assert_eq!(id, None);
1202 Ok(())
1203 }
1204
1205 #[test]
1210 fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
1211 let (_tmp, conn) = setup_db()?;
1212 let removed = delete_entities_by_ids(&conn, &[])?;
1213 assert_eq!(removed, 0);
1214 Ok(())
1215 }
1216
1217 #[test]
1218 fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
1219 let (_tmp, conn) = setup_db()?;
1220 let e = new_entity_helper("to-delete");
1221 let entity_id = upsert_entity(&conn, "global", &e)?;
1222
1223 let removed = delete_entities_by_ids(&conn, &[entity_id])?;
1224 assert_eq!(removed, 1);
1225
1226 let id = find_entity_id(&conn, "global", "to-delete")?;
1227 assert_eq!(id, None, "entity must have been removed");
1228 Ok(())
1229 }
1230
1231 #[test]
1232 fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
1233 let (_tmp, conn) = setup_db()?;
1234 let removed = delete_entities_by_ids(&conn, &[9999])?;
1235 assert_eq!(removed, 0);
1236 Ok(())
1237 }
1238
1239 #[test]
1240 fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
1241 let (_tmp, conn) = setup_db()?;
1242 let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
1243 let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
1244 let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
1245
1246 let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
1247 assert_eq!(removed, 2);
1248
1249 assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
1250 assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
1251 assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
1252 let _ = id3;
1253 Ok(())
1254 }
1255
1256 #[test]
1257 fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
1258 let (_tmp, conn) = setup_db()?;
1259 let e = new_entity_helper("del-com-vec");
1260 let entity_id = upsert_entity(&conn, "global", &e)?;
1261 let emb = embedding_zero();
1262 upsert_entity_vec(
1263 &conn,
1264 entity_id,
1265 "global",
1266 EntityType::Project,
1267 &emb,
1268 "del-com-vec",
1269 )?;
1270
1271 let count_antes: i64 = conn.query_row(
1272 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1273 params![entity_id],
1274 |r| r.get(0),
1275 )?;
1276 assert_eq!(count_antes, 1);
1277
1278 delete_entities_by_ids(&conn, &[entity_id])?;
1279
1280 let count_depois: i64 = conn.query_row(
1281 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1282 params![entity_id],
1283 |r| r.get(0),
1284 )?;
1285 assert_eq!(
1286 count_depois, 0,
1287 "entity_embeddings deve ser limpo junto com entities"
1288 );
1289 Ok(())
1290 }
1291
1292 #[test]
1297 fn test_upsert_relationship_creates_new() -> TestResult {
1298 let (_tmp, conn) = setup_db()?;
1299 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
1300 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
1301
1302 let rel = NewRelationship {
1303 source: "rel-a".to_string(),
1304 target: "rel-b".to_string(),
1305 relation: "uses".to_string(),
1306 strength: 0.8,
1307 description: None,
1308 };
1309 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1310 assert!(rel_id > 0);
1311 Ok(())
1312 }
1313
1314 #[test]
1315 fn test_upsert_relationship_idempotent() -> TestResult {
1316 let (_tmp, conn) = setup_db()?;
1317 let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
1318 let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
1319
1320 let rel = NewRelationship {
1321 source: "idem-a".to_string(),
1322 target: "idem-b".to_string(),
1323 relation: "uses".to_string(),
1324 strength: 0.5,
1325 description: None,
1326 };
1327 let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1328 let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1329 assert_eq!(id1, id2);
1330 Ok(())
1331 }
1332
1333 #[test]
1334 fn test_find_relationship_existing() -> TestResult {
1335 let (_tmp, conn) = setup_db()?;
1336 let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
1337 let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
1338
1339 let rel = NewRelationship {
1340 source: "fr-a".to_string(),
1341 target: "fr-b".to_string(),
1342 relation: "depends_on".to_string(),
1343 strength: 0.7,
1344 description: None,
1345 };
1346 upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1347
1348 let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
1349 let row = encontrada.ok_or("relationship should exist")?;
1350 assert_eq!(row.source_id, id_a);
1351 assert_eq!(row.target_id, id_b);
1352 assert!((row.weight - 0.7).abs() < 1e-9);
1353 Ok(())
1354 }
1355
1356 #[test]
1357 fn test_find_relationship_missing_returns_none() -> TestResult {
1358 let (_tmp, conn) = setup_db()?;
1359 let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
1360 assert!(resultado.is_none());
1361 Ok(())
1362 }
1363
1364 #[test]
1369 fn test_link_memory_entity_idempotent() -> TestResult {
1370 let (_tmp, conn) = setup_db()?;
1371 let memory_id = insert_memory(&conn)?;
1372 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
1373
1374 link_memory_entity(&conn, memory_id, entity_id)?;
1375 let resultado = link_memory_entity(&conn, memory_id, entity_id);
1376 assert!(
1377 resultado.is_ok(),
1378 "INSERT OR IGNORE must not fail on duplicate"
1379 );
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn test_link_memory_relationship_idempotent() -> TestResult {
1385 let (_tmp, conn) = setup_db()?;
1386 let memory_id = insert_memory(&conn)?;
1387 let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
1388 let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
1389
1390 let rel = NewRelationship {
1391 source: "mr-a".to_string(),
1392 target: "mr-b".to_string(),
1393 relation: "uses".to_string(),
1394 strength: 0.5,
1395 description: None,
1396 };
1397 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1398
1399 link_memory_relationship(&conn, memory_id, rel_id)?;
1400 let resultado = link_memory_relationship(&conn, memory_id, rel_id);
1401 assert!(
1402 resultado.is_ok(),
1403 "INSERT OR IGNORE must not fail on duplicate"
1404 );
1405 Ok(())
1406 }
1407
1408 #[test]
1413 fn test_increment_degree_increases_counter() -> TestResult {
1414 let (_tmp, conn) = setup_db()?;
1415 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1416
1417 increment_degree(&conn, entity_id)?;
1418 increment_degree(&conn, entity_id)?;
1419
1420 let degree: i64 = conn.query_row(
1421 "SELECT degree FROM entities WHERE id = ?1",
1422 params![entity_id],
1423 |r| r.get(0),
1424 )?;
1425 assert_eq!(degree, 2);
1426 Ok(())
1427 }
1428
1429 #[test]
1430 fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1431 let (_tmp, conn) = setup_db()?;
1432 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1433 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1434 let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1435
1436 let rel1 = NewRelationship {
1437 source: "rc-a".to_string(),
1438 target: "rc-b".to_string(),
1439 relation: "uses".to_string(),
1440 strength: 0.5,
1441 description: None,
1442 };
1443 let rel2 = NewRelationship {
1444 source: "rc-c".to_string(),
1445 target: "rc-a".to_string(),
1446 relation: "depends_on".to_string(),
1447 strength: 0.5,
1448 description: None,
1449 };
1450 upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1451 upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1452
1453 recalculate_degree(&conn, id_a)?;
1454
1455 let degree: i64 = conn.query_row(
1456 "SELECT degree FROM entities WHERE id = ?1",
1457 params![id_a],
1458 |r| r.get(0),
1459 )?;
1460 assert_eq!(
1461 degree, 2,
1462 "rc-a appears in two relationships (source+target)"
1463 );
1464 Ok(())
1465 }
1466
1467 #[test]
1472 fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1473 let (_tmp, conn) = setup_db()?;
1474 let memory_id = insert_memory(&conn)?;
1475 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1476 link_memory_entity(&conn, memory_id, entity_id)?;
1477
1478 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1479 assert!(!orfas.contains(&entity_id));
1480 Ok(())
1481 }
1482
1483 #[test]
1484 fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1485 let (_tmp, conn) = setup_db()?;
1486 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1487
1488 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1489 assert!(orfas.contains(&entity_id));
1490 Ok(())
1491 }
1492
1493 #[test]
1494 fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1495 let (_tmp, conn) = setup_db()?;
1496 let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1497 let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1498
1499 let orfas = find_orphan_entity_ids(&conn, None)?;
1500 assert!(orfas.contains(&id1));
1501 assert!(orfas.contains(&id2));
1502 Ok(())
1503 }
1504
1505 #[test]
1510 fn test_list_entities_with_namespace() -> TestResult {
1511 let (_tmp, conn) = setup_db()?;
1512 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1513 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1514 upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1515
1516 let lista = list_entities(&conn, Some("le-ns"))?;
1517 assert_eq!(lista.len(), 2);
1518 assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1519 Ok(())
1520 }
1521
1522 #[test]
1523 fn test_list_entities_without_namespace_returns_all() -> TestResult {
1524 let (_tmp, conn) = setup_db()?;
1525 upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1526 upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1527
1528 let lista = list_entities(&conn, None)?;
1529 assert!(lista.len() >= 2);
1530 Ok(())
1531 }
1532
1533 #[test]
1534 fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1535 let (_tmp, conn) = setup_db()?;
1536 let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1537 let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1538
1539 let rel = NewRelationship {
1540 source: "lr-a".to_string(),
1541 target: "lr-b".to_string(),
1542 relation: "uses".to_string(),
1543 strength: 0.5,
1544 description: None,
1545 };
1546 upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1547
1548 let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1549 assert!(!lista.is_empty());
1550 assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1551 Ok(())
1552 }
1553
1554 #[test]
1559 fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1560 let (_tmp, conn) = setup_db()?;
1561 let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1562 let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1563
1564 let rel = NewRelationship {
1565 source: "dr-a".to_string(),
1566 target: "dr-b".to_string(),
1567 relation: "uses".to_string(),
1568 strength: 0.5,
1569 description: None,
1570 };
1571 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1572
1573 delete_relationship_by_id(&conn, rel_id)?;
1574
1575 let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1576 assert!(encontrada.is_none(), "relationship must have been removed");
1577 Ok(())
1578 }
1579
1580 #[test]
1581 fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1582 let (_tmp, conn) = setup_db()?;
1583 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1584 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1585
1586 let (rel_id, created) =
1587 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1588 assert!(rel_id > 0);
1589 assert!(created);
1590 Ok(())
1591 }
1592
1593 #[test]
1594 fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1595 let (_tmp, conn) = setup_db()?;
1596 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1597 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1598
1599 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1600 let (_, created) =
1601 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1602 assert!(
1603 !created,
1604 "second call must return the existing relationship"
1605 );
1606 Ok(())
1607 }
1608
1609 #[test]
1614 fn accepts_type_field_as_alias() -> TestResult {
1615 let json = r#"{"name": "X", "type": "concept"}"#;
1616 let ent: NewEntity = serde_json::from_str(json)?;
1617 assert_eq!(ent.entity_type, EntityType::Concept);
1618 Ok(())
1619 }
1620
1621 #[test]
1622 fn accepts_canonical_entity_type_field() -> TestResult {
1623 let json = r#"{"name": "X", "entity_type": "concept"}"#;
1624 let ent: NewEntity = serde_json::from_str(json)?;
1625 assert_eq!(ent.entity_type, EntityType::Concept);
1626 Ok(())
1627 }
1628
1629 #[test]
1630 fn both_fields_present_yields_duplicate_error() {
1631 let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1633 let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1634 assert!(
1635 resultado.is_err(),
1636 "both fields in the same JSON are a duplicate"
1637 );
1638 }
1639
1640 #[test]
1641 fn validate_entity_name_accepts_valid() {
1642 assert!(validate_entity_name("rust-lang").is_ok());
1643 assert!(validate_entity_name("sqlite-graphrag").is_ok());
1644 assert!(validate_entity_name("ab").is_ok());
1645 }
1646
1647 #[test]
1648 fn validate_entity_name_rejects_short() {
1649 assert!(validate_entity_name("a").is_err());
1650 assert!(validate_entity_name("").is_err());
1651 }
1652
1653 #[test]
1654 fn validate_entity_name_rejects_newlines() {
1655 assert!(validate_entity_name("foo\nbar").is_err());
1656 assert!(validate_entity_name("foo\rbar").is_err());
1657 }
1658
1659 #[test]
1660 fn validate_entity_name_rejects_short_allcaps() {
1661 assert!(validate_entity_name("RAM").is_err());
1662 assert!(validate_entity_name("NAO").is_err());
1663 assert!(validate_entity_name("OK").is_err());
1664 }
1665
1666 #[test]
1667 fn validate_entity_name_accepts_long_allcaps() {
1668 assert!(validate_entity_name("SQLITE").is_ok());
1669 assert!(validate_entity_name("GRAPHRAG").is_ok());
1670 }
1671
1672 #[test]
1673 fn validate_entity_name_accepts_mixed_case() {
1674 assert!(validate_entity_name("FTS5").is_ok()); assert!(validate_entity_name("WAL").is_err()); }
1677
1678 #[test]
1680 fn validate_entity_name_rejects_purely_numeric() {
1681 assert!(validate_entity_name("89975").is_err());
1682 assert!(validate_entity_name("35313").is_err());
1683 assert!(validate_entity_name("12").is_err());
1684 assert!(validate_entity_name("issue-89975").is_ok());
1686 assert!(validate_entity_name("v2").is_ok());
1687 }
1688
1689 #[test]
1690 fn entity_name_similarity_prefers_prefix_of_kebab() {
1691 let s = entity_name_similarity("danilo", "danilo-aguiar-teixeira");
1692 assert!(s >= 0.90, "expected strong prefix score, got {s}");
1693 let exact = entity_name_similarity("danilo", "danilo");
1694 assert!((exact - 1.0).abs() < f64::EPSILON);
1695 }
1696
1697 #[test]
1699 fn test_unlink_memory_entity_removes_single_binding() -> TestResult {
1700 let (_tmp, conn) = setup_db()?;
1701 let memory_id = insert_memory(&conn)?;
1702 let e1 = upsert_entity(&conn, "global", &new_entity_helper("entidade-um"))?;
1703 let e2 = upsert_entity(&conn, "global", &new_entity_helper("entidade-dois"))?;
1704 link_memory_entity(&conn, memory_id, e1)?;
1705 link_memory_entity(&conn, memory_id, e2)?;
1706
1707 let removed = unlink_memory_entity(&conn, memory_id, e1)?;
1708 assert_eq!(removed, 1);
1709
1710 let remaining: i64 = conn.query_row(
1712 "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1713 params![memory_id],
1714 |r| r.get(0),
1715 )?;
1716 assert_eq!(remaining, 1);
1717
1718 assert_eq!(unlink_memory_entity(&conn, memory_id, e1)?, 0);
1720 Ok(())
1721 }
1722
1723 #[test]
1725 fn test_clear_memory_graph_bindings_clears_all() -> TestResult {
1726 let (_tmp, conn) = setup_db()?;
1727 let memory_id = insert_memory(&conn)?;
1728 let e1 = upsert_entity(&conn, "global", &new_entity_helper("alpha-node"))?;
1729 let e2 = upsert_entity(&conn, "global", &new_entity_helper("beta-node"))?;
1730 link_memory_entity(&conn, memory_id, e1)?;
1731 link_memory_entity(&conn, memory_id, e2)?;
1732 let rel = NewRelationship {
1733 source: "alpha-node".to_string(),
1734 target: "beta-node".to_string(),
1735 relation: "related".to_string(),
1736 strength: 0.5,
1737 description: None,
1738 };
1739 let rel_id = upsert_relationship(&conn, "global", e1, e2, &rel)?;
1740 link_memory_relationship(&conn, memory_id, rel_id)?;
1741
1742 let (e_removed, r_removed) = clear_memory_graph_bindings(&conn, memory_id)?;
1743 assert_eq!(e_removed, 2);
1744 assert_eq!(r_removed, 1);
1745
1746 let ent_left: i64 = conn.query_row(
1747 "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1748 params![memory_id],
1749 |r| r.get(0),
1750 )?;
1751 let rel_left: i64 = conn.query_row(
1752 "SELECT COUNT(*) FROM memory_relationships WHERE memory_id = ?1",
1753 params![memory_id],
1754 |r| r.get(0),
1755 )?;
1756 assert_eq!(ent_left, 0);
1757 assert_eq!(rel_left, 0);
1758 Ok(())
1759 }
1760}