Skip to main content

sqlite_graphrag/storage/
entities.rs

1//! Persistence layer for entities, relationships and their junction tables.
2//!
3//! The entity graph mirrors the conceptual content of memories: `entities`
4//! holds nodes, `relationships` holds typed edges and `memory_entities` and
5//! `memory_relationships` connect each memory to the graph slice it emitted.
6
7use 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/// Input payload used to upsert a single entity.
16///
17/// `name` is normalized to kebab-case by the caller. `description` is
18/// optional and preserved across upserts when the new value is `None`.
19#[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/// Input payload used to upsert a typed relationship between entities.
29///
30/// `strength` must lie within `[0.0, 1.0]` and is mapped to the `weight`
31/// column of the `relationships` table.
32#[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
46/// Validates entity name against quality rules.
47///
48/// Rejects names with newlines, names shorter than 2 characters, and
49/// ALL_CAPS abbreviations of 4 characters or fewer (common NER noise).
50///
51/// # Errors
52///
53/// Returns `Err(AppError::Validation)` when the name violates any rule.
54pub 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.len() <= 4
66        && name
67            .chars()
68            .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
69    {
70        return Err(AppError::Validation(format!(
71            "entity name '{name}' rejected: short ALL_CAPS names are typically NER noise"
72        )));
73    }
74    Ok(())
75}
76
77/// Upserts an entity and returns its primary key.
78///
79/// Uses `ON CONFLICT(namespace, name)` to keep one row per entity within a
80/// namespace, refreshing `type` and `description` opportunistically.
81///
82/// # Errors
83///
84/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
85pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
86    // Step 1: validate the original name — catches ALL_CAPS short noise (NER artefacts),
87    // newlines, and names shorter than 2 characters before any transformation.
88    validate_entity_name(&e.name)?;
89    // Step 2: normalize to kebab-case ASCII (NFKD, lowercase, spaces/underscores → hyphens).
90    let normalized_name = normalize_entity_name(&e.name);
91    // Step 3: guard post-normalization length — a valid original could collapse to < 2 chars
92    // (e.g. a single accented character that strips entirely).
93    if normalized_name.chars().count() < 2 {
94        return Err(AppError::Validation(format!(
95            "entity name '{}' normalizes to '{}' which is too short (minimum 2 characters)",
96            e.name, normalized_name
97        )));
98    }
99    conn.execute(
100        "INSERT INTO entities (namespace, name, type, description)
101         VALUES (?1, ?2, ?3, ?4)
102         ON CONFLICT(namespace, name) DO UPDATE SET
103           type        = excluded.type,
104           description = COALESCE(excluded.description, entities.description),
105           updated_at  = unixepoch()",
106        params![namespace, normalized_name, e.entity_type, e.description],
107    )?;
108    let id: i64 = conn.query_row(
109        "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
110        params![namespace, normalized_name],
111        |r| r.get(0),
112    )?;
113    Ok(id)
114}
115
116/// Replaces the vector row for an entity in `entity_embeddings`.
117///
118/// v1.0.76: sqlite-vec was removed. Embeddings live in a regular BLOB-backed
119/// table; cosine similarity is computed in pure Rust on demand. The
120/// `entity_type` and `name` arguments are accepted for API compatibility
121/// but are not stored — the entities table is the source of truth.
122///
123/// # Errors
124///
125/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
126pub fn upsert_entity_vec(
127    conn: &Connection,
128    entity_id: i64,
129    namespace: &str,
130    _entity_type: EntityType,
131    embedding: &[f32],
132    _name: &str,
133) -> Result<(), AppError> {
134    let embedding_bytes = f32_to_bytes(embedding);
135    with_busy_retry(|| {
136        conn.execute(
137            "DELETE FROM entity_embeddings WHERE entity_id = ?1",
138            params![entity_id],
139        )?;
140        conn.execute(
141            "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
142             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
143            params![
144                entity_id,
145                namespace,
146                &embedding_bytes,
147                "llm-headless",
148                crate::constants::SQLITE_GRAPHRAG_VERSION,
149                crate::constants::EMBEDDING_DIM as i64,
150            ],
151        )?;
152        Ok(())
153    })
154}
155
156/// Upserts a typed relationship between two entity ids.
157///
158/// Conflicts on `(source_id, target_id, relation)` refresh `weight` and
159/// preserve a non-null `description`. Returns the `rowid` of the stored row.
160///
161/// # Errors
162///
163/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
164pub fn upsert_relationship(
165    conn: &Connection,
166    namespace: &str,
167    source_id: i64,
168    target_id: i64,
169    rel: &NewRelationship,
170) -> Result<i64, AppError> {
171    conn.execute(
172        "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
173         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
174         ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
175           weight = excluded.weight,
176           description = COALESCE(excluded.description, relationships.description)",
177        params![
178            namespace,
179            source_id,
180            target_id,
181            rel.relation,
182            rel.strength,
183            rel.description
184        ],
185    )?;
186    let id: i64 = conn.query_row(
187        "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
188        params![source_id, target_id, rel.relation],
189        |r| r.get(0),
190    )?;
191    Ok(id)
192}
193
194/// Links a memory to an entity in the `memory_entities` join table.
195///
196/// # Errors
197///
198/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
199pub fn link_memory_entity(
200    conn: &Connection,
201    memory_id: i64,
202    entity_id: i64,
203) -> Result<(), AppError> {
204    conn.execute(
205        "INSERT OR IGNORE INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
206        params![memory_id, entity_id],
207    )?;
208    Ok(())
209}
210
211/// Links a memory to a relationship in the `memory_relationships` join table.
212///
213/// # Errors
214///
215/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
216pub fn link_memory_relationship(
217    conn: &Connection,
218    memory_id: i64,
219    rel_id: i64,
220) -> Result<(), AppError> {
221    conn.execute(
222        "INSERT OR IGNORE INTO memory_relationships (memory_id, relationship_id) VALUES (?1, ?2)",
223        params![memory_id, rel_id],
224    )?;
225    Ok(())
226}
227
228/// Increments the `degree` counter of an entity by one.
229///
230/// # Errors
231///
232/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
233pub fn increment_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
234    conn.execute(
235        "UPDATE entities SET degree = degree + 1 WHERE id = ?1",
236        params![entity_id],
237    )?;
238    Ok(())
239}
240
241/// Looks up the entity by name and namespace. Returns the id when it exists.
242///
243/// # Errors
244///
245/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
246pub fn find_entity_id(
247    conn: &Connection,
248    namespace: &str,
249    name: &str,
250) -> Result<Option<i64>, AppError> {
251    // Normalize the lookup name so it matches the normalized names written by
252    // `upsert_entity`. Without this, an entity written through normalization
253    // (e.g. "Foo Bar" -> "foo-bar") would be unreachable by its original
254    // spelling, breaking delete-entity, reclassify, merge-entities, rename and
255    // memory-entities lookups.
256    let name = normalize_entity_name(name);
257    let mut stmt =
258        conn.prepare_cached("SELECT id FROM entities WHERE namespace = ?1 AND name = ?2")?;
259    match stmt.query_row(params![namespace, &name], |r| r.get::<_, i64>(0)) {
260        Ok(id) => Ok(Some(id)),
261        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
262        Err(e) => Err(AppError::Database(e)),
263    }
264}
265
266/// Structure representing an existing relation.
267#[derive(Debug, Serialize)]
268pub struct RelationshipRow {
269    pub id: i64,
270    pub namespace: String,
271    pub source_id: i64,
272    pub target_id: i64,
273    pub relation: String,
274    pub weight: f64,
275    pub description: Option<String>,
276}
277
278/// Looks up a specific relation by (source_id, target_id, relation).
279///
280/// # Errors
281///
282/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
283pub fn find_relationship(
284    conn: &Connection,
285    source_id: i64,
286    target_id: i64,
287    relation: &str,
288) -> Result<Option<RelationshipRow>, AppError> {
289    let mut stmt = conn.prepare_cached(
290        "SELECT id, namespace, source_id, target_id, relation, weight, description
291         FROM relationships
292         WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
293    )?;
294    match stmt.query_row(params![source_id, target_id, relation], |r| {
295        Ok(RelationshipRow {
296            id: r.get(0)?,
297            namespace: r.get(1)?,
298            source_id: r.get(2)?,
299            target_id: r.get(3)?,
300            relation: r.get(4)?,
301            weight: r.get(5)?,
302            description: r.get(6)?,
303        })
304    }) {
305        Ok(row) => Ok(Some(row)),
306        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
307        Err(e) => Err(AppError::Database(e)),
308    }
309}
310
311/// Creates a relation if it does not exist (returns action="created")
312/// or returns the existing relation (action="already_exists") with updated weight.
313///
314/// # Errors
315///
316/// - [`AppError::Database`] — SQLite query or constraint failure.
317/// - [`AppError::Validation`] — self-link attempt (source equals target).
318pub fn create_or_fetch_relationship(
319    conn: &Connection,
320    namespace: &str,
321    source_id: i64,
322    target_id: i64,
323    relation: &str,
324    weight: f64,
325    description: Option<&str>,
326) -> Result<(i64, bool), AppError> {
327    // Check if it exists first; update weight if different.
328    let existing = find_relationship(conn, source_id, target_id, relation)?;
329    if let Some(row) = existing {
330        if (row.weight - weight).abs() > f64::EPSILON {
331            conn.execute(
332                "UPDATE relationships SET weight = ?1 WHERE id = ?2",
333                params![weight, row.id],
334            )?;
335        }
336        return Ok((row.id, false));
337    }
338    conn.execute(
339        "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
340         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
341        params![
342            namespace,
343            source_id,
344            target_id,
345            relation,
346            weight,
347            description
348        ],
349    )?;
350    let id: i64 = conn.query_row(
351        "SELECT id FROM relationships WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
352        params![source_id, target_id, relation],
353        |r| r.get(0),
354    )?;
355    Ok((id, true))
356}
357
358/// Removes a relation by id and cleans up memory_relationships.
359///
360/// # Errors
361///
362/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
363pub fn delete_relationship_by_id(conn: &Connection, relationship_id: i64) -> Result<(), AppError> {
364    conn.execute(
365        "DELETE FROM memory_relationships WHERE relationship_id = ?1",
366        params![relationship_id],
367    )?;
368    conn.execute(
369        "DELETE FROM relationships WHERE id = ?1",
370        params![relationship_id],
371    )?;
372    Ok(())
373}
374
375/// Recalculates the `degree` field of an entity.
376///
377/// # Errors
378///
379/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
380pub fn recalculate_degree(conn: &Connection, entity_id: i64) -> Result<(), AppError> {
381    conn.execute(
382        "UPDATE entities
383         SET degree = (SELECT COUNT(*) FROM relationships
384                       WHERE source_id = entities.id OR target_id = entities.id)
385         WHERE id = ?1",
386        params![entity_id],
387    )?;
388    Ok(())
389}
390
391/// Entity row with enough data for graph export/query.
392#[derive(Debug, Serialize, Clone)]
393pub struct EntityNode {
394    pub id: i64,
395    pub name: String,
396    pub namespace: String,
397    pub kind: String,
398}
399
400/// Lists entities, filtering by namespace if provided.
401///
402/// # Errors
403///
404/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
405pub fn list_entities(
406    conn: &Connection,
407    namespace: Option<&str>,
408) -> Result<Vec<EntityNode>, AppError> {
409    if let Some(ns) = namespace {
410        let mut stmt = conn.prepare_cached(
411            "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
412        )?;
413        let rows = stmt
414            .query_map(params![ns], |r| {
415                Ok(EntityNode {
416                    id: r.get(0)?,
417                    name: r.get(1)?,
418                    namespace: r.get(2)?,
419                    kind: r.get(3)?,
420                })
421            })?
422            .collect::<Result<Vec<_>, _>>()?;
423        Ok(rows)
424    } else {
425        let mut stmt = conn.prepare_cached(
426            "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
427        )?;
428        let rows = stmt
429            .query_map([], |r| {
430                Ok(EntityNode {
431                    id: r.get(0)?,
432                    name: r.get(1)?,
433                    namespace: r.get(2)?,
434                    kind: r.get(3)?,
435                })
436            })?
437            .collect::<Result<Vec<_>, _>>()?;
438        Ok(rows)
439    }
440}
441
442/// Lists relations filtered by namespace (of source/target entities).
443///
444/// # Errors
445///
446/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
447pub fn list_relationships_by_namespace(
448    conn: &Connection,
449    namespace: Option<&str>,
450) -> Result<Vec<RelationshipRow>, AppError> {
451    if let Some(ns) = namespace {
452        let mut stmt = conn.prepare_cached(
453            "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
454             FROM relationships r
455             JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
456             JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
457             ORDER BY r.id",
458        )?;
459        let rows = stmt
460            .query_map(params![ns], |r| {
461                Ok(RelationshipRow {
462                    id: r.get(0)?,
463                    namespace: r.get(1)?,
464                    source_id: r.get(2)?,
465                    target_id: r.get(3)?,
466                    relation: r.get(4)?,
467                    weight: r.get(5)?,
468                    description: r.get(6)?,
469                })
470            })?
471            .collect::<Result<Vec<_>, _>>()?;
472        Ok(rows)
473    } else {
474        let mut stmt = conn.prepare_cached(
475            "SELECT id, namespace, source_id, target_id, relation, weight, description
476             FROM relationships ORDER BY id",
477        )?;
478        let rows = stmt
479            .query_map([], |r| {
480                Ok(RelationshipRow {
481                    id: r.get(0)?,
482                    namespace: r.get(1)?,
483                    source_id: r.get(2)?,
484                    target_id: r.get(3)?,
485                    relation: r.get(4)?,
486                    weight: r.get(5)?,
487                    description: r.get(6)?,
488                })
489            })?
490            .collect::<Result<Vec<_>, _>>()?;
491        Ok(rows)
492    }
493}
494
495/// Locates orphan entities: no link in memory_entities and no relations.
496///
497/// # Errors
498///
499/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
500pub fn find_orphan_entity_ids(
501    conn: &Connection,
502    namespace: Option<&str>,
503) -> Result<Vec<i64>, AppError> {
504    if let Some(ns) = namespace {
505        let mut stmt = conn.prepare_cached(
506            "SELECT e.id FROM entities e
507             WHERE e.namespace = ?1
508               AND NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
509               AND NOT EXISTS (
510                   SELECT 1 FROM relationships r
511                   WHERE r.source_id = e.id OR r.target_id = e.id
512               )",
513        )?;
514        let ids = stmt
515            .query_map(params![ns], |r| r.get::<_, i64>(0))?
516            .collect::<Result<Vec<_>, _>>()?;
517        Ok(ids)
518    } else {
519        let mut stmt = conn.prepare_cached(
520            "SELECT e.id FROM entities e
521             WHERE NOT EXISTS (SELECT 1 FROM memory_entities me WHERE me.entity_id = e.id)
522               AND NOT EXISTS (
523                   SELECT 1 FROM relationships r
524                   WHERE r.source_id = e.id OR r.target_id = e.id
525               )",
526        )?;
527        let ids = stmt
528            .query_map([], |r| r.get::<_, i64>(0))?
529            .collect::<Result<Vec<_>, _>>()?;
530        Ok(ids)
531    }
532}
533
534/// Deletes entities and their associated vectors. Returns the number of entities removed.
535///
536/// # Errors
537///
538/// Returns [`AppError::Database`] when the underlying SQLite operation fails.
539pub fn delete_entities_by_ids(conn: &Connection, entity_ids: &[i64]) -> Result<usize, AppError> {
540    if entity_ids.is_empty() {
541        return Ok(0);
542    }
543    let mut removed = 0usize;
544    for id in entity_ids {
545        // FK CASCADE on entity_embeddings handles cleanup automatically.
546        let _ = conn.execute("DELETE FROM vec_entities WHERE entity_id = ?1", params![id]);
547        let affected = conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
548        removed += affected;
549    }
550    Ok(removed)
551}
552
553/// Counts relationships matching the given relation type within a namespace.
554///
555/// Used by `prune-relations --dry-run` to preview the number of relationships
556/// that would be deleted without actually modifying the database.
557///
558/// # Errors
559///
560/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
561pub fn count_relationships_by_relation(
562    conn: &Connection,
563    namespace: &str,
564    relation: &str,
565) -> Result<usize, AppError> {
566    let count: i64 = conn.query_row(
567        "SELECT COUNT(*) FROM relationships WHERE namespace = ?1 AND relation = ?2",
568        params![namespace, relation],
569        |r| r.get(0),
570    )?;
571    Ok(count as usize)
572}
573
574/// Returns unique entity names involved in relationships of the given type.
575///
576/// Queries both source and target sides of every matching relationship row,
577/// deduplicates via `DISTINCT`, and returns the names in alphabetical order.
578///
579/// # Errors
580///
581/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
582pub fn list_entity_names_by_relation(
583    conn: &Connection,
584    namespace: &str,
585    relation: &str,
586) -> Result<Vec<String>, AppError> {
587    let mut stmt = conn.prepare_cached(
588        "SELECT DISTINCT e.name FROM entities e
589         INNER JOIN relationships r ON (e.id = r.source_id OR e.id = r.target_id)
590         WHERE r.namespace = ?1 AND r.relation = ?2
591         ORDER BY e.name",
592    )?;
593    let names: Vec<String> = stmt
594        .query_map(params![namespace, relation], |row| row.get(0))?
595        .collect::<Result<Vec<_>, _>>()?;
596    Ok(names)
597}
598
599/// Deletes all relationships matching a relation type within a namespace.
600///
601/// Operates in chunks of 1000 to avoid holding long write locks and blocking
602/// WAL readers. After deletion, recalculates degree for every affected entity.
603///
604/// Returns `(count_deleted, affected_entity_ids)`.
605///
606/// # Errors
607///
608/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
609pub fn delete_relationships_by_relation(
610    conn: &Connection,
611    namespace: &str,
612    relation: &str,
613) -> Result<(usize, Vec<i64>), AppError> {
614    // Step 1: collect all affected entity IDs before deletion.
615    let mut stmt = conn.prepare_cached(
616        "SELECT DISTINCT source_id FROM relationships WHERE namespace = ?1 AND relation = ?2
617         UNION
618         SELECT DISTINCT target_id FROM relationships WHERE namespace = ?1 AND relation = ?2",
619    )?;
620    let entity_ids: Vec<i64> = stmt
621        .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
622        .collect::<Result<Vec<_>, _>>()?;
623
624    // Step 2: collect relationship IDs to delete.
625    let mut id_stmt =
626        conn.prepare_cached("SELECT id FROM relationships WHERE namespace = ?1 AND relation = ?2")?;
627    let rel_ids: Vec<i64> = id_stmt
628        .query_map(params![namespace, relation], |r| r.get::<_, i64>(0))?
629        .collect::<Result<Vec<_>, _>>()?;
630
631    // Step 3: delete in chunks of 1000 (memory_relationships + relationships).
632    let mut total_deleted: usize = 0;
633    for chunk in rel_ids.chunks(1000) {
634        for &rel_id in chunk {
635            conn.execute(
636                "DELETE FROM memory_relationships WHERE relationship_id = ?1",
637                params![rel_id],
638            )?;
639            let affected =
640                conn.execute("DELETE FROM relationships WHERE id = ?1", params![rel_id])?;
641            total_deleted += affected;
642        }
643    }
644
645    // Step 4: recalculate degree for all affected entities.
646    for &eid in &entity_ids {
647        recalculate_degree(conn, eid)?;
648    }
649
650    Ok((total_deleted, entity_ids))
651}
652
653/// Searches the `entity_embeddings` table for the k nearest neighbours
654/// using pure-Rust cosine similarity.
655///
656/// v1.0.76: sqlite-vec was removed. The full table scan + in-process
657/// cosine is O(N × D) per call. For namespaces with more than ~10k
658/// entities, the operator should rely on FTS5 (`hybrid-search`) for
659/// coarse filtering before reaching this function.
660///
661/// # Errors
662///
663/// - [`AppError::Database`] — SQLite query failure.
664/// - [`AppError::Embedding`] — invalid or mismatched embedding dimension.
665pub fn knn_search(
666    conn: &Connection,
667    embedding: &[f32],
668    namespace: &str,
669    k: usize,
670) -> Result<Vec<(i64, f32)>, AppError> {
671    if embedding.len() != crate::constants::EMBEDDING_DIM {
672        return Err(AppError::Embedding(format!(
673            "knn_search embedding has {} dims, expected {}",
674            embedding.len(),
675            crate::constants::EMBEDDING_DIM
676        )));
677    }
678    let mut stmt = conn.prepare_cached(
679        "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
680    )?;
681    let mut scored: Vec<(i64, f32)> = stmt
682        .query_map(params![namespace], |r| {
683            let id: i64 = r.get(0)?;
684            let bytes: Vec<u8> = r.get(1)?;
685            Ok((id, bytes))
686        })?
687        .filter_map(|row| {
688            row.ok().and_then(|(id, bytes)| {
689                let stored = crate::embedder::bytes_to_f32(&bytes);
690                if stored.len() != embedding.len() {
691                    return None;
692                }
693                let score = crate::similarity::cosine_similarity(embedding, &stored);
694                Some((id, score))
695            })
696        })
697        .collect();
698    // `cosine_similarity` returns a value in [-1.0, 1.0]; 1.0 is the
699    // best match. Sort descending and truncate to `k`.
700    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
701    scored.truncate(k);
702    Ok(scored)
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use crate::constants::EMBEDDING_DIM;
709    use crate::entity_type::EntityType;
710    use crate::storage::connection::register_vec_extension;
711    use rusqlite::Connection;
712    use tempfile::TempDir;
713
714    type TestResult = Result<(), Box<dyn std::error::Error>>;
715
716    fn setup_db() -> Result<(TempDir, Connection), Box<dyn std::error::Error>> {
717        register_vec_extension();
718        let tmp = TempDir::new()?;
719        let db_path = tmp.path().join("test.db");
720        let mut conn = Connection::open(&db_path)?;
721        crate::migrations::runner().run(&mut conn)?;
722        Ok((tmp, conn))
723    }
724
725    fn insert_memory(conn: &Connection) -> Result<i64, Box<dyn std::error::Error>> {
726        conn.execute(
727            "INSERT INTO memories (namespace, name, type, description, body, body_hash)
728             VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
729            [],
730        )?;
731        Ok(conn.last_insert_rowid())
732    }
733
734    fn new_entity_helper(name: &str) -> NewEntity {
735        NewEntity {
736            name: name.to_string(),
737            entity_type: EntityType::Project,
738            description: None,
739        }
740    }
741
742    fn embedding_zero() -> Vec<f32> {
743        vec![0.0f32; EMBEDDING_DIM]
744    }
745
746    // ------------------------------------------------------------------ //
747    // upsert_entity
748    // ------------------------------------------------------------------ //
749
750    #[test]
751    fn test_upsert_entity_creates_new() -> TestResult {
752        let (_tmp, conn) = setup_db()?;
753        let e = new_entity_helper("projeto-alpha");
754        let id = upsert_entity(&conn, "global", &e)?;
755        assert!(id > 0);
756        Ok(())
757    }
758
759    #[test]
760    fn test_upsert_entity_idempotent_returns_same_id() -> TestResult {
761        let (_tmp, conn) = setup_db()?;
762        let e = new_entity_helper("projeto-beta");
763        let id1 = upsert_entity(&conn, "global", &e)?;
764        let id2 = upsert_entity(&conn, "global", &e)?;
765        assert_eq!(id1, id2);
766        Ok(())
767    }
768
769    #[test]
770    fn test_upsert_entity_updates_description() -> TestResult {
771        let (_tmp, conn) = setup_db()?;
772        let e1 = new_entity_helper("projeto-gamma");
773        let id1 = upsert_entity(&conn, "global", &e1)?;
774
775        let e2 = NewEntity {
776            name: "projeto-gamma".to_string(),
777            entity_type: EntityType::Tool,
778            description: Some("nova desc".to_string()),
779        };
780        let id2 = upsert_entity(&conn, "global", &e2)?;
781        assert_eq!(id1, id2);
782
783        let desc: Option<String> = conn.query_row(
784            "SELECT description FROM entities WHERE id = ?1",
785            params![id1],
786            |r| r.get(0),
787        )?;
788        assert_eq!(desc.as_deref(), Some("nova desc"));
789        Ok(())
790    }
791
792    #[test]
793    fn test_upsert_entity_different_namespaces_create_distinct_records() -> TestResult {
794        let (_tmp, conn) = setup_db()?;
795        let e = new_entity_helper("compartilhada");
796        let id1 = upsert_entity(&conn, "ns1", &e)?;
797        let id2 = upsert_entity(&conn, "ns2", &e)?;
798        assert_ne!(id1, id2);
799        Ok(())
800    }
801
802    // ------------------------------------------------------------------ //
803    // upsert_entity_vec — covers DELETE+INSERT (new branch after the OOM fix)
804    // ------------------------------------------------------------------ //
805
806    #[test]
807    fn test_upsert_entity_vec_first_time_without_conflict() -> TestResult {
808        let (_tmp, conn) = setup_db()?;
809        let e = new_entity_helper("vec-nova");
810        let entity_id = upsert_entity(&conn, "global", &e)?;
811        let emb = embedding_zero();
812
813        let result = upsert_entity_vec(
814            &conn,
815            entity_id,
816            "global",
817            EntityType::Project,
818            &emb,
819            "vec-nova",
820        );
821        assert!(result.is_ok(), "first insertion must succeed");
822
823        let count: i64 = conn.query_row(
824            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
825            params![entity_id],
826            |r| r.get(0),
827        )?;
828        assert_eq!(count, 1, "must have exactly one row after insertion");
829        Ok(())
830    }
831
832    #[test]
833    fn test_upsert_entity_vec_second_time_replaces_without_error() -> TestResult {
834        // Covers the branch where DELETE removes the existing row before INSERT.
835        let (_tmp, conn) = setup_db()?;
836        let e = new_entity_helper("vec-existente");
837        let entity_id = upsert_entity(&conn, "global", &e)?;
838        let emb = embedding_zero();
839
840        upsert_entity_vec(
841            &conn,
842            entity_id,
843            "global",
844            EntityType::Project,
845            &emb,
846            "vec-existente",
847        )?;
848
849        // Second call: DELETE returns 1 removed row, INSERT must succeed.
850        let result = upsert_entity_vec(
851            &conn,
852            entity_id,
853            "global",
854            EntityType::Tool,
855            &emb,
856            "vec-existente",
857        );
858        assert!(
859            result.is_ok(),
860            "second insertion (replace) must succeed: {result:?}"
861        );
862
863        let count: i64 = conn.query_row(
864            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
865            params![entity_id],
866            |r| r.get(0),
867        )?;
868        assert_eq!(count, 1, "must have exactly one row after replacement");
869        Ok(())
870    }
871
872    #[test]
873    fn test_upsert_entity_vec_multiple_independent_entities() -> TestResult {
874        let (_tmp, conn) = setup_db()?;
875        let emb = embedding_zero();
876
877        for i in 0..3i64 {
878            let nome = format!("ent-{i}");
879            let e = new_entity_helper(&nome);
880            let entity_id = upsert_entity(&conn, "global", &e)?;
881            upsert_entity_vec(&conn, entity_id, "global", EntityType::Project, &emb, &nome)?;
882        }
883
884        let count: i64 =
885            conn.query_row("SELECT COUNT(*) FROM entity_embeddings", [], |r| r.get(0))?;
886        assert_eq!(
887            count, 3,
888            "must have three distinct rows in entity_embeddings"
889        );
890        Ok(())
891    }
892
893    // ------------------------------------------------------------------ //
894    // find_entity_id
895    // ------------------------------------------------------------------ //
896
897    #[test]
898    fn test_find_entity_id_existing_returns_some() -> TestResult {
899        let (_tmp, conn) = setup_db()?;
900        let e = new_entity_helper("entidade-busca");
901        let id_inserido = upsert_entity(&conn, "global", &e)?;
902        let id_encontrado = find_entity_id(&conn, "global", "entidade-busca")?;
903        assert_eq!(id_encontrado, Some(id_inserido));
904        Ok(())
905    }
906
907    #[test]
908    fn test_find_entity_id_missing_returns_none() -> TestResult {
909        let (_tmp, conn) = setup_db()?;
910        let id = find_entity_id(&conn, "global", "nao-existe")?;
911        assert_eq!(id, None);
912        Ok(())
913    }
914
915    // ------------------------------------------------------------------ //
916    // delete_entities_by_ids
917    // ------------------------------------------------------------------ //
918
919    #[test]
920    fn test_delete_entities_by_ids_empty_list_returns_zero() -> TestResult {
921        let (_tmp, conn) = setup_db()?;
922        let removed = delete_entities_by_ids(&conn, &[])?;
923        assert_eq!(removed, 0);
924        Ok(())
925    }
926
927    #[test]
928    fn test_delete_entities_by_ids_removes_valid_entity() -> TestResult {
929        let (_tmp, conn) = setup_db()?;
930        let e = new_entity_helper("to-delete");
931        let entity_id = upsert_entity(&conn, "global", &e)?;
932
933        let removed = delete_entities_by_ids(&conn, &[entity_id])?;
934        assert_eq!(removed, 1);
935
936        let id = find_entity_id(&conn, "global", "to-delete")?;
937        assert_eq!(id, None, "entity must have been removed");
938        Ok(())
939    }
940
941    #[test]
942    fn test_delete_entities_by_ids_missing_id_returns_zero() -> TestResult {
943        let (_tmp, conn) = setup_db()?;
944        let removed = delete_entities_by_ids(&conn, &[9999])?;
945        assert_eq!(removed, 0);
946        Ok(())
947    }
948
949    #[test]
950    fn test_delete_entities_by_ids_removes_multiple() -> TestResult {
951        let (_tmp, conn) = setup_db()?;
952        let id1 = upsert_entity(&conn, "global", &new_entity_helper("del-a"))?;
953        let id2 = upsert_entity(&conn, "global", &new_entity_helper("del-b"))?;
954        let id3 = upsert_entity(&conn, "global", &new_entity_helper("del-c"))?;
955
956        let removed = delete_entities_by_ids(&conn, &[id1, id2])?;
957        assert_eq!(removed, 2);
958
959        assert!(find_entity_id(&conn, "global", "del-a")?.is_none());
960        assert!(find_entity_id(&conn, "global", "del-b")?.is_none());
961        assert!(find_entity_id(&conn, "global", "del-c")?.is_some());
962        let _ = id3;
963        Ok(())
964    }
965
966    #[test]
967    fn test_delete_entities_by_ids_also_removes_vec() -> TestResult {
968        let (_tmp, conn) = setup_db()?;
969        let e = new_entity_helper("del-com-vec");
970        let entity_id = upsert_entity(&conn, "global", &e)?;
971        let emb = embedding_zero();
972        upsert_entity_vec(
973            &conn,
974            entity_id,
975            "global",
976            EntityType::Project,
977            &emb,
978            "del-com-vec",
979        )?;
980
981        let count_antes: i64 = conn.query_row(
982            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
983            params![entity_id],
984            |r| r.get(0),
985        )?;
986        assert_eq!(count_antes, 1);
987
988        delete_entities_by_ids(&conn, &[entity_id])?;
989
990        let count_depois: i64 = conn.query_row(
991            "SELECT COUNT(*) FROM entity_embeddings WHERE entity_id = ?1",
992            params![entity_id],
993            |r| r.get(0),
994        )?;
995        assert_eq!(
996            count_depois, 0,
997            "entity_embeddings deve ser limpo junto com entities"
998        );
999        Ok(())
1000    }
1001
1002    // ------------------------------------------------------------------ //
1003    // upsert_relationship / find_relationship
1004    // ------------------------------------------------------------------ //
1005
1006    #[test]
1007    fn test_upsert_relationship_creates_new() -> TestResult {
1008        let (_tmp, conn) = setup_db()?;
1009        let id_a = upsert_entity(&conn, "global", &new_entity_helper("rel-a"))?;
1010        let id_b = upsert_entity(&conn, "global", &new_entity_helper("rel-b"))?;
1011
1012        let rel = NewRelationship {
1013            source: "rel-a".to_string(),
1014            target: "rel-b".to_string(),
1015            relation: "uses".to_string(),
1016            strength: 0.8,
1017            description: None,
1018        };
1019        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1020        assert!(rel_id > 0);
1021        Ok(())
1022    }
1023
1024    #[test]
1025    fn test_upsert_relationship_idempotent() -> TestResult {
1026        let (_tmp, conn) = setup_db()?;
1027        let id_a = upsert_entity(&conn, "global", &new_entity_helper("idem-a"))?;
1028        let id_b = upsert_entity(&conn, "global", &new_entity_helper("idem-b"))?;
1029
1030        let rel = NewRelationship {
1031            source: "idem-a".to_string(),
1032            target: "idem-b".to_string(),
1033            relation: "uses".to_string(),
1034            strength: 0.5,
1035            description: None,
1036        };
1037        let id1 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1038        let id2 = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1039        assert_eq!(id1, id2);
1040        Ok(())
1041    }
1042
1043    #[test]
1044    fn test_find_relationship_existing() -> TestResult {
1045        let (_tmp, conn) = setup_db()?;
1046        let id_a = upsert_entity(&conn, "global", &new_entity_helper("fr-a"))?;
1047        let id_b = upsert_entity(&conn, "global", &new_entity_helper("fr-b"))?;
1048
1049        let rel = NewRelationship {
1050            source: "fr-a".to_string(),
1051            target: "fr-b".to_string(),
1052            relation: "depends_on".to_string(),
1053            strength: 0.7,
1054            description: None,
1055        };
1056        upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1057
1058        let encontrada = find_relationship(&conn, id_a, id_b, "depends_on")?;
1059        let row = encontrada.ok_or("relationship should exist")?;
1060        assert_eq!(row.source_id, id_a);
1061        assert_eq!(row.target_id, id_b);
1062        assert!((row.weight - 0.7).abs() < 1e-9);
1063        Ok(())
1064    }
1065
1066    #[test]
1067    fn test_find_relationship_missing_returns_none() -> TestResult {
1068        let (_tmp, conn) = setup_db()?;
1069        let resultado = find_relationship(&conn, 9999, 8888, "uses")?;
1070        assert!(resultado.is_none());
1071        Ok(())
1072    }
1073
1074    // ------------------------------------------------------------------ //
1075    // link_memory_entity / link_memory_relationship
1076    // ------------------------------------------------------------------ //
1077
1078    #[test]
1079    fn test_link_memory_entity_idempotent() -> TestResult {
1080        let (_tmp, conn) = setup_db()?;
1081        let memory_id = insert_memory(&conn)?;
1082        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("me-ent"))?;
1083
1084        link_memory_entity(&conn, memory_id, entity_id)?;
1085        let resultado = link_memory_entity(&conn, memory_id, entity_id);
1086        assert!(
1087            resultado.is_ok(),
1088            "INSERT OR IGNORE must not fail on duplicate"
1089        );
1090        Ok(())
1091    }
1092
1093    #[test]
1094    fn test_link_memory_relationship_idempotent() -> TestResult {
1095        let (_tmp, conn) = setup_db()?;
1096        let memory_id = insert_memory(&conn)?;
1097        let id_a = upsert_entity(&conn, "global", &new_entity_helper("mr-a"))?;
1098        let id_b = upsert_entity(&conn, "global", &new_entity_helper("mr-b"))?;
1099
1100        let rel = NewRelationship {
1101            source: "mr-a".to_string(),
1102            target: "mr-b".to_string(),
1103            relation: "uses".to_string(),
1104            strength: 0.5,
1105            description: None,
1106        };
1107        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1108
1109        link_memory_relationship(&conn, memory_id, rel_id)?;
1110        let resultado = link_memory_relationship(&conn, memory_id, rel_id);
1111        assert!(
1112            resultado.is_ok(),
1113            "INSERT OR IGNORE must not fail on duplicate"
1114        );
1115        Ok(())
1116    }
1117
1118    // ------------------------------------------------------------------ //
1119    // increment_degree / recalculate_degree
1120    // ------------------------------------------------------------------ //
1121
1122    #[test]
1123    fn test_increment_degree_increases_counter() -> TestResult {
1124        let (_tmp, conn) = setup_db()?;
1125        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("grau-ent"))?;
1126
1127        increment_degree(&conn, entity_id)?;
1128        increment_degree(&conn, entity_id)?;
1129
1130        let degree: i64 = conn.query_row(
1131            "SELECT degree FROM entities WHERE id = ?1",
1132            params![entity_id],
1133            |r| r.get(0),
1134        )?;
1135        assert_eq!(degree, 2);
1136        Ok(())
1137    }
1138
1139    #[test]
1140    fn test_recalculate_degree_reflects_actual_relations() -> TestResult {
1141        let (_tmp, conn) = setup_db()?;
1142        let id_a = upsert_entity(&conn, "global", &new_entity_helper("rc-a"))?;
1143        let id_b = upsert_entity(&conn, "global", &new_entity_helper("rc-b"))?;
1144        let id_c = upsert_entity(&conn, "global", &new_entity_helper("rc-c"))?;
1145
1146        let rel1 = NewRelationship {
1147            source: "rc-a".to_string(),
1148            target: "rc-b".to_string(),
1149            relation: "uses".to_string(),
1150            strength: 0.5,
1151            description: None,
1152        };
1153        let rel2 = NewRelationship {
1154            source: "rc-c".to_string(),
1155            target: "rc-a".to_string(),
1156            relation: "depends_on".to_string(),
1157            strength: 0.5,
1158            description: None,
1159        };
1160        upsert_relationship(&conn, "global", id_a, id_b, &rel1)?;
1161        upsert_relationship(&conn, "global", id_c, id_a, &rel2)?;
1162
1163        recalculate_degree(&conn, id_a)?;
1164
1165        let degree: i64 = conn.query_row(
1166            "SELECT degree FROM entities WHERE id = ?1",
1167            params![id_a],
1168            |r| r.get(0),
1169        )?;
1170        assert_eq!(
1171            degree, 2,
1172            "rc-a appears in two relationships (source+target)"
1173        );
1174        Ok(())
1175    }
1176
1177    // ------------------------------------------------------------------ //
1178    // find_orphan_entity_ids
1179    // ------------------------------------------------------------------ //
1180
1181    #[test]
1182    fn test_find_orphan_entity_ids_without_orphans() -> TestResult {
1183        let (_tmp, conn) = setup_db()?;
1184        let memory_id = insert_memory(&conn)?;
1185        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("nao-orfa"))?;
1186        link_memory_entity(&conn, memory_id, entity_id)?;
1187
1188        let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1189        assert!(!orfas.contains(&entity_id));
1190        Ok(())
1191    }
1192
1193    #[test]
1194    fn test_find_orphan_entity_ids_detects_orphans() -> TestResult {
1195        let (_tmp, conn) = setup_db()?;
1196        let entity_id = upsert_entity(&conn, "global", &new_entity_helper("sim-orfa"))?;
1197
1198        let orfas = find_orphan_entity_ids(&conn, Some("global"))?;
1199        assert!(orfas.contains(&entity_id));
1200        Ok(())
1201    }
1202
1203    #[test]
1204    fn test_find_orphan_entity_ids_without_namespace_returns_all() -> TestResult {
1205        let (_tmp, conn) = setup_db()?;
1206        let id1 = upsert_entity(&conn, "ns-a", &new_entity_helper("orfa-a"))?;
1207        let id2 = upsert_entity(&conn, "ns-b", &new_entity_helper("orfa-b"))?;
1208
1209        let orfas = find_orphan_entity_ids(&conn, None)?;
1210        assert!(orfas.contains(&id1));
1211        assert!(orfas.contains(&id2));
1212        Ok(())
1213    }
1214
1215    // ------------------------------------------------------------------ //
1216    // list_entities / list_relationships_by_namespace
1217    // ------------------------------------------------------------------ //
1218
1219    #[test]
1220    fn test_list_entities_with_namespace() -> TestResult {
1221        let (_tmp, conn) = setup_db()?;
1222        upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-1"))?;
1223        upsert_entity(&conn, "le-ns", &new_entity_helper("le-ent-2"))?;
1224        upsert_entity(&conn, "outro-ns", &new_entity_helper("le-ent-3"))?;
1225
1226        let lista = list_entities(&conn, Some("le-ns"))?;
1227        assert_eq!(lista.len(), 2);
1228        assert!(lista.iter().all(|e| e.namespace == "le-ns"));
1229        Ok(())
1230    }
1231
1232    #[test]
1233    fn test_list_entities_without_namespace_returns_all() -> TestResult {
1234        let (_tmp, conn) = setup_db()?;
1235        upsert_entity(&conn, "ns1", &new_entity_helper("all-ent-1"))?;
1236        upsert_entity(&conn, "ns2", &new_entity_helper("all-ent-2"))?;
1237
1238        let lista = list_entities(&conn, None)?;
1239        assert!(lista.len() >= 2);
1240        Ok(())
1241    }
1242
1243    #[test]
1244    fn test_list_relationships_by_namespace_filters_correctly() -> TestResult {
1245        let (_tmp, conn) = setup_db()?;
1246        let id_a = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-a"))?;
1247        let id_b = upsert_entity(&conn, "rel-ns", &new_entity_helper("lr-b"))?;
1248
1249        let rel = NewRelationship {
1250            source: "lr-a".to_string(),
1251            target: "lr-b".to_string(),
1252            relation: "uses".to_string(),
1253            strength: 0.5,
1254            description: None,
1255        };
1256        upsert_relationship(&conn, "rel-ns", id_a, id_b, &rel)?;
1257
1258        let lista = list_relationships_by_namespace(&conn, Some("rel-ns"))?;
1259        assert!(!lista.is_empty());
1260        assert!(lista.iter().all(|r| r.namespace == "rel-ns"));
1261        Ok(())
1262    }
1263
1264    // ------------------------------------------------------------------ //
1265    // delete_relationship_by_id / create_or_fetch_relationship
1266    // ------------------------------------------------------------------ //
1267
1268    #[test]
1269    fn test_delete_relationship_by_id_removes_relation() -> TestResult {
1270        let (_tmp, conn) = setup_db()?;
1271        let id_a = upsert_entity(&conn, "global", &new_entity_helper("dr-a"))?;
1272        let id_b = upsert_entity(&conn, "global", &new_entity_helper("dr-b"))?;
1273
1274        let rel = NewRelationship {
1275            source: "dr-a".to_string(),
1276            target: "dr-b".to_string(),
1277            relation: "uses".to_string(),
1278            strength: 0.5,
1279            description: None,
1280        };
1281        let rel_id = upsert_relationship(&conn, "global", id_a, id_b, &rel)?;
1282
1283        delete_relationship_by_id(&conn, rel_id)?;
1284
1285        let encontrada = find_relationship(&conn, id_a, id_b, "uses")?;
1286        assert!(encontrada.is_none(), "relationship must have been removed");
1287        Ok(())
1288    }
1289
1290    #[test]
1291    fn test_create_or_fetch_relationship_creates_new() -> TestResult {
1292        let (_tmp, conn) = setup_db()?;
1293        let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf-a"))?;
1294        let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf-b"))?;
1295
1296        let (rel_id, created) =
1297            create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1298        assert!(rel_id > 0);
1299        assert!(created);
1300        Ok(())
1301    }
1302
1303    #[test]
1304    fn test_create_or_fetch_relationship_returns_existing() -> TestResult {
1305        let (_tmp, conn) = setup_db()?;
1306        let id_a = upsert_entity(&conn, "global", &new_entity_helper("cf2-a"))?;
1307        let id_b = upsert_entity(&conn, "global", &new_entity_helper("cf2-b"))?;
1308
1309        create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1310        let (_, created) =
1311            create_or_fetch_relationship(&conn, "global", id_a, id_b, "uses", 0.5, None)?;
1312        assert!(
1313            !created,
1314            "second call must return the existing relationship"
1315        );
1316        Ok(())
1317    }
1318
1319    // ------------------------------------------------------------------ //
1320    // serde alias: field "type" accepted as a synonym for "entity_type"
1321    // ------------------------------------------------------------------ //
1322
1323    #[test]
1324    fn accepts_type_field_as_alias() -> TestResult {
1325        let json = r#"{"name": "X", "type": "concept"}"#;
1326        let ent: NewEntity = serde_json::from_str(json)?;
1327        assert_eq!(ent.entity_type, EntityType::Concept);
1328        Ok(())
1329    }
1330
1331    #[test]
1332    fn accepts_canonical_entity_type_field() -> TestResult {
1333        let json = r#"{"name": "X", "entity_type": "concept"}"#;
1334        let ent: NewEntity = serde_json::from_str(json)?;
1335        assert_eq!(ent.entity_type, EntityType::Concept);
1336        Ok(())
1337    }
1338
1339    #[test]
1340    fn both_fields_present_yields_duplicate_error() {
1341        // having both entity_type and type in the same JSON is a duplicate and must fail
1342        let json = r#"{"name": "X", "entity_type": "concept", "type": "person"}"#;
1343        let resultado: Result<NewEntity, _> = serde_json::from_str(json);
1344        assert!(
1345            resultado.is_err(),
1346            "both fields in the same JSON are a duplicate"
1347        );
1348    }
1349
1350    #[test]
1351    fn validate_entity_name_accepts_valid() {
1352        assert!(validate_entity_name("rust-lang").is_ok());
1353        assert!(validate_entity_name("sqlite-graphrag").is_ok());
1354        assert!(validate_entity_name("ab").is_ok());
1355    }
1356
1357    #[test]
1358    fn validate_entity_name_rejects_short() {
1359        assert!(validate_entity_name("a").is_err());
1360        assert!(validate_entity_name("").is_err());
1361    }
1362
1363    #[test]
1364    fn validate_entity_name_rejects_newlines() {
1365        assert!(validate_entity_name("foo\nbar").is_err());
1366        assert!(validate_entity_name("foo\rbar").is_err());
1367    }
1368
1369    #[test]
1370    fn validate_entity_name_rejects_short_allcaps() {
1371        assert!(validate_entity_name("RAM").is_err());
1372        assert!(validate_entity_name("NAO").is_err());
1373        assert!(validate_entity_name("OK").is_err());
1374    }
1375
1376    #[test]
1377    fn validate_entity_name_accepts_long_allcaps() {
1378        assert!(validate_entity_name("SQLITE").is_ok());
1379        assert!(validate_entity_name("GRAPHRAG").is_ok());
1380    }
1381
1382    #[test]
1383    fn validate_entity_name_accepts_mixed_case() {
1384        assert!(validate_entity_name("FTS5").is_ok()); // 4 chars but has digit
1385        assert!(validate_entity_name("WAL").is_err()); // 3 chars ALL_CAPS
1386    }
1387}