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