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", default = "default_relationship_strength")]
45 pub strength: f64,
46 pub description: Option<String>,
47}
48
49fn default_relationship_strength() -> f64 {
50 crate::constants::DEFAULT_RELATION_WEIGHT
51}
52
53pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
62 if name.len() < 2 {
63 return Err(AppError::Validation(format!(
64 "entity name '{name}' must be at least 2 characters"
65 )));
66 }
67 if name.contains('\n') || name.contains('\r') {
68 return Err(AppError::Validation(
69 "entity name must not contain newline characters".to_string(),
70 ));
71 }
72 if name.chars().all(|c| c.is_ascii_digit()) {
76 return Err(AppError::Validation(format!(
77 "entity name '{name}' rejected: purely numeric names look like entity IDs — \
78 use --from-id/--to-id for ID-based linking, or pass a non-numeric name"
79 )));
80 }
81 if name.len() <= 4
82 && name
83 .chars()
84 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
85 {
86 return Err(AppError::Validation(format!(
87 "entity name '{name}' rejected: short ALL_CAPS names are typically NER noise"
88 )));
89 }
90 Ok(())
91}
92
93#[derive(Debug, Clone)]
95pub struct FuzzyEntityMatch {
96 pub id: i64,
97 pub name: String,
98 pub score: f64,
100}
101
102pub fn entity_name_similarity(query: &str, name: &str) -> f64 {
108 let q = query.trim().to_ascii_lowercase();
109 let n = name.trim().to_ascii_lowercase();
110 if q.is_empty() || n.is_empty() {
111 return 0.0;
112 }
113 if q == n {
114 return 1.0;
115 }
116 if n.starts_with(&q) {
118 let rest = &n[q.len()..];
119 if rest.is_empty()
120 || rest.starts_with('-')
121 || rest.starts_with('_')
122 || rest.starts_with(' ')
123 {
124 return 0.95;
125 }
126 return 0.88;
128 }
129 if q.starts_with(&n) && n.len() >= 3 {
130 return 0.80;
131 }
132 let first_token = n
133 .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
134 .next()
135 .unwrap_or(n.as_str());
136 if first_token == q {
137 return 0.92;
138 }
139 if n.contains(&q) && q.len() >= 3 {
140 return 0.82;
141 }
142 rapidfuzz::distance::jaro_winkler::normalized_similarity(q.chars(), n.chars())
143}
144
145pub fn suggest_entity_names(
150 conn: &Connection,
151 namespace: &str,
152 query: &str,
153 limit: usize,
154 min_score: f64,
155) -> Result<Vec<FuzzyEntityMatch>, AppError> {
156 let entities = list_entities(conn, Some(namespace))?;
157 let mut scored: Vec<FuzzyEntityMatch> = entities
158 .into_iter()
159 .filter_map(|e| {
160 let score = entity_name_similarity(query, &e.name);
161 if score >= min_score {
162 Some(FuzzyEntityMatch {
163 id: e.id,
164 name: e.name,
165 score,
166 })
167 } else {
168 None
169 }
170 })
171 .collect();
172 scored.sort_by(|a, b| {
173 b.score
174 .partial_cmp(&a.score)
175 .unwrap_or(std::cmp::Ordering::Equal)
176 .then_with(|| a.name.cmp(&b.name))
177 });
178 scored.truncate(limit.max(1));
179 Ok(scored)
180}
181
182pub fn resolve_entity_fuzzy(
191 conn: &Connection,
192 namespace: &str,
193 name: &str,
194 auto_fuzzy: bool,
195) -> Result<Option<(i64, String, bool)>, AppError> {
196 if let Some(id) = find_entity_id(conn, namespace, name)? {
197 return Ok(Some((id, name.to_string(), false)));
198 }
199 let normalized = crate::parsers::normalize_entity_name(name);
202 if normalized != name {
203 if let Some(id) = find_entity_id(conn, namespace, &normalized)? {
204 return Ok(Some((id, normalized, false)));
205 }
206 }
207 if !auto_fuzzy {
208 return Ok(None);
209 }
210 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.75)?;
211 if suggestions.is_empty() {
212 return Ok(None);
213 }
214 let top = &suggestions[0];
215 let clear_winner =
216 top.score >= 0.90 && (suggestions.len() == 1 || top.score - suggestions[1].score >= 0.05);
217 let single_strong = suggestions.len() == 1 && top.score >= 0.85;
218 if clear_winner || single_strong {
219 tracing::warn!(
220 target: "entities",
221 query = %name,
222 resolved = %top.name,
223 score = top.score,
224 "fuzzy entity resolution: exact match failed; using best candidate"
225 );
226 return Ok(Some((top.id, top.name.clone(), true)));
227 }
228 Ok(None)
229}
230
231pub fn entity_not_found_with_suggestions(
233 conn: &Connection,
234 namespace: &str,
235 name: &str,
236) -> AppError {
237 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.70).unwrap_or_default();
238 if suggestions.is_empty() {
239 return AppError::NotFound(format!(
240 "entity '{name}' not found in namespace '{namespace}'"
241 ));
242 }
243 let list: Vec<String> = suggestions
244 .iter()
245 .map(|s| format!("{} (score={:.2})", s.name, s.score))
246 .collect();
247 AppError::NotFound(format!(
248 "entity '{name}' not found in namespace '{namespace}'. Did you mean: {}? \
249 Re-run with --fuzzy to auto-resolve a clear match, or pass the canonical name.",
250 list.join(", ")
251 ))
252}
253
254pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
263 validate_entity_name(&e.name)?;
266 let normalized_name = normalize_entity_name(&e.name);
268 if normalized_name.chars().count() < 2 {
271 return Err(AppError::Validation(format!(
272 "entity name '{}' normalizes to '{}' which is too short (minimum 2 characters)",
273 e.name, normalized_name
274 )));
275 }
276 conn.execute(
277 "INSERT INTO entities (namespace, name, type, description)
278 VALUES (?1, ?2, ?3, ?4)
279 ON CONFLICT(namespace, name) DO UPDATE SET
280 type = excluded.type,
281 description = COALESCE(excluded.description, entities.description),
282 updated_at = unixepoch()",
283 params![namespace, normalized_name, e.entity_type, e.description],
284 )?;
285 let id: i64 = conn.query_row(
286 "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
287 params![namespace, normalized_name],
288 |r| r.get(0),
289 )?;
290 Ok(id)
291}
292
293pub fn upsert_entity_vec(
304 conn: &Connection,
305 entity_id: i64,
306 namespace: &str,
307 _entity_type: EntityType,
308 embedding: &[f32],
309 _name: &str,
310) -> Result<(), AppError> {
311 if embedding.is_empty() {
316 tracing::debug!(
317 entity_id,
318 "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
319 );
320 return Ok(());
321 }
322 let embedding_bytes = f32_to_bytes(embedding);
323 with_busy_retry(|| {
324 conn.execute(
325 "DELETE FROM entity_embeddings WHERE entity_id = ?1",
326 params![entity_id],
327 )?;
328 conn.execute(
329 "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
330 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
331 params![
332 entity_id,
333 namespace,
334 &embedding_bytes,
335 "llm-headless",
336 crate::constants::SQLITE_GRAPHRAG_VERSION,
337 crate::constants::embedding_dim() as i64,
338 ],
339 )?;
340 Ok(())
341 })
342}
343
344pub fn upsert_relationship(
353 conn: &Connection,
354 namespace: &str,
355 source_id: i64,
356 target_id: i64,
357 rel: &NewRelationship,
358) -> Result<i64, AppError> {
359 conn.execute(
360 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
361 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
362 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
363 weight = excluded.weight,
364 description = COALESCE(excluded.description, relationships.description)",
365 params![
366 namespace,
367 source_id,
368 target_id,
369 rel.relation,
370 rel.strength,
371 rel.description
372 ],
373 )?;
374 let id: i64 = conn.query_row(
375 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
376 params![source_id, target_id, rel.relation],
377 |r| r.get(0),
378 )?;
379 Ok(id)
380}
381
382pub fn link_memory_entity(
388 conn: &Connection,
389 memory_id: i64,
390 entity_id: i64,
391) -> Result<(), AppError> {
392 conn.execute(
393 "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
394 params![memory_id, entity_id],
395 )?;
396 Ok(())
397}
398
399pub fn link_memory_relationship(
405 conn: &Connection,
406 memory_id: i64,
407 rel_id: i64,
408) -> Result<(), AppError> {
409 conn.execute(
410 "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
411 params![memory_id, rel_id],
412 )?;
413 Ok(())
414}
415
416pub fn unlink_memory_entity(
426 conn: &Connection,
427 memory_id: i64,
428 entity_id: i64,
429) -> Result<u64, AppError> {
430 let affected = conn.execute(
431 "DELETE FROM memory_entities WHERE memory_id = ?1 AND entity_id = ?2",
432 params![memory_id, entity_id],
433 )?;
434 Ok(affected as u64)
435}
436
437pub fn clear_memory_graph_bindings(
447 conn: &Connection,
448 memory_id: i64,
449) -> Result<(u64, u64), AppError> {
450 let entities_removed = conn.execute(
451 "DELETE FROM memory_entities WHERE memory_id = ?1",
452 params![memory_id],
453 )? as u64;
454 let rels_removed = conn.execute(
455 "DELETE FROM memory_relationships WHERE memory_id = ?1",
456 params![memory_id],
457 )? as u64;
458 Ok((entities_removed, rels_removed))
459}
460
461pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
467 conn.execute(
468 "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
469 params![entity_id],
470 )?;
471 Ok(())
472}
473
474pub fn find_entity_id(
480 conn: &Connection,
481 namespace: &str,
482 name: &str,
483) -> Result<Option<i64>, AppError> {
484 let name = normalize_entity_name(name);
490 let mut stmt =
491 conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
492 match stmt.query_row(params![namespace, &name], |r| r.get::<_, i64>(0)) {
493 Ok(id) => Ok(Some(id)),
494 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
495 Err(e) => Err(AppError::Database(e)),
496 }
497}
498
499#[derive(Debug, Serialize)]
501pub struct RelationshipRow {
502 pub id: i64,
503 pub namespace: String,
504 pub source_id: i64,
505 pub target_id: i64,
506 pub relation: String,
507 pub weight: f64,
508 pub description: Option<String>,
509}
510
511pub fn find_relationship(
517 conn: &Connection,
518 source_id: i64,
519 target_id: i64,
520 relation: &str,
521) -> Result<Option<RelationshipRow>, AppError> {
522 let mut stmt = conn.prepare_cached(
523 "SELECT id, namespace, source_id, target_id, relation, weight, description
524 FROM relationships
525 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
526 )?;
527 match stmt.query_row(params![source_id, target_id, relation], |r| {
528 Ok(RelationshipRow {
529 id: r.get(0)?,
530 namespace: r.get(1)?,
531 source_id: r.get(2)?,
532 target_id: r.get(3)?,
533 relation: r.get(4)?,
534 weight: r.get(5)?,
535 description: r.get(6)?,
536 })
537 }) {
538 Ok(row) => Ok(Some(row)),
539 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
540 Err(e) => Err(AppError::Database(e)),
541 }
542}
543
544pub fn create_or_fetch_relationship(
552 conn: &Connection,
553 namespace: &str,
554 source_id: i64,
555 target_id: i64,
556 relation: &str,
557 weight: f64,
558 description: Option<&str>,
559) -> Result<(i64, bool), AppError> {
560 let existing = find_relationship(conn, source_id, target_id, relation)?;
562 if let Some(row) = existing {
563 if (row.weight - weight).abs() > f64::EPSILON {
564 conn.execute(
565 "UPDATE relationships SET weight = ?1 WHERE id = ?2",
566 params![weight, row.id],
567 )?;
568 }
569 return Ok((row.id, false));
570 }
571 conn.execute(
572 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
573 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
574 params![
575 namespace,
576 source_id,
577 target_id,
578 relation,
579 weight,
580 description
581 ],
582 )?;
583 let id: i64 = conn.query_row(
584 "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
585 params![source_id, target_id, relation],
586 |r| r.get(0),
587 )?;
588 Ok((id, true))
589}
590
591pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
597 conn.execute(
598 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
599 params![relationship_id],
600 )?;
601 conn.execute(
602 "DELETE FROM relationships WHERE id = ?1",
603 params![relationship_id],
604 )?;
605 Ok(())
606}
607
608pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
614 conn.execute(
615 "UPDATE entities
616 SET degree = (SELECT COUNT(*) FROM relationships
617 WHERE source_id = entities.id OR target_id = entities.id)
618 WHERE id = ?1",
619 params![entity_id],
620 )?;
621 Ok(())
622}
623
624#[derive(Debug, Serialize, Clone)]
626pub struct EntityNode {
627 pub id: i64,
628 pub name: String,
629 pub namespace: String,
630 pub kind: String,
631}
632
633pub fn list_entities(
639 conn: &Connection,
640 namespace: Option<&str>,
641) -> Result<Vec<EntityNode>, AppError> {
642 if let Some(ns) = namespace {
643 let mut stmt = conn.prepare_cached(
644 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
645 )?;
646 let rows = stmt
647 .query_map(params![ns], |r| {
648 Ok(EntityNode {
649 id: r.get(0)?,
650 name: r.get(1)?,
651 namespace: r.get(2)?,
652 kind: r.get(3)?,
653 })
654 })?
655 .collect::<Result<Vec<_>, _>>()?;
656 Ok(rows)
657 } else {
658 let mut stmt = conn.prepare_cached(
659 "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
660 )?;
661 let rows = stmt
662 .query_map([], |r| {
663 Ok(EntityNode {
664 id: r.get(0)?,
665 name: r.get(1)?,
666 namespace: r.get(2)?,
667 kind: r.get(3)?,
668 })
669 })?
670 .collect::<Result<Vec<_>, _>>()?;
671 Ok(rows)
672 }
673}
674
675pub fn list_relationships_by_namespace(
681 conn: &Connection,
682 namespace: Option<&str>,
683) -> Result<Vec<RelationshipRow>, AppError> {
684 if let Some(ns) = namespace {
685 let mut stmt = conn.prepare_cached(
686 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
687 FROM relationships r
688 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
689 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
690 ORDER BY r.id",
691 )?;
692 let rows = stmt
693 .query_map(params![ns], |r| {
694 Ok(RelationshipRow {
695 id: r.get(0)?,
696 namespace: r.get(1)?,
697 source_id: r.get(2)?,
698 target_id: r.get(3)?,
699 relation: r.get(4)?,
700 weight: r.get(5)?,
701 description: r.get(6)?,
702 })
703 })?
704 .collect::<Result<Vec<_>, _>>()?;
705 Ok(rows)
706 } else {
707 let mut stmt = conn.prepare_cached(
708 "SELECT id, namespace, source_id, target_id, relation, weight, description
709 FROM relationships ORDER BY id",
710 )?;
711 let rows = stmt
712 .query_map([], |r| {
713 Ok(RelationshipRow {
714 id: r.get(0)?,
715 namespace: r.get(1)?,
716 source_id: r.get(2)?,
717 target_id: r.get(3)?,
718 relation: r.get(4)?,
719 weight: r.get(5)?,
720 description: r.get(6)?,
721 })
722 })?
723 .collect::<Result<Vec<_>, _>>()?;
724 Ok(rows)
725 }
726}
727
728pub fn find_orphan_entity_ids(
734 conn: &Connection,
735 namespace: Option<&str>,
736) -> Result<Vec<i64>, AppError> {
737 if let Some(ns) = namespace {
738 let mut stmt = conn.prepare_cached(
739 "SELECT e.id FROM entities e
740 WHERE e.namespace = ?1
741 AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
742 AND NOT EXISTS (
743 SELECT 1 FROM relationships r
744 WHERE r.source_id = e.id OR r.target_id = e.id
745 )",
746 )?;
747 let ids = stmt
748 .query_map(params![ns], |r| r.get::<_, i64>(0))?
749 .collect::<Result<Vec<_>, _>>()?;
750 Ok(ids)
751 } else {
752 let mut stmt = conn.prepare_cached(
753 "SELECT e.id FROM entities e
754 WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
755 AND NOT EXISTS (
756 SELECT 1 FROM relationships r
757 WHERE r.source_id = e.id OR r.target_id = e.id
758 )",
759 )?;
760 let ids = stmt
761 .query_map([], |r| r.get::<_, i64>(0))?
762 .collect::<Result<Vec<_>, _>>()?;
763 Ok(ids)
764 }
765}
766
767pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
773 if entity_ids.is_empty() {
774 return Ok(0);
775 }
776 let mut removed = 0usize;
777 for id in entity_ids {
778 let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
780 let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
781 removed += affected;
782 }
783 Ok(removed)
784}
785
786pub fn count_relationships_by_relation(
795 conn: &Connection,
796 namespace: &str,
797 relation: &str,
798) -> Result<usize, AppError> {
799 let count: i64 = conn.query_row(
800 "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
801 params![namespace, relation],
802 |r| r.get(0),
803 )?;
804 Ok(count as usize)
805}
806
807pub fn list_entity_names_by_relation(
816 conn: &Connection,
817 namespace: &str,
818 relation: &str,
819) -> Result<Vec<String>, AppError> {
820 let mut stmt = conn.prepare_cached(
821 "SELECT DISTINCT e.name FROM entities e
822 INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
823 WHERE r.namespace = ?1 AND r.relation = ?2
824 ORDER BY e.name",
825 )?;
826 let names: Vec<String> = stmt
827 .query_map(params![namespace, relation], |row| row.get(0))?
828 .collect::<Result<Vec<_>, _>>()?;
829 Ok(names)
830}
831
832pub fn delete_relationships_by_relation(
843 conn: &Connection,
844 namespace: &str,
845 relation: &str,
846) -> Result<(usize, Vec<i64>), AppError> {
847 let mut stmt = conn.prepare_cached(
849 "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
850 UNION
851 SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
852 )?;
853 let entity_ids: Vec<i64> = stmt
854 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
855 .collect::<Result<Vec<_>, _>>()?;
856
857 let mut id_stmt =
859 conn.prepare_cached("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
860 let rel_ids: Vec<i64> = id_stmt
861 .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
862 .collect::<Result<Vec<_>, _>>()?;
863
864 let mut total_deleted: usize = 0;
866 for chunk in rel_ids.chunks(1000) {
867 for &rel_id in chunk {
868 conn.execute(
869 "DELETE FROM memory_relationships WHERE relationship_id = ?1",
870 params![rel_id],
871 )?;
872 let affected =
873 conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
874 total_deleted += affected;
875 }
876 }
877
878 for &eid in &entity_ids {
880 recalculate_degree(conn, eid)?;
881 }
882
883 Ok((total_deleted, entity_ids))
884}
885
886pub fn knn_search(
899 conn: &Connection,
900 embedding: &[f32],
901 namespace: &str,
902 k: usize,
903) -> Result<Vec<(i64, f32)>, AppError> {
904 if embedding.len() != crate::constants::embedding_dim() {
905 return Err(AppError::Embedding(format!(
906 "knn_search embedding has {} dims, expected {}",
907 embedding.len(),
908 crate::constants::embedding_dim()
909 )));
910 }
911 let mut stmt = conn.prepare_cached(
912 "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
913 )?;
914 let mut scored: Vec<(i64, f32)> = stmt
915 .query_map(params![namespace], |r| {
916 let id: i64 = r.get(0)?;
917 let bytes: Vec<u8> = r.get(1)?;
918 Ok((id, bytes))
919 })?
920 .filter_map(|row| {
921 row.ok().and_then(|(id, bytes)| {
922 let stored = crate::embedder::bytes_to_f32(&bytes);
923 if stored.len() != embedding.len() {
924 return None;
925 }
926 let score = crate::similarity::cosine_similarity(embedding, &stored);
927 Some((id, score))
928 })
929 })
930 .collect();
931 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
934 scored.truncate(k);
935 Ok(scored)
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941 use crate::constants::embedding_dim;
942 use crate::entity_type::EntityType;
943 use crate::storage::connection::register_vec_extension;
944 use rusqlite::Connection;
945 use tempfile::TempDir;
946
947 type TestResult = Result<(), Box<dyn std::error::Error>>;
948
949 fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
950 register_vec_extension();
951 let tmp = TempDir::new()?;
952 let db_path = tmp.path().join("test.db");
953 let mut conn = Connection::open(&db_path)?;
954 crate::migrations::runner().run(&mut conn)?;
955 Ok((tmp, conn))
956 }
957
958 fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
959 conn.execute(
960 "INSERT INTO memories (namespace, name, type, description, body, body_hash)
961 VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
962 [],
963 )?;
964 Ok(conn.last_insert_rowid())
965 }
966
967 fn new_entity_helper(name: &str) -> NewEntity {
968 NewEntity {
969 name: name.to_string(),
970 entity_type: EntityType::Project,
971 description: None,
972 }
973 }
974
975 fn embedding_zero() -> Vec<f32> {
976 vec![0.0f32; embedding_dim()]
977 }
978
979 #[test]
984 fn test_upsert_entity_creates_new() -> TestResult {
985 let (_tmp, conn) = setup_db()?;
986 let e = new_entity_helper("projeto-alpha");
987 let id = upsert_entity(&conn, "global", &e)?;
988 assert!(id > 0);
989 Ok(())
990 }
991
992 #[test]
993 fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
994 let (_tmp, conn) = setup_db()?;
995 let e = new_entity_helper("projeto-beta");
996 let id1 = upsert_entity(&conn, "global", &e)?;
997 let id2 = upsert_entity(&conn, "global", &e)?;
998 assert_eq!(id1, id2);
999 Ok(())
1000 }
1001
1002 #[test]
1003 fn test_upsert_entity_updates_description() -> TestResult {
1004 let (_tmp, conn) = setup_db()?;
1005 let e1 = new_entity_helper("projeto-gamma");
1006 let id1 = upsert_entity(&conn, "global", &e1)?;
1007
1008 let e2 = NewEntity {
1009 name: "projeto-gamma".to_string(),
1010 entity_type: EntityType::Tool,
1011 description: Some("nova desc".to_string()),
1012 };
1013 let id2 = upsert_entity(&conn, "global", &e2)?;
1014 assert_eq!(id1, id2);
1015
1016 let desc: Option<String> = conn.query_row(
1017 "SELECT description FROM entities WHERE id = ?1",
1018 params![id1],
1019 |r| r.get(0),
1020 )?;
1021 assert_eq!(desc.as_deref(), Some("nova desc"));
1022 Ok(())
1023 }
1024
1025 #[test]
1026 fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
1027 let (_tmp, conn) = setup_db()?;
1028 let e = new_entity_helper("compartilhada");
1029 let id1 = upsert_entity(&conn, "ns1", &e)?;
1030 let id2 = upsert_entity(&conn, "ns2", &e)?;
1031 assert_ne!(id1, id2);
1032 Ok(())
1033 }
1034
1035 #[test]
1042 fn test_upsert_entity_vec_empty_embedding_skips_row() -> TestResult {
1043 let (_tmp, conn) = setup_db()?;
1044 let e = new_entity_helper("vec-vazia");
1045 let entity_id = upsert_entity(&conn, "global", &e)?;
1046
1047 upsert_entity_vec(
1048 &conn,
1049 entity_id,
1050 "global",
1051 EntityType::Project,
1052 &[],
1053 "vec-vazia",
1054 )?;
1055
1056 let count: i64 = conn.query_row(
1057 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1058 params![entity_id],
1059 |r| r.get(0),
1060 )?;
1061 assert_eq!(count, 0, "empty embedding must not persist a row");
1062 Ok(())
1063 }
1064
1065 #[test]
1067 #[serial_test::serial(env)]
1068 fn test_upsert_entity_vec_empty_embedding_preserves_existing_row() -> TestResult {
1069 let (_tmp, conn) = setup_db()?;
1070 let e = new_entity_helper("vec-preservada");
1071 let entity_id = upsert_entity(&conn, "global", &e)?;
1072 let emb = embedding_zero();
1073 upsert_entity_vec(
1074 &conn,
1075 entity_id,
1076 "global",
1077 EntityType::Project,
1078 &emb,
1079 "vec-preservada",
1080 )?;
1081
1082 upsert_entity_vec(
1083 &conn,
1084 entity_id,
1085 "global",
1086 EntityType::Project,
1087 &[],
1088 "vec-preservada",
1089 )?;
1090
1091 let count: i64 = conn.query_row(
1092 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1093 params![entity_id],
1094 |r| r.get(0),
1095 )?;
1096 assert_eq!(count, 1, "existing vector must survive an empty upsert");
1097 Ok(())
1098 }
1099
1100 #[test]
1101 #[serial_test::serial(env)]
1102 fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
1103 let (_tmp, conn) = setup_db()?;
1104 let e = new_entity_helper("vec-nova");
1105 let entity_id = upsert_entity(&conn, "global", &e)?;
1106 let emb = embedding_zero();
1107
1108 let result = upsert_entity_vec(
1109 &conn,
1110 entity_id,
1111 "global",
1112 EntityType::Project,
1113 &emb,
1114 "vec-nova",
1115 );
1116 assert!(result.is_ok(), "first insertion must succeed");
1117
1118 let count: i64 = conn.query_row(
1119 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1120 params![entity_id],
1121 |r| r.get(0),
1122 )?;
1123 assert_eq!(count, 1, "must have exactly one row after insertion");
1124 Ok(())
1125 }
1126
1127 #[test]
1128 #[serial_test::serial(env)]
1129 fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
1130 let (_tmp, conn) = setup_db()?;
1132 let e = new_entity_helper("vec-existente");
1133 let entity_id = upsert_entity(&conn, "global", &e)?;
1134 let emb = embedding_zero();
1135
1136 upsert_entity_vec(
1137 &conn,
1138 entity_id,
1139 "global",
1140 EntityType::Project,
1141 &emb,
1142 "vec-existente",
1143 )?;
1144
1145 let result = upsert_entity_vec(
1147 &conn,
1148 entity_id,
1149 "global",
1150 EntityType::Tool,
1151 &emb,
1152 "vec-existente",
1153 );
1154 assert!(
1155 result.is_ok(),
1156 "second insertion (replace) must succeed: {result:?}"
1157 );
1158
1159 let count: i64 = conn.query_row(
1160 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1161 params![entity_id],
1162 |r| r.get(0),
1163 )?;
1164 assert_eq!(count, 1, "must have exactly one row after replacement");
1165 Ok(())
1166 }
1167
1168 #[test]
1169 #[serial_test::serial(env)]
1170 fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
1171 let (_tmp, conn) = setup_db()?;
1172 let emb = embedding_zero();
1173
1174 for i in 0..3i64 {
1175 let nome = format!("ent-{i}");
1176 let e = new_entity_helper(&nome);
1177 let entity_id = upsert_entity(&conn, "global", &e)?;
1178 upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
1179 }
1180
1181 let count: i64 =
1182 conn.query_row("SELECT COUNT(*) FROM entity_embeddings", [], |r| r.get(0))?;
1183 assert_eq!(
1184 count, 3,
1185 "must have three distinct rows in entity_embeddings"
1186 );
1187 Ok(())
1188 }
1189
1190 #[test]
1195 fn test_find_entity_id_existing_returns_some() -> TestResult {
1196 let (_tmp, conn) = setup_db()?;
1197 let e = new_entity_helper("entidade-busca");
1198 let id_inserido = upsert_entity(&conn, "global", &e)?;
1199 let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
1200 assert_eq!(id_encontrado, Some(id_inserido));
1201 Ok(())
1202 }
1203
1204 #[test]
1205 fn test_find_entity_id_missing_returns_none() -> TestResult {
1206 let (_tmp, conn) = setup_db()?;
1207 let id = find_entity_id(&conn, "global", "nao-existe")?;
1208 assert_eq!(id, None);
1209 Ok(())
1210 }
1211
1212 #[test]
1217 fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
1218 let (_tmp, conn) = setup_db()?;
1219 let removed = delete_entities_by_ids(&conn, &[])?;
1220 assert_eq!(removed, 0);
1221 Ok(())
1222 }
1223
1224 #[test]
1225 fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
1226 let (_tmp, conn) = setup_db()?;
1227 let e = new_entity_helper("to-delete");
1228 let entity_id = upsert_entity(&conn, "global", &e)?;
1229
1230 let removed = delete_entities_by_ids(&conn, &[entity_id])?;
1231 assert_eq!(removed, 1);
1232
1233 let id = find_entity_id(&conn, "global", "to-delete")?;
1234 assert_eq!(id, None, "entity must have been removed");
1235 Ok(())
1236 }
1237
1238 #[test]
1239 fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
1240 let (_tmp, conn) = setup_db()?;
1241 let removed = delete_entities_by_ids(&conn, &[9999])?;
1242 assert_eq!(removed, 0);
1243 Ok(())
1244 }
1245
1246 #[test]
1247 fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
1248 let (_tmp, conn) = setup_db()?;
1249 let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
1250 let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
1251 let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
1252
1253 let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
1254 assert_eq!(removed, 2);
1255
1256 assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
1257 assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
1258 assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
1259 let _ = id3;
1260 Ok(())
1261 }
1262
1263 #[test]
1264 fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
1265 let (_tmp, conn) = setup_db()?;
1266 let e = new_entity_helper("del-com-vec");
1267 let entity_id = upsert_entity(&conn, "global", &e)?;
1268 let emb = embedding_zero();
1269 upsert_entity_vec(
1270 &conn,
1271 entity_id,
1272 "global",
1273 EntityType::Project,
1274 &emb,
1275 "del-com-vec",
1276 )?;
1277
1278 let count_antes: i64 = conn.query_row(
1279 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1280 params![entity_id],
1281 |r| r.get(0),
1282 )?;
1283 assert_eq!(count_antes, 1);
1284
1285 delete_entities_by_ids(&conn, &[entity_id])?;
1286
1287 let count_depois: i64 = conn.query_row(
1288 "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
1289 params![entity_id],
1290 |r| r.get(0),
1291 )?;
1292 assert_eq!(
1293 count_depois, 0,
1294 "entity_embeddings deve ser limpo junto com entities"
1295 );
1296 Ok(())
1297 }
1298
1299 #[test]
1304 fn test_upsert_relationship_creates_new() -> TestResult {
1305 let (_tmp, conn) = setup_db()?;
1306 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
1307 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
1308
1309 let rel = NewRelationship {
1310 source: "rel-a".to_string(),
1311 target: "rel-b".to_string(),
1312 relation: "uses".to_string(),
1313 strength: 0.8,
1314 description: None,
1315 };
1316 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1317 assert!(rel_id > 0);
1318 Ok(())
1319 }
1320
1321 #[test]
1322 fn test_upsert_relationship_idempotent() -> TestResult {
1323 let (_tmp, conn) = setup_db()?;
1324 let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
1325 let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
1326
1327 let rel = NewRelationship {
1328 source: "idem-a".to_string(),
1329 target: "idem-b".to_string(),
1330 relation: "uses".to_string(),
1331 strength: 0.5,
1332 description: None,
1333 };
1334 let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1335 let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1336 assert_eq!(id1, id2);
1337 Ok(())
1338 }
1339
1340 #[test]
1341 fn test_find_relationship_existing() -> TestResult {
1342 let (_tmp, conn) = setup_db()?;
1343 let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
1344 let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
1345
1346 let rel = NewRelationship {
1347 source: "fr-a".to_string(),
1348 target: "fr-b".to_string(),
1349 relation: "depends_on".to_string(),
1350 strength: 0.7,
1351 description: None,
1352 };
1353 upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1354
1355 let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
1356 let row = encontrada.ok_or("relationship should exist")?;
1357 assert_eq!(row.source_id, id_a);
1358 assert_eq!(row.target_id, id_b);
1359 assert!((row.weight - 0.7).abs() < 1e-9);
1360 Ok(())
1361 }
1362
1363 #[test]
1364 fn test_find_relationship_missing_returns_none() -> TestResult {
1365 let (_tmp, conn) = setup_db()?;
1366 let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
1367 assert!(resultado.is_none());
1368 Ok(())
1369 }
1370
1371 #[test]
1376 fn test_link_memory_entity_idempotent() -> TestResult {
1377 let (_tmp, conn) = setup_db()?;
1378 let memory_id = insert_memory(&conn)?;
1379 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
1380
1381 link_memory_entity(&conn, memory_id, entity_id)?;
1382 let resultado = link_memory_entity(&conn, memory_id, entity_id);
1383 assert!(
1384 resultado.is_ok(),
1385 "INSERT OR IGNORE must not fail on duplicate"
1386 );
1387 Ok(())
1388 }
1389
1390 #[test]
1391 fn test_link_memory_relationship_idempotent() -> TestResult {
1392 let (_tmp, conn) = setup_db()?;
1393 let memory_id = insert_memory(&conn)?;
1394 let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
1395 let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
1396
1397 let rel = NewRelationship {
1398 source: "mr-a".to_string(),
1399 target: "mr-b".to_string(),
1400 relation: "uses".to_string(),
1401 strength: 0.5,
1402 description: None,
1403 };
1404 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1405
1406 link_memory_relationship(&conn, memory_id, rel_id)?;
1407 let resultado = link_memory_relationship(&conn, memory_id, rel_id);
1408 assert!(
1409 resultado.is_ok(),
1410 "INSERT OR IGNORE must not fail on duplicate"
1411 );
1412 Ok(())
1413 }
1414
1415 #[test]
1420 fn test_increment_degree_increases_counter() -> TestResult {
1421 let (_tmp, conn) = setup_db()?;
1422 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1423
1424 increment_degree(&conn, entity_id)?;
1425 increment_degree(&conn, entity_id)?;
1426
1427 let degree: i64 = conn.query_row(
1428 "SELECT degree FROM entities WHERE id = ?1",
1429 params![entity_id],
1430 |r| r.get(0),
1431 )?;
1432 assert_eq!(degree, 2);
1433 Ok(())
1434 }
1435
1436 #[test]
1437 fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1438 let (_tmp, conn) = setup_db()?;
1439 let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1440 let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1441 let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1442
1443 let rel1 = NewRelationship {
1444 source: "rc-a".to_string(),
1445 target: "rc-b".to_string(),
1446 relation: "uses".to_string(),
1447 strength: 0.5,
1448 description: None,
1449 };
1450 let rel2 = NewRelationship {
1451 source: "rc-c".to_string(),
1452 target: "rc-a".to_string(),
1453 relation: "depends_on".to_string(),
1454 strength: 0.5,
1455 description: None,
1456 };
1457 upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1458 upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1459
1460 recalculate_degree(&conn, id_a)?;
1461
1462 let degree: i64 = conn.query_row(
1463 "SELECT degree FROM entities WHERE id = ?1",
1464 params![id_a],
1465 |r| r.get(0),
1466 )?;
1467 assert_eq!(
1468 degree, 2,
1469 "rc-a appears in two relationships (source+target)"
1470 );
1471 Ok(())
1472 }
1473
1474 #[test]
1479 fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1480 let (_tmp, conn) = setup_db()?;
1481 let memory_id = insert_memory(&conn)?;
1482 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1483 link_memory_entity(&conn, memory_id, entity_id)?;
1484
1485 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1486 assert!(!orfas.contains(&entity_id));
1487 Ok(())
1488 }
1489
1490 #[test]
1491 fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1492 let (_tmp, conn) = setup_db()?;
1493 let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1494
1495 let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1496 assert!(orfas.contains(&entity_id));
1497 Ok(())
1498 }
1499
1500 #[test]
1501 fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1502 let (_tmp, conn) = setup_db()?;
1503 let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1504 let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1505
1506 let orfas = find_orphan_entity_ids(&conn, None)?;
1507 assert!(orfas.contains(&id1));
1508 assert!(orfas.contains(&id2));
1509 Ok(())
1510 }
1511
1512 #[test]
1517 fn test_list_entities_with_namespace() -> TestResult {
1518 let (_tmp, conn) = setup_db()?;
1519 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1520 upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1521 upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1522
1523 let lista = list_entities(&conn, Some("le-ns"))?;
1524 assert_eq!(lista.len(), 2);
1525 assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1526 Ok(())
1527 }
1528
1529 #[test]
1530 fn test_list_entities_without_namespace_returns_all() -> TestResult {
1531 let (_tmp, conn) = setup_db()?;
1532 upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1533 upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1534
1535 let lista = list_entities(&conn, None)?;
1536 assert!(lista.len() >= 2);
1537 Ok(())
1538 }
1539
1540 #[test]
1541 fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1542 let (_tmp, conn) = setup_db()?;
1543 let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1544 let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1545
1546 let rel = NewRelationship {
1547 source: "lr-a".to_string(),
1548 target: "lr-b".to_string(),
1549 relation: "uses".to_string(),
1550 strength: 0.5,
1551 description: None,
1552 };
1553 upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1554
1555 let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1556 assert!(!lista.is_empty());
1557 assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1558 Ok(())
1559 }
1560
1561 #[test]
1566 fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1567 let (_tmp, conn) = setup_db()?;
1568 let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1569 let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1570
1571 let rel = NewRelationship {
1572 source: "dr-a".to_string(),
1573 target: "dr-b".to_string(),
1574 relation: "uses".to_string(),
1575 strength: 0.5,
1576 description: None,
1577 };
1578 let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1579
1580 delete_relationship_by_id(&conn, rel_id)?;
1581
1582 let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1583 assert!(encontrada.is_none(), "relationship must have been removed");
1584 Ok(())
1585 }
1586
1587 #[test]
1588 fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1589 let (_tmp, conn) = setup_db()?;
1590 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1591 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1592
1593 let (rel_id, created) =
1594 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1595 assert!(rel_id > 0);
1596 assert!(created);
1597 Ok(())
1598 }
1599
1600 #[test]
1601 fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1602 let (_tmp, conn) = setup_db()?;
1603 let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1604 let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1605
1606 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1607 let (_, created) =
1608 create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1609 assert!(
1610 !created,
1611 "second call must return the existing relationship"
1612 );
1613 Ok(())
1614 }
1615
1616 #[test]
1621 fn accepts_type_field_as_alias() -> TestResult {
1622 let json = r#"{"name": "X", "type": "concept"}"#;
1623 let ent: NewEntity = serde_json::from_str(json)?;
1624 assert_eq!(ent.entity_type, EntityType::Concept);
1625 Ok(())
1626 }
1627
1628 #[test]
1629 fn accepts_canonical_entity_type_field() -> TestResult {
1630 let json = r#"{"name": "X", "entity_type": "concept"}"#;
1631 let ent: NewEntity = serde_json::from_str(json)?;
1632 assert_eq!(ent.entity_type, EntityType::Concept);
1633 Ok(())
1634 }
1635
1636 #[test]
1637 fn both_fields_present_yields_duplicate_error() {
1638 let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1640 let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1641 assert!(
1642 resultado.is_err(),
1643 "both fields in the same JSON are a duplicate"
1644 );
1645 }
1646
1647 #[test]
1648 fn validate_entity_name_accepts_valid() {
1649 assert!(validate_entity_name("rust-lang").is_ok());
1650 assert!(validate_entity_name("sqlite-graphrag").is_ok());
1651 assert!(validate_entity_name("ab").is_ok());
1652 }
1653
1654 #[test]
1655 fn validate_entity_name_rejects_short() {
1656 assert!(validate_entity_name("a").is_err());
1657 assert!(validate_entity_name("").is_err());
1658 }
1659
1660 #[test]
1661 fn validate_entity_name_rejects_newlines() {
1662 assert!(validate_entity_name("foo\nbar").is_err());
1663 assert!(validate_entity_name("foo\rbar").is_err());
1664 }
1665
1666 #[test]
1667 fn validate_entity_name_rejects_short_allcaps() {
1668 assert!(validate_entity_name("RAM").is_err());
1669 assert!(validate_entity_name("NAO").is_err());
1670 assert!(validate_entity_name("OK").is_err());
1671 }
1672
1673 #[test]
1674 fn validate_entity_name_accepts_long_allcaps() {
1675 assert!(validate_entity_name("SQLITE").is_ok());
1676 assert!(validate_entity_name("GRAPHRAG").is_ok());
1677 }
1678
1679 #[test]
1680 fn validate_entity_name_accepts_mixed_case() {
1681 assert!(validate_entity_name("FTS5").is_ok()); assert!(validate_entity_name("WAL").is_err()); }
1684
1685 #[test]
1687 fn validate_entity_name_rejects_purely_numeric() {
1688 assert!(validate_entity_name("89975").is_err());
1689 assert!(validate_entity_name("35313").is_err());
1690 assert!(validate_entity_name("12").is_err());
1691 assert!(validate_entity_name("issue-89975").is_ok());
1693 assert!(validate_entity_name("v2").is_ok());
1694 }
1695
1696 #[test]
1697 fn entity_name_similarity_prefers_prefix_of_kebab() {
1698 let s = entity_name_similarity("danilo", "danilo-aguiar-teixeira");
1699 assert!(s >= 0.90, "expected strong prefix score, got {s}");
1700 let exact = entity_name_similarity("danilo", "danilo");
1701 assert!((exact - 1.0).abs() < f64::EPSILON);
1702 }
1703
1704 #[test]
1706 fn test_unlink_memory_entity_removes_single_binding() -> TestResult {
1707 let (_tmp, conn) = setup_db()?;
1708 let memory_id = insert_memory(&conn)?;
1709 let e1 = upsert_entity(&conn, "global", &new_entity_helper("entidade-um"))?;
1710 let e2 = upsert_entity(&conn, "global", &new_entity_helper("entidade-dois"))?;
1711 link_memory_entity(&conn, memory_id, e1)?;
1712 link_memory_entity(&conn, memory_id, e2)?;
1713
1714 let removed = unlink_memory_entity(&conn, memory_id, e1)?;
1715 assert_eq!(removed, 1);
1716
1717 let remaining: i64 = conn.query_row(
1719 "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1720 params![memory_id],
1721 |r| r.get(0),
1722 )?;
1723 assert_eq!(remaining, 1);
1724
1725 assert_eq!(unlink_memory_entity(&conn, memory_id, e1)?, 0);
1727 Ok(())
1728 }
1729
1730 #[test]
1732 fn test_clear_memory_graph_bindings_clears_all() -> TestResult {
1733 let (_tmp, conn) = setup_db()?;
1734 let memory_id = insert_memory(&conn)?;
1735 let e1 = upsert_entity(&conn, "global", &new_entity_helper("alpha-node"))?;
1736 let e2 = upsert_entity(&conn, "global", &new_entity_helper("beta-node"))?;
1737 link_memory_entity(&conn, memory_id, e1)?;
1738 link_memory_entity(&conn, memory_id, e2)?;
1739 let rel = NewRelationship {
1740 source: "alpha-node".to_string(),
1741 target: "beta-node".to_string(),
1742 relation: "related".to_string(),
1743 strength: 0.5,
1744 description: None,
1745 };
1746 let rel_id = upsert_relationship(&conn, "global", e1, e2, &rel)?;
1747 link_memory_relationship(&conn, memory_id, rel_id)?;
1748
1749 let (e_removed, r_removed) = clear_memory_graph_bindings(&conn, memory_id)?;
1750 assert_eq!(e_removed, 2);
1751 assert_eq!(r_removed, 1);
1752
1753 let ent_left: i64 = conn.query_row(
1754 "SELECT COUNT(*) FROM memory_entities WHERE memory_id = ?1",
1755 params![memory_id],
1756 |r| r.get(0),
1757 )?;
1758 let rel_left: i64 = conn.query_row(
1759 "SELECT COUNT(*) FROM memory_relationships WHERE memory_id = ?1",
1760 params![memory_id],
1761 |r| r.get(0),
1762 )?;
1763 assert_eq!(ent_left, 0);
1764 assert_eq!(rel_left, 0);
1765 Ok(())
1766 }
1767}