Skip to main content

sqlite_graphrag/commands/
merge_entities.rs

1//! Handler for the `merge-entities` CLI subcommand (GAP-19).
2//!
3//! Merges two or more source entities into a single target entity by:
4//!   1. Retargeting all relationships pointing at any source to the target.
5//!   2. Deduplicating relationships that become identical after the merge
6//!      (same source_id + target_id + relation).
7//!   3. Retargeting memory_entities bindings.
8//!   4. Deleting the now-empty source entity rows.
9
10use crate::errors::AppError;
11use crate::i18n::errors_msg;
12use crate::output::{self, OutputFormat};
13use crate::paths::AppPaths;
14use crate::storage::connection::open_rw;
15use crate::storage::entities;
16use rusqlite::params;
17use serde::Serialize;
18
19#[derive(clap::Args)]
20#[command(after_long_help = "EXAMPLES:\n  \
21    # Merge two source entities into a target\n  \
22    sqlite-graphrag merge-entities --names auth,authentication --into auth-service\n\n  \
23    # Merge three sources into one target across a namespace\n  \
24    sqlite-graphrag merge-entities --names svc-a,svc-b,old-svc --into canonical-service --namespace my-project\n\n  \
25    # Merge by ID (unambiguous when homonyms exist across namespaces)\n  \
26    sqlite-graphrag merge-entities --ids 12,17 --into-id 3\n\n\
27NOTE:\n  \
28    --names is a comma-separated list of source entity names.\n  \
29    --into is the target entity name and must already exist.\n  \
30    --ids / --into-id select entities by ID; IDs are globally unique so they\n  \
31    disambiguate homonyms. They conflict with --names / --into respectively\n  \
32    and must belong to the resolved namespace.\n  \
33    Source entities are deleted after the merge; the target is preserved.\n  \
34    Duplicate relationships (same endpoints + relation) are removed automatically.\n  \
35    Run `sqlite-graphrag cleanup-orphans` afterwards if sources had no other links.")]
36pub struct MergeEntitiesArgs {
37    /// Comma-separated list of source entity names to merge into the target.
38    #[arg(
39        long,
40        value_delimiter = ',',
41        value_name = "NAMES",
42        required_unless_present = "ids",
43        conflicts_with = "ids"
44    )]
45    pub names: Vec<String>,
46    /// v1.1.1 (P5): comma-separated list of source entity IDs. IDs are
47    /// globally unique, so they disambiguate homonyms across namespaces.
48    /// Conflicts with --names; every ID must belong to the resolved namespace.
49    #[arg(long, value_delimiter = ',', value_name = "IDS")]
50    pub ids: Vec<i64>,
51    /// Target entity name. Must already exist. All source relationships are redirected here.
52    #[arg(
53        long,
54        value_name = "TARGET",
55        required_unless_present = "into_id",
56        conflicts_with = "into_id"
57    )]
58    pub into: Option<String>,
59    /// v1.1.1 (P5): target entity ID. Unambiguous alternative to --into.
60    #[arg(long, value_name = "TARGET_ID")]
61    pub into_id: Option<i64>,
62    #[arg(long)]
63    pub namespace: Option<String>,
64    #[arg(long, value_enum, default_value = "json")]
65    pub format: OutputFormat,
66    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
67    pub json: bool,
68    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
69    pub db: Option<String>,
70    /// v1.1.03: allow merging source entities from OTHER namespaces into the
71    /// target. Default false preserves same-namespace safety. When true, each
72    /// --ids source is resolved by its own row (no namespace filter); target
73    /// must still exist in the resolved namespace.
74    #[arg(long, default_value_t = false, hide = false)]
75    pub cross_namespace: bool,
76}
77
78#[derive(Serialize)]
79struct MergeEntitiesResponse {
80    action: String,
81    sources: Vec<String>,
82    target: String,
83    namespace: String,
84    /// v1.1.1 (P5): resolved target entity ID, echoed for unambiguous auditing.
85    target_id: i64,
86    relationships_moved: usize,
87    entities_removed: usize,
88    /// Total execution time in milliseconds from handler start to serialisation.
89    elapsed_ms: u64,
90}
91
92/// v1.1.1 (P5): resolves an entity ID to its name. When `enforce_namespace`
93/// is true (default behaviour, same-namespace safety), the lookup also enforces
94/// that the entity belongs to `namespace` — IDs are global, so a bare existence
95/// check could silently cross namespaces. When false (v1.1.03 cross-namespace
96/// merge), the lookup resolves the entity by its own row and returns the
97/// namespace it actually lives in, so callers can audit the cross-namespace move.
98fn find_entity_name_by_id(
99    conn: &rusqlite::Connection,
100    namespace: &str,
101    id: i64,
102    enforce_namespace: bool,
103) -> Result<(String, String), AppError> {
104    let mut stmt = if enforce_namespace {
105        conn.prepare_cached(
106            "SELECT name, namespace FROM entities WHERE id = ?1 AND namespace = ?2",
107        )?
108    } else {
109        conn.prepare_cached("SELECT name, namespace FROM entities WHERE id = ?1")?
110    };
111    let row = if enforce_namespace {
112        stmt.query_row(params![id, namespace], |r| {
113            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
114        })
115    } else {
116        stmt.query_row(params![id], |r| {
117            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
118        })
119    };
120    match row {
121        Ok((name, ns_actual)) => Ok((name, ns_actual)),
122        Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(format!(
123            "entity id={id} not found in namespace '{namespace}'"
124        ))),
125        Err(e) => Err(AppError::Database(e)),
126    }
127}
128
129pub fn run(args: MergeEntitiesArgs) -> Result<(), AppError> {
130    let inicio = std::time::Instant::now();
131
132    if args.names.is_empty() && args.ids.is_empty() {
133        return Err(AppError::Validation(
134            "--names or --ids must contain at least one source entity".to_string(),
135        ));
136    }
137
138    // v1.1.05 Bug 4: reject self-referential merge at the earliest possible
139    // point (before any DB work), so shell word-splitting mistakes fail loud.
140    if let Some(target_id) = args.into_id {
141        if args.ids.contains(&target_id) {
142            return Err(AppError::Validation(format!(
143                "source entity id={target_id} equals target id={target_id} — \
144                 self-referential merge is not allowed (remove target from --ids)"
145            )));
146        }
147    }
148    if let Some(ref target_name) = args.into {
149        if args.names.iter().any(|n| n == target_name) {
150            return Err(AppError::Validation(format!(
151                "source entity '{target_name}' equals target '{target_name}' — \
152                 self-referential merge is not allowed (remove target from --names)"
153            )));
154        }
155    }
156
157    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
158    let paths = AppPaths::resolve(args.db.as_deref())?;
159
160    crate::storage::connection::ensure_db_ready(&paths)?;
161
162    let mut conn = open_rw(&paths.db)?;
163
164    // Resolve target entity — by ID (v1.1.1 P5, unambiguous) or by name.
165    // Existence is validated here, BEFORE any mutation.
166    let (target_id, target_name) = match args.into_id {
167        Some(id) => {
168            // Target is always validated in the resolved namespace, even when
169            // --cross-namespace is set: cross-namespace only relaxes SOURCES.
170            let (name, _ns_actual) = find_entity_name_by_id(&conn, &namespace, id, true)?;
171            (id, name)
172        }
173        None => {
174            let Some(name) = args.into.clone() else {
175                return Err(AppError::Validation(
176                    "--into or --into-id is required".to_string(),
177                ));
178            };
179            let id = entities::find_entity_id(&conn, &namespace, &name)?.ok_or_else(|| {
180                AppError::NotFound(errors_msg::entity_not_found(&name, &namespace))
181            })?;
182            (id, name)
183        }
184    };
185
186    // Resolve source entity IDs — reject self-referential merge (G21),
187    // by ID (v1.1.1 P5) or by name. All lookups happen BEFORE the transaction.
188    // Defense-in-depth: re-check even after early parse-time guard (Bug 4).
189    let mut source_ids: Vec<i64> = Vec::with_capacity(args.names.len() + args.ids.len());
190    let mut source_names: Vec<String> = Vec::with_capacity(source_ids.capacity());
191    if !args.ids.is_empty() {
192        for &id in &args.ids {
193            if id == target_id {
194                return Err(AppError::Validation(format!(
195                    "source entity id={id} equals target id={target_id} — \
196                     self-referential merge is not allowed"
197                )));
198            }
199            // v1.1.03: when --cross-namespace is set, resolve each source by its
200            // own row (no namespace filter) and warn on the cross-namespace move.
201            // Default (false) preserves same-namespace safety.
202            let (name, ns_actual) =
203                find_entity_name_by_id(&conn, &namespace, id, !args.cross_namespace)?;
204            if args.cross_namespace && ns_actual != namespace {
205                tracing::warn!(
206                    target: "merge_entities",
207                    from_id = id,
208                    from_namespace = %ns_actual,
209                    to_namespace = %namespace,
210                    "cross-namespace merge"
211                );
212            }
213            if !source_ids.contains(&id) {
214                source_ids.push(id);
215                source_names.push(name);
216            }
217        }
218    } else {
219        for name in &args.names {
220            if name == &target_name {
221                return Err(AppError::Validation(format!(
222                    "source entity '{name}' equals target '{target_name}' — \
223                     self-referential merge is not allowed"
224                )));
225            }
226            let id = entities::find_entity_id(&conn, &namespace, name)?.ok_or_else(|| {
227                AppError::NotFound(errors_msg::entity_not_found(name, &namespace))
228            })?;
229            if id == target_id {
230                return Err(AppError::Validation(format!(
231                    "source entity '{name}' resolves to the target (id={target_id}) — \
232                     self-referential merge is not allowed"
233                )));
234            }
235            if !source_ids.contains(&id) {
236                source_ids.push(id);
237                source_names.push(name.clone());
238            }
239        }
240    }
241
242    if source_ids.is_empty() {
243        return Err(AppError::Validation(
244            "no valid source entities to merge (all names equal the target or were duplicates)"
245                .to_string(),
246        ));
247    }
248
249    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
250
251    let mut relationships_moved: usize = 0;
252
253    for &src_id in &source_ids {
254        // Step 1a: redirect source_id, ignoring UNIQUE conflicts.
255        let moved_src = tx.execute(
256            "UPDATE OR IGNORE relationships SET source_id = ?1 WHERE source_id = ?2",
257            params![target_id, src_id],
258        )?;
259        tx.execute(
260            "DELETE FROM relationships WHERE source_id = ?1",
261            params![src_id],
262        )?;
263        // Step 1b: redirect target_id, ignoring UNIQUE conflicts.
264        let moved_tgt = tx.execute(
265            "UPDATE OR IGNORE relationships SET target_id = ?1 WHERE target_id = ?2",
266            params![target_id, src_id],
267        )?;
268        tx.execute(
269            "DELETE FROM relationships WHERE target_id = ?1",
270            params![src_id],
271        )?;
272        relationships_moved += moved_src + moved_tgt;
273    }
274
275    // Step 2: remove self-loops introduced by the redirect (target → target).
276    tx.execute("DELETE FROM relationships WHERE source_id = target_id", [])?;
277
278    // Step 3: deduplicate relationships that now share (source, target, relation).
279    // Safety net — UPDATE OR IGNORE should have handled most duplicates above.
280    tx.execute(
281        "DELETE FROM relationships
282         WHERE id NOT IN (
283             SELECT MIN(id)
284             FROM relationships
285             GROUP BY source_id, target_id, relation
286         )",
287        [],
288    )?;
289
290    // Step 4: retarget memory_entities bindings.
291    // Use UPDATE OR IGNORE to skip conflicts when memory is already bound to
292    // target entity. Then DELETE remaining source rows (the conflicting ones
293    // that UPDATE OR IGNORE skipped). Same pattern as relationships (Step 1).
294    for &src_id in &source_ids {
295        tx.execute(
296            "UPDATE OR IGNORE memory_entities SET entity_id = ?1 WHERE entity_id = ?2",
297            params![target_id, src_id],
298        )?;
299        tx.execute(
300            "DELETE FROM memory_entities WHERE entity_id = ?1",
301            params![src_id],
302        )?;
303    }
304
305    // Step 5: deduplicate memory_entities bindings (same memory + entity).
306    tx.execute(
307        "DELETE FROM memory_entities
308         WHERE rowid NOT IN (
309             SELECT MIN(rowid)
310             FROM memory_entities
311             GROUP BY memory_id, entity_id
312         )",
313        [],
314    )?;
315
316    // Step 6: delete source entities. v1.0.76: FK ON DELETE CASCADE on
317    // entity_embeddings handles the vector row automatically.
318    let mut entities_removed: usize = 0;
319    for &src_id in &source_ids {
320        let removed = tx.execute("DELETE FROM entities WHERE id = ?1", params![src_id])?;
321        entities_removed += removed;
322    }
323
324    // Step 7: recalculate degree for target and all adjacent entities.
325    let adjacent_ids: Vec<i64> = {
326        let mut stmt = tx.prepare(
327            "SELECT DISTINCT CASE WHEN source_id = ?1 THEN target_id ELSE source_id END
328             FROM relationships WHERE source_id = ?1 OR target_id = ?1",
329        )?;
330        let ids: Vec<i64> = stmt
331            .query_map(params![target_id], |r| r.get(0))?
332            .collect::<Result<Vec<_>, _>>()?;
333        ids
334    };
335    entities::recalculate_degree(&tx, target_id)?;
336    for &adj_id in &adjacent_ids {
337        entities::recalculate_degree(&tx, adj_id)?;
338    }
339
340    tx.commit()?;
341
342    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
343
344    let response = MergeEntitiesResponse {
345        action: "merged".to_string(),
346        sources: source_names,
347        target: target_name,
348        namespace: namespace.clone(),
349        target_id,
350        relationships_moved,
351        entities_removed,
352        elapsed_ms: inicio.elapsed().as_millis() as u64,
353    };
354
355    match args.format {
356        OutputFormat::Json => output::emit_json(&response)?,
357        OutputFormat::Text | OutputFormat::Markdown => {
358            output::emit_text(&format!(
359                "merged: {} sources into '{}' (relationships_moved={}, entities_removed={}) [{}]",
360                response.sources.len(),
361                response.target,
362                response.relationships_moved,
363                response.entities_removed,
364                response.namespace
365            ));
366        }
367    }
368
369    Ok(())
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    // v1.1.1 (P5): ID resolution is namespace-scoped — a homonym in another
377    // namespace must NOT be reachable through its ID from the wrong namespace.
378    #[test]
379    fn find_entity_name_by_id_disambiguates_homonyms_across_namespaces() {
380        let conn = rusqlite::Connection::open_in_memory().unwrap();
381        conn.execute_batch(
382            "CREATE TABLE entities (
383                id INTEGER PRIMARY KEY,
384                namespace TEXT NOT NULL,
385                name TEXT NOT NULL,
386                UNIQUE(namespace, name)
387            );",
388        )
389        .unwrap();
390        conn.execute(
391            "INSERT INTO entities (id, namespace, name)
392             VALUES (1, 'ns-a', 'auth'), (2, 'ns-b', 'auth')",
393            [],
394        )
395        .unwrap();
396
397        // Same name in two namespaces: each ID resolves only in its own.
398        assert_eq!(
399            find_entity_name_by_id(&conn, "ns-a", 1, true).unwrap(),
400            ("auth".to_string(), "ns-a".to_string())
401        );
402        assert_eq!(
403            find_entity_name_by_id(&conn, "ns-b", 2, true).unwrap(),
404            ("auth".to_string(), "ns-b".to_string())
405        );
406        let err = find_entity_name_by_id(&conn, "ns-a", 2, true).unwrap_err();
407        assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
408        assert!(err.to_string().contains("id=2"), "obtido: {err}");
409
410        // v1.1.03: with enforce_namespace=false, id=2 resolves from any
411        // namespace and reports the namespace it actually lives in.
412        let (name, ns_actual) = find_entity_name_by_id(&conn, "ns-a", 2, false).unwrap();
413        assert_eq!(name, "auth");
414        assert_eq!(ns_actual, "ns-b");
415    }
416
417    #[test]
418    fn find_entity_name_by_id_missing_id_is_not_found() {
419        let conn = rusqlite::Connection::open_in_memory().unwrap();
420        conn.execute_batch(
421            "CREATE TABLE entities (
422                id INTEGER PRIMARY KEY,
423                namespace TEXT NOT NULL,
424                name TEXT NOT NULL
425            );",
426        )
427        .unwrap();
428        let err = find_entity_name_by_id(&conn, "global", 99, true).unwrap_err();
429        assert_eq!(err.exit_code(), 4);
430    }
431
432    // v1.1.1 (P5): clap-level exclusivity between name-based and ID-based
433    // selectors, and requiredness of at least one selector per side.
434    #[derive(clap::Parser)]
435    struct TestCli {
436        #[command(flatten)]
437        args: MergeEntitiesArgs,
438    }
439
440    #[test]
441    fn clap_rejects_names_combined_with_ids() {
442        use clap::Parser;
443        let err =
444            match TestCli::try_parse_from(["t", "--names", "a,b", "--ids", "1,2", "--into", "tgt"])
445            {
446                Ok(_) => panic!("expected argument conflict"),
447                Err(e) => e,
448            };
449        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
450    }
451
452    #[test]
453    fn clap_rejects_into_combined_with_into_id() {
454        use clap::Parser;
455        let err =
456            match TestCli::try_parse_from(["t", "--names", "a", "--into", "tgt", "--into-id", "3"])
457            {
458                Ok(_) => panic!("expected argument conflict"),
459                Err(e) => e,
460            };
461        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
462    }
463
464    /// v1.1.05 Bug 4: clap value_delimiter must keep target id out of sources
465    /// when the operator writes `--ids 35575,35340 --into-id 35340` — the
466    /// early self-ref guard must fire without needing a live database.
467    #[test]
468    fn self_referential_ids_rejected_before_db() {
469        let args = MergeEntitiesArgs {
470            names: vec![],
471            ids: vec![35575, 35340],
472            into: None,
473            into_id: Some(35340),
474            namespace: Some("global".into()),
475            format: OutputFormat::Json,
476            json: false,
477            db: Some("/nonexistent/no-db.sqlite".into()),
478            cross_namespace: true,
479        };
480        let err = run(args).expect_err("self-ref must fail");
481        assert_eq!(err.exit_code(), 1, "validation exit");
482        let msg = err.to_string();
483        assert!(
484            msg.contains("self-referential") || msg.contains("35340"),
485            "expected self-ref message, got: {msg}"
486        );
487    }
488
489    #[test]
490    fn clap_parses_ids_with_target_in_list() {
491        use clap::Parser;
492        let ok = TestCli::try_parse_from([
493            "t",
494            "--ids",
495            "35575,35340",
496            "--into-id",
497            "35340",
498            "--cross-namespace",
499        ])
500        .expect("must parse");
501        assert_eq!(ok.args.ids, vec![35575, 35340]);
502        assert_eq!(ok.args.into_id, Some(35340));
503        assert!(ok.args.cross_namespace);
504    }
505
506    #[test]
507    fn clap_requires_a_source_and_a_target_selector() {
508        use clap::Parser;
509        assert!(TestCli::try_parse_from(["t", "--into", "tgt"]).is_err());
510        assert!(TestCli::try_parse_from(["t", "--names", "a"]).is_err());
511        let ok = match TestCli::try_parse_from(["t", "--ids", "1,2", "--into-id", "3"]) {
512            Ok(cli) => cli,
513            Err(e) => panic!("expected successful parse: {e}"),
514        };
515        assert_eq!(ok.args.ids, vec![1, 2]);
516        assert_eq!(ok.args.into_id, Some(3));
517        assert!(ok.args.names.is_empty());
518        assert!(ok.args.into.is_none());
519    }
520
521    #[test]
522    fn merge_entities_response_serializes_all_fields() {
523        let resp = MergeEntitiesResponse {
524            action: "merged".to_string(),
525            sources: vec!["auth".to_string(), "authentication".to_string()],
526            target: "auth-service".to_string(),
527            namespace: "global".to_string(),
528            target_id: 1,
529            relationships_moved: 7,
530            entities_removed: 2,
531            elapsed_ms: 15,
532        };
533        let json = serde_json::to_value(&resp).expect("serialization failed");
534        assert_eq!(json["action"], "merged");
535        assert_eq!(json["target"], "auth-service");
536        assert_eq!(json["namespace"], "global");
537        assert_eq!(json["relationships_moved"], 7);
538        assert_eq!(json["entities_removed"], 2);
539        let sources = json["sources"].as_array().expect("must be array");
540        assert_eq!(sources.len(), 2);
541        assert!(json["elapsed_ms"].is_number());
542    }
543
544    #[test]
545    fn merge_entities_response_action_is_merged() {
546        let resp = MergeEntitiesResponse {
547            action: "merged".to_string(),
548            sources: vec!["src".to_string()],
549            target: "tgt".to_string(),
550            namespace: "ns".to_string(),
551            target_id: 1,
552            relationships_moved: 0,
553            entities_removed: 1,
554            elapsed_ms: 0,
555        };
556        assert_eq!(resp.action, "merged");
557    }
558
559    #[test]
560    fn merge_entities_response_empty_sources_serializes() {
561        let resp = MergeEntitiesResponse {
562            action: "merged".to_string(),
563            sources: vec![],
564            target: "target".to_string(),
565            namespace: "global".to_string(),
566            target_id: 1,
567            relationships_moved: 0,
568            entities_removed: 0,
569            elapsed_ms: 1,
570        };
571        let json = serde_json::to_value(&resp).expect("serialization failed");
572        let sources = json["sources"].as_array().expect("must be array");
573        assert_eq!(sources.len(), 0);
574    }
575
576    #[test]
577    fn merge_entities_response_with_zero_relationships_moved() {
578        let resp = MergeEntitiesResponse {
579            action: "merged".to_string(),
580            sources: vec!["src-a".to_string()],
581            target: "tgt".to_string(),
582            namespace: "global".to_string(),
583            target_id: 1,
584            relationships_moved: 0,
585            entities_removed: 1,
586            elapsed_ms: 5,
587        };
588        let json = serde_json::to_value(&resp).expect("serialization failed");
589        assert_eq!(json["relationships_moved"], 0);
590        assert_eq!(json["entities_removed"], 1);
591    }
592
593    #[test]
594    fn merge_entities_response_multiple_sources() {
595        let resp = MergeEntitiesResponse {
596            action: "merged".to_string(),
597            sources: vec!["a".into(), "b".into(), "c".into()],
598            target: "canonical".to_string(),
599            namespace: "proj".to_string(),
600            target_id: 1,
601            relationships_moved: 12,
602            entities_removed: 3,
603            elapsed_ms: 42,
604        };
605        let json = serde_json::to_value(&resp).expect("serialization failed");
606        assert_eq!(json["entities_removed"], 3);
607        let sources = json["sources"].as_array().unwrap();
608        assert_eq!(sources.len(), 3);
609    }
610
611    // v1.1.03: integration setup — a fully migrated DB on disk so run() can
612    // open it via AppPaths + open_rw. Returns the tempdir (kept alive for the
613    // test lifetime), the seeded connection, and the DB file path.
614    fn setup_migrated_db_on_disk() -> (tempfile::TempDir, rusqlite::Connection, std::path::PathBuf)
615    {
616        crate::storage::connection::register_vec_extension();
617        let tmp = tempfile::TempDir::new().expect("tempdir");
618        let db_path = tmp.path().join("test.db");
619        let mut conn = rusqlite::Connection::open(&db_path).expect("open");
620        crate::migrations::runner().run(&mut conn).expect("migrate");
621        (tmp, conn, db_path)
622    }
623
624    // v1.1.03 (Bug 3): --cross-namespace allows merging a source that lives in
625    // a DIFFERENT namespace into the target. Relationships are retargeted and
626    // the source row is deleted from its origin namespace.
627    #[test]
628    fn cross_namespace_merges_source_from_other_namespace() {
629        let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
630        // Target lives in "global".
631        conn.execute(
632            "INSERT INTO entities (namespace, name, type) VALUES ('global','tgt','concept')",
633            [],
634        )
635        .unwrap();
636        let tgt_id = conn.last_insert_rowid();
637        // Source lives in "ai-sdd" — a homonym in a different namespace.
638        conn.execute(
639            "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','dup-src','concept')",
640            [],
641        )
642        .unwrap();
643        let src_id = conn.last_insert_rowid();
644        // A third entity in "global" that the source points to, so the
645        // retarget produces an observable relationship_moved > 0.
646        conn.execute(
647            "INSERT INTO entities (namespace, name, type) VALUES ('global','third','concept')",
648            [],
649        )
650        .unwrap();
651        let third_id = conn.last_insert_rowid();
652        conn.execute(
653            "INSERT INTO relationships (namespace, source_id, target_id, relation, weight) \
654             VALUES ('ai-sdd', ?1, ?2, 'related', 0.5)",
655            params![src_id, third_id],
656        )
657        .unwrap();
658        // Drop the seeding connection so run() can open the DB exclusively.
659        drop(conn);
660
661        let args = MergeEntitiesArgs {
662            names: vec![],
663            ids: vec![src_id],
664            into: None,
665            into_id: Some(tgt_id),
666            namespace: Some("global".to_string()),
667            format: OutputFormat::Json,
668            json: false,
669            db: Some(db_path.to_string_lossy().into_owned()),
670            cross_namespace: true,
671        };
672        run(args).expect("cross-namespace merge must succeed");
673
674        // Reopen and verify: source deleted, relationship retargeted to target.
675        let conn = rusqlite::Connection::open(&db_path).unwrap();
676        let src_remaining: i64 = conn
677            .query_row(
678                "SELECT COUNT(*) FROM entities WHERE id = ?1",
679                params![src_id],
680                |r| r.get(0),
681            )
682            .unwrap();
683        assert_eq!(src_remaining, 0, "cross-namespace source must be deleted");
684        let moved: i64 = conn
685            .query_row(
686                "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 AND target_id = ?2",
687                params![tgt_id, third_id],
688                |r| r.get(0),
689            )
690            .unwrap();
691        assert!(
692            moved > 0,
693            "relationship must be retargeted to the target entity"
694        );
695    }
696
697    // v1.1.03 (Bug 3): without --cross-namespace, a source ID from another
698    // namespace is rejected with NotFound — preserves the same-namespace safety
699    // (non-regression of the v1.1.1 P5 behaviour).
700    #[test]
701    fn cross_namespace_default_false_rejects_cross_id() {
702        let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
703        conn.execute(
704            "INSERT INTO entities (namespace, name, type) VALUES ('global','tgt','concept')",
705            [],
706        )
707        .unwrap();
708        let tgt_id = conn.last_insert_rowid();
709        conn.execute(
710            "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','dup-src','concept')",
711            [],
712        )
713        .unwrap();
714        let src_id = conn.last_insert_rowid();
715        drop(conn);
716
717        let args = MergeEntitiesArgs {
718            names: vec![],
719            ids: vec![src_id],
720            into: None,
721            into_id: Some(tgt_id),
722            namespace: Some("global".to_string()),
723            format: OutputFormat::Json,
724            json: false,
725            db: Some(db_path.to_string_lossy().into_owned()),
726            cross_namespace: false,
727        };
728        let err = run(args).expect_err("default must reject cross-namespace ID");
729        assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
730    }
731
732    // v1.1.03 (Bug 3): even with --cross-namespace, the TARGET must still exist
733    // in the resolved namespace — cross-namespace only relaxes SOURCES.
734    #[test]
735    fn cross_namespace_target_must_still_be_in_resolved_namespace() {
736        let (_tmp, conn, db_path) = setup_migrated_db_on_disk();
737        // Target lives in "ai-sdd", but we will resolve namespace to "global".
738        conn.execute(
739            "INSERT INTO entities (namespace, name, type) VALUES ('ai-sdd','tgt','concept')",
740            [],
741        )
742        .unwrap();
743        let tgt_id = conn.last_insert_rowid();
744        conn.execute(
745            "INSERT INTO entities (namespace, name, type) VALUES ('global','src','concept')",
746            [],
747        )
748        .unwrap();
749        let src_id = conn.last_insert_rowid();
750        drop(conn);
751
752        let args = MergeEntitiesArgs {
753            names: vec![],
754            ids: vec![src_id],
755            into: None,
756            into_id: Some(tgt_id),
757            namespace: Some("global".to_string()),
758            format: OutputFormat::Json,
759            json: false,
760            db: Some(db_path.to_string_lossy().into_owned()),
761            cross_namespace: true,
762        };
763        let err = run(args).expect_err("target in wrong namespace must fail");
764        assert_eq!(
765            err.exit_code(),
766            4,
767            "target must still be NotFound in the resolved namespace"
768        );
769    }
770}