Skip to main content

sqlite_graphrag/commands/
link.rs

1//! Handler for the `link` CLI subcommand.
2
3use crate::constants::DEFAULT_RELATION_WEIGHT;
4use crate::entity_type::EntityType;
5use crate::errors::AppError;
6use crate::i18n::{errors_msg, validation};
7use crate::output::{self, OutputFormat};
8use crate::paths::AppPaths;
9use crate::storage::connection::open_rw;
10use crate::storage::entities;
11use crate::storage::entities::NewEntity;
12use rusqlite::params;
13use serde::Serialize;
14
15#[derive(clap::Args)]
16#[command(after_long_help = "EXAMPLES:\n  \
17    # Link two existing graph entities (curated via `remember --graph-stdin` or created by `enrich`)\n  \
18    sqlite-graphrag link --from oauth-flow --to refresh-tokens --relation related\n\n  \
19    # Auto-create entities that don't exist yet\n  \
20    sqlite-graphrag link --from concept-a --to concept-b --relation depends-on --create-missing\n\n  \
21    # Specify entity type for auto-created entities\n  \
22    sqlite-graphrag link --from alice --to acme-corp --relation related --create-missing --entity-type person\n\n  \
23    # Use a custom (non-canonical) relation type\n  \
24    sqlite-graphrag link --from module-a --to module-b --relation implements --create-missing\n\n  \
25    # If the entity does not exist and --create-missing is not set, the command fails with exit 4.\n  \
26    # To list current entity names:\n  \
27    sqlite-graphrag graph entities | jaq '.entities[].name'\n\n  \
28NOTE:\n  \
29LOCK WAITING:\n  \
30    The root-level --wait-lock SECONDS flag (default 30s) controls how long\n  \
31    the link/unlink subcommands wait for the global CLI lock before failing\n  \
32    with exit 15. In a cold start (first call in a new namespace), the lock\n  \
33    acquisition may exceed the default wait. CI pipelines should pass\n  \
34    --wait-lock 60 for headroom. The link command emits a tracing::info!\n  \
35    diagnostic when the wait exceeds 5 seconds so operators can correlate\n  \
36    cold-start latency with this CLI invocation.\n\n  \
37    --from and --to expect ENTITY names (graph nodes), not memory names.\n  \
38    Use --from-id / --to-id when you only have numeric entity IDs (v1.1.05).\n  \
39    Purely numeric names are rejected so --create-missing cannot spawn ghosts.\n  \
40    Memory names are managed via remember/read/edit/forget; entities are curated via\n  \
41    remember --graph-stdin, created by enrich, or auto-created via --create-missing.")]
42/// Link args.
43pub struct LinkArgs {
44    /// Source ENTITY name (graph node, not memory). Entities are curated via
45    /// `remember --graph-stdin`, created by `enrich`, or auto-created via `--create-missing`.
46    /// Use `graph entities` to list available entity names. Also accepts the alias `--name`.
47    /// Conflicts with `--from-id`.
48    #[arg(
49        long,
50        alias = "name",
51        required_unless_present = "from_id",
52        conflicts_with = "from_id"
53    )]
54    pub from: Option<String>,
55    /// Source entity ID (unambiguous alternative to `--from`). Conflicts with `--from`.
56    #[arg(long, value_name = "ID")]
57    pub from_id: Option<i64>,
58    /// Target ENTITY name (graph node, not memory). See `--from` for sourcing entity names.
59    /// Conflicts with `--to-id`.
60    #[arg(long, required_unless_present = "to_id", conflicts_with = "to_id")]
61    pub to: Option<String>,
62    /// Target entity ID (unambiguous alternative to `--to`). Conflicts with `--to`.
63    #[arg(long, value_name = "ID")]
64    pub to_id: Option<i64>,
65    /// Relation type between entities. Canonical values: applies-to, uses,
66    /// depends-on, causes, fixes, contradicts, supports, follows, related,
67    /// mentions, replaces, tracked-in. Any kebab-case or snake_case string
68    /// is also accepted as a custom relation.
69    #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
70    pub relation: String,
71    /// Relationship weight.
72    #[arg(long)]
73    pub weight: Option<f64>,
74    /// Namespace scope.
75    #[arg(long)]
76    pub namespace: Option<String>,
77    /// Output format.
78    #[arg(long, value_enum, default_value = "json")]
79    pub format: OutputFormat,
80    /// Emit machine-readable JSON on stdout.
81    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
82    pub json: bool,
83    /// Path to the SQLite database file.
84    #[arg(long)]
85    pub db: Option<String>,
86    /// Auto-create entities when they do not exist. Created entities default to
87    /// type `concept` unless `--entity-type` specifies a different type.
88    #[arg(long, default_value_t = false)]
89    pub create_missing: bool,
90    /// Entity type assigned to auto-created entities (only effective with `--create-missing`).
91    #[arg(long, value_enum, default_value = "concept")]
92    pub entity_type: EntityType,
93    /// Reject non-canonical relation types with exit 1.
94    ///
95    /// When set, any relation not in the canonical list causes an immediate error.
96    /// Canonical values: applies-to, uses, depends-on, causes, fixes, contradicts,
97    /// supports, follows, related, mentions, replaces, tracked-in.
98    #[arg(
99        long,
100        default_value_t = false,
101        help = "Reject non-canonical relation types with exit 1"
102    )]
103    pub strict_relations: bool,
104}
105
106#[derive(Serialize)]
107struct LinkResponse {
108    action: String,
109    from: String,
110    to: String,
111    relation: String,
112    weight: f64,
113    namespace: String,
114    /// Total execution time in milliseconds from handler start to serialisation.
115    elapsed_ms: u64,
116    /// Entity names that were auto-created by `--create-missing`.
117    #[serde(skip_serializing_if = "Vec::is_empty")]
118    created_entities: Vec<String>,
119    /// Non-fatal warnings (e.g. non-canonical relation type).
120    #[serde(skip_serializing_if = "Vec::is_empty")]
121    warnings: Vec<String>,
122}
123
124/// Run.
125pub fn run(args: LinkArgs) -> Result<(), AppError> {
126    let inicio = std::time::Instant::now();
127    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
128    let paths = AppPaths::resolve(args.db.as_deref())?;
129
130    crate::storage::connection::ensure_db_ready(&paths)?;
131
132    let mut conn = open_rw(&paths.db)?;
133
134    // v1.1.05 Bug 5: resolve by name OR by explicit --from-id / --to-id.
135    // Name path still validates BEFORE normalization (BUG-13 ALL_CAPS guard)
136    // and rejects purely numeric names so --create-missing cannot spawn ghosts.
137    let (norm_from, source_id_pre, from_is_id) = match (args.from_id, args.from.as_ref()) {
138        (Some(id), _) => {
139            let (name, _) = resolve_entity_name_by_id(&conn, &namespace, id)?;
140            (name, Some(id), true)
141        }
142        (None, Some(from_name)) => {
143            if let Err(msg) = crate::storage::entities::validate_entity_name(from_name) {
144                return Err(AppError::Validation(msg.to_string()));
145            }
146            let norm = crate::parsers::normalize_entity_name(from_name);
147            (norm, None, false)
148        }
149        (None, None) => {
150            return Err(AppError::Validation(
151                "--from or --from-id is required".to_string(),
152            ));
153        }
154    };
155
156    let (norm_to, target_id_pre, to_is_id) = match (args.to_id, args.to.as_ref()) {
157        (Some(id), _) => {
158            let (name, _) = resolve_entity_name_by_id(&conn, &namespace, id)?;
159            (name, Some(id), true)
160        }
161        (None, Some(to_name)) => {
162            if let Err(msg) = crate::storage::entities::validate_entity_name(to_name) {
163                return Err(AppError::Validation(msg.to_string()));
164            }
165            let norm = crate::parsers::normalize_entity_name(to_name);
166            (norm, None, false)
167        }
168        (None, None) => {
169            return Err(AppError::Validation(
170                "--to or --to-id is required".to_string(),
171            ));
172        }
173    };
174
175    tracing::debug!(
176        target: "link",
177        from = %norm_from,
178        to = %norm_to,
179        relation = %args.relation,
180        "creating relationship"
181    );
182
183    if norm_from == norm_to {
184        return Err(AppError::Validation(validation::self_referential_link()));
185    }
186    if let (Some(a), Some(b)) = (source_id_pre, target_id_pre) {
187        if a == b {
188            return Err(AppError::Validation(validation::self_referential_link()));
189        }
190    }
191
192    let weight = args.weight.unwrap_or(DEFAULT_RELATION_WEIGHT);
193    if !(0.0..=1.0).contains(&weight) {
194        return Err(AppError::Validation(validation::invalid_link_weight(
195            weight,
196        )));
197    }
198    if weight >= 0.95 {
199        tracing::warn!(target: "link",
200            weight = weight,
201            "weight >= 0.95 compresses the scoring range; consider using a value below 0.95"
202        );
203    }
204    if weight <= 0.05 {
205        tracing::warn!(target: "link",
206            weight = weight,
207            "weight <= 0.05 may be too weak to influence traversal; consider using a value above 0.05"
208        );
209    }
210
211    let mut warnings: Vec<String> = Vec::with_capacity(2);
212    let is_canonical = crate::parsers::is_canonical_relation(&args.relation);
213    if !is_canonical {
214        if args.strict_relations {
215            return Err(AppError::Validation(validation::non_canonical_relation(
216                &args.relation,
217                &crate::parsers::CANONICAL_RELATIONS.join(", "),
218            )));
219        }
220        warnings.push(format!("non-canonical relation '{}'", args.relation));
221        tracing::warn!(target: "link",
222            relation = %args.relation,
223            "non-canonical relation accepted; consider using a well-known value"
224        );
225    }
226    let relation_str = &args.relation;
227
228    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
229
230    let mut created_entities: Vec<String> = Vec::with_capacity(2);
231
232    if args.entity_type.as_str() == "memory" {
233        tracing::warn!(target: "link",
234            entity_type = "memory",
235            "entity_type 'memory' may conflict with memory table semantics; consider using 'concept' or another type"
236        );
237    }
238
239    // ID path never creates missing entities — IDs must already exist.
240    let source_id = if let Some(id) = source_id_pre {
241        let _ = from_is_id;
242        id
243    } else {
244        match entities::find_entity_id(&tx, &namespace, &norm_from)? {
245            Some(id) => id,
246            None if args.create_missing => {
247                let new_entity = NewEntity {
248                    name: norm_from.clone(),
249                    entity_type: args.entity_type,
250                    description: None,
251                };
252                created_entities.push(norm_from.clone());
253                entities::upsert_entity(&tx, &namespace, &new_entity)?
254            }
255            None => {
256                return Err(AppError::NotFound(errors_msg::entity_not_found(
257                    &norm_from, &namespace,
258                )));
259            }
260        }
261    };
262
263    let target_id = if let Some(id) = target_id_pre {
264        let _ = to_is_id;
265        id
266    } else {
267        match entities::find_entity_id(&tx, &namespace, &norm_to)? {
268            Some(id) => id,
269            None if args.create_missing => {
270                let new_entity = NewEntity {
271                    name: norm_to.clone(),
272                    entity_type: args.entity_type,
273                    description: None,
274                };
275                created_entities.push(norm_to.clone());
276                entities::upsert_entity(&tx, &namespace, &new_entity)?
277            }
278            None => {
279                return Err(AppError::NotFound(errors_msg::entity_not_found(
280                    &norm_to, &namespace,
281                )));
282            }
283        }
284    };
285
286    let (rel_id, was_created) = entities::create_or_fetch_relationship(
287        &tx,
288        &namespace,
289        source_id,
290        target_id,
291        relation_str,
292        weight,
293        None,
294    )?;
295
296    let actual_weight: f64 = tx.query_row(
297        "SELECT weight FROM relationships WHERE id = ?1",
298        params![rel_id],
299        |r| r.get(0),
300    )?;
301
302    if was_created {
303        entities::recalculate_degree(&tx, source_id)?;
304        entities::recalculate_degree(&tx, target_id)?;
305    }
306    tx.commit()?;
307
308    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
309
310    let action = if was_created {
311        "created".to_string()
312    } else {
313        "already_exists".to_string()
314    };
315
316    let response = LinkResponse {
317        action: action.clone(),
318        from: norm_from.clone(),
319        to: norm_to.clone(),
320        relation: relation_str.to_string(),
321        weight: actual_weight,
322        namespace: namespace.clone(),
323        elapsed_ms: inicio.elapsed().as_millis() as u64,
324        created_entities,
325        warnings,
326    };
327
328    match args.format {
329        OutputFormat::Json => output::emit_json(&response)?,
330        OutputFormat::Text | OutputFormat::Markdown => {
331            output::emit_text(&format!(
332                "{}: {} --[{}]--> {} [{}]",
333                action, response.from, response.relation, response.to, response.namespace
334            ));
335        }
336    }
337
338    Ok(())
339}
340
341/// Resolve entity id → (name, namespace) enforcing namespace membership.
342fn resolve_entity_name_by_id(
343    conn: &rusqlite::Connection,
344    namespace: &str,
345    id: i64,
346) -> Result<(String, String), AppError> {
347    let mut stmt = conn
348        .prepare_cached("SELECT name, namespace FROM entities WHERE id = ?1 AND namespace = ?2")?;
349    match stmt.query_row(params![id, namespace], |r| {
350        Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
351    }) {
352        Ok(row) => Ok(row),
353        Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(format!(
354            "entity id={id} not found in namespace '{namespace}'"
355        ))),
356        Err(e) => Err(AppError::Database(e)),
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[derive(clap::Parser)]
365    struct TestCli {
366        #[command(flatten)]
367        args: LinkArgs,
368    }
369
370    #[test]
371    fn clap_accepts_from_id_to_id() {
372        use clap::Parser;
373        let ok = match TestCli::try_parse_from([
374            "t",
375            "--from-id",
376            "1",
377            "--to-id",
378            "2",
379            "--relation",
380            "supports",
381        ]) {
382            Ok(v) => v,
383            Err(e) => panic!("from-id/to-id must parse: {e}"),
384        };
385        assert_eq!(ok.args.from_id, Some(1));
386        assert_eq!(ok.args.to_id, Some(2));
387    }
388
389    #[test]
390    fn clap_rejects_from_combined_with_from_id() {
391        use clap::Parser;
392        match TestCli::try_parse_from([
393            "t",
394            "--from",
395            "a",
396            "--from-id",
397            "1",
398            "--to",
399            "b",
400            "--relation",
401            "supports",
402        ]) {
403            Ok(_) => panic!("expected argument conflict"),
404            Err(err) => assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict),
405        }
406    }
407
408    #[test]
409    fn link_rejects_purely_numeric_name_validation() {
410        // Unit-level: validate_entity_name rejects digit-only names.
411        assert!(crate::storage::entities::validate_entity_name("89975").is_err());
412    }
413
414    #[test]
415    fn link_response_without_redundant_aliases() {
416        // P1-O: source/target fields were removed from the JSON response.
417        let resp = LinkResponse {
418            action: "created".to_string(),
419            from: "entity-a".to_string(),
420            to: "entity-b".to_string(),
421            relation: "uses".to_string(),
422            weight: 1.0,
423            namespace: "default".to_string(),
424            elapsed_ms: 0,
425            created_entities: vec![],
426            warnings: vec![],
427        };
428        let json = serde_json::to_value(&resp).expect("serialization must work");
429        assert_eq!(json["from"], "entity-a");
430        assert_eq!(json["to"], "entity-b");
431        assert!(
432            json.get("source").is_none(),
433            "field 'source' was removed in P1-O"
434        );
435        assert!(
436            json.get("target").is_none(),
437            "field 'target' was removed in P1-O"
438        );
439    }
440
441    #[test]
442    fn link_response_serializes_all_fields() {
443        let resp = LinkResponse {
444            action: "already_exists".to_string(),
445            from: "origin".to_string(),
446            to: "destination".to_string(),
447            relation: "mentions".to_string(),
448            weight: 0.8,
449            namespace: "test".to_string(),
450            elapsed_ms: 5,
451            created_entities: vec![],
452            warnings: vec![],
453        };
454        let json = serde_json::to_value(&resp).expect("serialization must work");
455        assert!(json.get("action").is_some());
456        assert!(json.get("from").is_some());
457        assert!(json.get("to").is_some());
458        assert!(json.get("relation").is_some());
459        assert!(json.get("weight").is_some());
460        assert!(json.get("namespace").is_some());
461        assert!(json.get("elapsed_ms").is_some());
462    }
463
464    #[test]
465    fn link_response_omits_created_entities_when_empty() {
466        let resp = LinkResponse {
467            action: "created".to_string(),
468            from: "a".to_string(),
469            to: "b".to_string(),
470            relation: "uses".to_string(),
471            weight: 1.0,
472            namespace: "global".to_string(),
473            elapsed_ms: 0,
474            created_entities: vec![],
475            warnings: vec![],
476        };
477        let json = serde_json::to_value(&resp).expect("serialization");
478        assert!(
479            json.get("created_entities").is_none(),
480            "empty vec must be omitted"
481        );
482    }
483
484    #[test]
485    fn link_response_includes_created_entities_when_present() {
486        let resp = LinkResponse {
487            action: "created".to_string(),
488            from: "new-a".to_string(),
489            to: "new-b".to_string(),
490            relation: "depends-on".to_string(),
491            weight: 0.5,
492            namespace: "test".to_string(),
493            elapsed_ms: 1,
494            created_entities: vec!["new-a".to_string(), "new-b".to_string()],
495            warnings: vec![],
496        };
497        let json = serde_json::to_value(&resp).expect("serialization");
498        let created = json["created_entities"].as_array().expect("must be array");
499        assert_eq!(created.len(), 2);
500        assert_eq!(created[0], "new-a");
501        assert_eq!(created[1], "new-b");
502    }
503
504    #[test]
505    fn link_response_includes_warnings_when_non_canonical() {
506        let resp = LinkResponse {
507            action: "created".to_string(),
508            from: "a".to_string(),
509            to: "b".to_string(),
510            relation: "implements".to_string(),
511            weight: 0.5,
512            namespace: "global".to_string(),
513            elapsed_ms: 0,
514            created_entities: vec![],
515            warnings: vec!["non-canonical relation 'implements'".to_string()],
516        };
517        let json = serde_json::to_value(&resp).expect("serialization");
518        let w = json["warnings"]
519            .as_array()
520            .expect("warnings must be present");
521        assert_eq!(w.len(), 1);
522        assert!(w[0].as_str().unwrap().contains("implements"));
523    }
524
525    #[test]
526    fn link_response_omits_warnings_when_empty() {
527        let resp = LinkResponse {
528            action: "created".to_string(),
529            from: "a".to_string(),
530            to: "b".to_string(),
531            relation: "uses".to_string(),
532            weight: 0.5,
533            namespace: "global".to_string(),
534            elapsed_ms: 0,
535            created_entities: vec![],
536            warnings: vec![],
537        };
538        let json = serde_json::to_value(&resp).expect("serialization");
539        assert!(
540            json.get("warnings").is_none(),
541            "empty warnings must be omitted"
542        );
543    }
544}