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