1use 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.")]
42pub struct LinkArgs {
43 #[arg(
48 long,
49 alias = "name",
50 required_unless_present = "from_id",
51 conflicts_with = "from_id"
52 )]
53 pub from: Option<String>,
54 #[arg(long, value_name = "ID")]
56 pub from_id: Option<i64>,
57 #[arg(long, required_unless_present = "to_id", conflicts_with = "to_id")]
60 pub to: Option<String>,
61 #[arg(long, value_name = "ID")]
63 pub to_id: Option<i64>,
64 #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
69 pub relation: String,
70 #[arg(long)]
71 pub weight: Option<f64>,
72 #[arg(long)]
73 pub namespace: Option<String>,
74 #[arg(long, value_enum, default_value = "json")]
75 pub format: OutputFormat,
76 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
77 pub json: bool,
78 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
79 pub db: Option<String>,
80 #[arg(long, default_value_t = false)]
83 pub create_missing: bool,
84 #[arg(long, value_enum, default_value = "concept")]
86 pub entity_type: EntityType,
87 #[arg(
93 long,
94 default_value_t = false,
95 help = "Reject non-canonical relation types with exit 1"
96 )]
97 pub strict_relations: bool,
98}
99
100#[derive(Serialize)]
101struct LinkResponse {
102 action: String,
103 from: String,
104 to: String,
105 relation: String,
106 weight: f64,
107 namespace: String,
108 elapsed_ms: u64,
110 #[serde(skip_serializing_if = "Vec::is_empty")]
112 created_entities: Vec<String>,
113 #[serde(skip_serializing_if = "Vec::is_empty")]
115 warnings: Vec<String>,
116}
117
118pub fn run(args: LinkArgs) -> Result<(), AppError> {
119 let inicio = std::time::Instant::now();
120 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
121 let paths = AppPaths::resolve(args.db.as_deref())?;
122
123 crate::storage::connection::ensure_db_ready(&paths)?;
124
125 let mut conn = open_rw(&paths.db)?;
126
127 let (norm_from, source_id_pre, from_is_id) = match (args.from_id, args.from.as_ref()) {
131 (Some(id), _) => {
132 let (name, _) = resolve_entity_name_by_id(&conn, &namespace, id)?;
133 (name, Some(id), true)
134 }
135 (None, Some(from_name)) => {
136 if let Err(msg) = crate::storage::entities::validate_entity_name(from_name) {
137 return Err(AppError::Validation(msg.to_string()));
138 }
139 let norm = crate::parsers::normalize_entity_name(from_name);
140 (norm, None, false)
141 }
142 (None, None) => {
143 return Err(AppError::Validation(
144 "--from or --from-id is required".to_string(),
145 ));
146 }
147 };
148
149 let (norm_to, target_id_pre, to_is_id) = match (args.to_id, args.to.as_ref()) {
150 (Some(id), _) => {
151 let (name, _) = resolve_entity_name_by_id(&conn, &namespace, id)?;
152 (name, Some(id), true)
153 }
154 (None, Some(to_name)) => {
155 if let Err(msg) = crate::storage::entities::validate_entity_name(to_name) {
156 return Err(AppError::Validation(msg.to_string()));
157 }
158 let norm = crate::parsers::normalize_entity_name(to_name);
159 (norm, None, false)
160 }
161 (None, None) => {
162 return Err(AppError::Validation(
163 "--to or --to-id is required".to_string(),
164 ));
165 }
166 };
167
168 tracing::debug!(
169 target: "link",
170 from = %norm_from,
171 to = %norm_to,
172 relation = %args.relation,
173 "creating relationship"
174 );
175
176 if norm_from == norm_to {
177 return Err(AppError::Validation(validation::self_referential_link()));
178 }
179 if let (Some(a), Some(b)) = (source_id_pre, target_id_pre) {
180 if a == b {
181 return Err(AppError::Validation(validation::self_referential_link()));
182 }
183 }
184
185 let weight = args.weight.unwrap_or(DEFAULT_RELATION_WEIGHT);
186 if !(0.0..=1.0).contains(&weight) {
187 return Err(AppError::Validation(validation::invalid_link_weight(
188 weight,
189 )));
190 }
191 if weight >= 0.95 {
192 tracing::warn!(target: "link",
193 weight = weight,
194 "weight >= 0.95 compresses the scoring range; consider using a value below 0.95"
195 );
196 }
197 if weight <= 0.05 {
198 tracing::warn!(target: "link",
199 weight = weight,
200 "weight <= 0.05 may be too weak to influence traversal; consider using a value above 0.05"
201 );
202 }
203
204 let mut warnings: Vec<String> = Vec::with_capacity(2);
205 let is_canonical = crate::parsers::is_canonical_relation(&args.relation);
206 if !is_canonical {
207 if args.strict_relations {
208 return Err(AppError::Validation(format!(
209 "non-canonical relation '{}': use --strict-relations=false or choose from: {}",
210 args.relation,
211 crate::parsers::CANONICAL_RELATIONS.join(", ")
212 )));
213 }
214 warnings.push(format!("non-canonical relation '{}'", args.relation));
215 tracing::warn!(target: "link",
216 relation = %args.relation,
217 "non-canonical relation accepted; consider using a well-known value"
218 );
219 }
220 let relation_str = &args.relation;
221
222 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
223
224 let mut created_entities: Vec<String> = Vec::with_capacity(2);
225
226 if args.entity_type.as_str() == "memory" {
227 tracing::warn!(target: "link",
228 entity_type = "memory",
229 "entity_type 'memory' may conflict with memory table semantics; consider using 'concept' or another type"
230 );
231 }
232
233 let source_id = if let Some(id) = source_id_pre {
235 let _ = from_is_id;
236 id
237 } else {
238 match entities::find_entity_id(&tx, &namespace, &norm_from)? {
239 Some(id) => id,
240 None if args.create_missing => {
241 let new_entity = NewEntity {
242 name: norm_from.clone(),
243 entity_type: args.entity_type,
244 description: None,
245 };
246 created_entities.push(norm_from.clone());
247 entities::upsert_entity(&tx, &namespace, &new_entity)?
248 }
249 None => {
250 return Err(AppError::NotFound(errors_msg::entity_not_found(
251 &norm_from, &namespace,
252 )));
253 }
254 }
255 };
256
257 let target_id = if let Some(id) = target_id_pre {
258 let _ = to_is_id;
259 id
260 } else {
261 match entities::find_entity_id(&tx, &namespace, &norm_to)? {
262 Some(id) => id,
263 None if args.create_missing => {
264 let new_entity = NewEntity {
265 name: norm_to.clone(),
266 entity_type: args.entity_type,
267 description: None,
268 };
269 created_entities.push(norm_to.clone());
270 entities::upsert_entity(&tx, &namespace, &new_entity)?
271 }
272 None => {
273 return Err(AppError::NotFound(errors_msg::entity_not_found(
274 &norm_to, &namespace,
275 )));
276 }
277 }
278 };
279
280 let (rel_id, was_created) = entities::create_or_fetch_relationship(
281 &tx,
282 &namespace,
283 source_id,
284 target_id,
285 relation_str,
286 weight,
287 None,
288 )?;
289
290 let actual_weight: f64 = tx.query_row(
291 "SELECT weight FROM relationships WHERE id = ?1",
292 params![rel_id],
293 |r| r.get(0),
294 )?;
295
296 if was_created {
297 entities::recalculate_degree(&tx, source_id)?;
298 entities::recalculate_degree(&tx, target_id)?;
299 }
300 tx.commit()?;
301
302 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
303
304 let action = if was_created {
305 "created".to_string()
306 } else {
307 "already_exists".to_string()
308 };
309
310 let response = LinkResponse {
311 action: action.clone(),
312 from: norm_from.clone(),
313 to: norm_to.clone(),
314 relation: relation_str.to_string(),
315 weight: actual_weight,
316 namespace: namespace.clone(),
317 elapsed_ms: inicio.elapsed().as_millis() as u64,
318 created_entities,
319 warnings,
320 };
321
322 match args.format {
323 OutputFormat::Json => output::emit_json(&response)?,
324 OutputFormat::Text | OutputFormat::Markdown => {
325 output::emit_text(&format!(
326 "{}: {} --[{}]--> {} [{}]",
327 action, response.from, response.relation, response.to, response.namespace
328 ));
329 }
330 }
331
332 Ok(())
333}
334
335fn resolve_entity_name_by_id(
337 conn: &rusqlite::Connection,
338 namespace: &str,
339 id: i64,
340) -> Result<(String, String), AppError> {
341 let mut stmt = conn
342 .prepare_cached("SELECT name, namespace FROM entities WHERE id = ?1 AND namespace = ?2")?;
343 match stmt.query_row(params![id, namespace], |r| {
344 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
345 }) {
346 Ok(row) => Ok(row),
347 Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(format!(
348 "entity id={id} not found in namespace '{namespace}'"
349 ))),
350 Err(e) => Err(AppError::Database(e)),
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[derive(clap::Parser)]
359 struct TestCli {
360 #[command(flatten)]
361 args: LinkArgs,
362 }
363
364 #[test]
365 fn clap_accepts_from_id_to_id() {
366 use clap::Parser;
367 let ok = match TestCli::try_parse_from([
368 "t",
369 "--from-id",
370 "1",
371 "--to-id",
372 "2",
373 "--relation",
374 "supports",
375 ]) {
376 Ok(v) => v,
377 Err(e) => panic!("from-id/to-id must parse: {e}"),
378 };
379 assert_eq!(ok.args.from_id, Some(1));
380 assert_eq!(ok.args.to_id, Some(2));
381 }
382
383 #[test]
384 fn clap_rejects_from_combined_with_from_id() {
385 use clap::Parser;
386 match TestCli::try_parse_from([
387 "t",
388 "--from",
389 "a",
390 "--from-id",
391 "1",
392 "--to",
393 "b",
394 "--relation",
395 "supports",
396 ]) {
397 Ok(_) => panic!("expected argument conflict"),
398 Err(err) => assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict),
399 }
400 }
401
402 #[test]
403 fn link_rejects_purely_numeric_name_validation() {
404 assert!(crate::storage::entities::validate_entity_name("89975").is_err());
406 }
407
408 #[test]
409 fn link_response_without_redundant_aliases() {
410 let resp = LinkResponse {
412 action: "created".to_string(),
413 from: "entity-a".to_string(),
414 to: "entity-b".to_string(),
415 relation: "uses".to_string(),
416 weight: 1.0,
417 namespace: "default".to_string(),
418 elapsed_ms: 0,
419 created_entities: vec![],
420 warnings: vec![],
421 };
422 let json = serde_json::to_value(&resp).expect("serialization must work");
423 assert_eq!(json["from"], "entity-a");
424 assert_eq!(json["to"], "entity-b");
425 assert!(
426 json.get("source").is_none(),
427 "field 'source' was removed in P1-O"
428 );
429 assert!(
430 json.get("target").is_none(),
431 "field 'target' was removed in P1-O"
432 );
433 }
434
435 #[test]
436 fn link_response_serializes_all_fields() {
437 let resp = LinkResponse {
438 action: "already_exists".to_string(),
439 from: "origin".to_string(),
440 to: "destination".to_string(),
441 relation: "mentions".to_string(),
442 weight: 0.8,
443 namespace: "test".to_string(),
444 elapsed_ms: 5,
445 created_entities: vec![],
446 warnings: vec![],
447 };
448 let json = serde_json::to_value(&resp).expect("serialization must work");
449 assert!(json.get("action").is_some());
450 assert!(json.get("from").is_some());
451 assert!(json.get("to").is_some());
452 assert!(json.get("relation").is_some());
453 assert!(json.get("weight").is_some());
454 assert!(json.get("namespace").is_some());
455 assert!(json.get("elapsed_ms").is_some());
456 }
457
458 #[test]
459 fn link_response_omits_created_entities_when_empty() {
460 let resp = LinkResponse {
461 action: "created".to_string(),
462 from: "a".to_string(),
463 to: "b".to_string(),
464 relation: "uses".to_string(),
465 weight: 1.0,
466 namespace: "global".to_string(),
467 elapsed_ms: 0,
468 created_entities: vec![],
469 warnings: vec![],
470 };
471 let json = serde_json::to_value(&resp).expect("serialization");
472 assert!(
473 json.get("created_entities").is_none(),
474 "empty vec must be omitted"
475 );
476 }
477
478 #[test]
479 fn link_response_includes_created_entities_when_present() {
480 let resp = LinkResponse {
481 action: "created".to_string(),
482 from: "new-a".to_string(),
483 to: "new-b".to_string(),
484 relation: "depends-on".to_string(),
485 weight: 0.5,
486 namespace: "test".to_string(),
487 elapsed_ms: 1,
488 created_entities: vec!["new-a".to_string(), "new-b".to_string()],
489 warnings: vec![],
490 };
491 let json = serde_json::to_value(&resp).expect("serialization");
492 let created = json["created_entities"].as_array().expect("must be array");
493 assert_eq!(created.len(), 2);
494 assert_eq!(created[0], "new-a");
495 assert_eq!(created[1], "new-b");
496 }
497
498 #[test]
499 fn link_response_includes_warnings_when_non_canonical() {
500 let resp = LinkResponse {
501 action: "created".to_string(),
502 from: "a".to_string(),
503 to: "b".to_string(),
504 relation: "implements".to_string(),
505 weight: 0.5,
506 namespace: "global".to_string(),
507 elapsed_ms: 0,
508 created_entities: vec![],
509 warnings: vec!["non-canonical relation 'implements'".to_string()],
510 };
511 let json = serde_json::to_value(&resp).expect("serialization");
512 let w = json["warnings"]
513 .as_array()
514 .expect("warnings must be present");
515 assert_eq!(w.len(), 1);
516 assert!(w[0].as_str().unwrap().contains("implements"));
517 }
518
519 #[test]
520 fn link_response_omits_warnings_when_empty() {
521 let resp = LinkResponse {
522 action: "created".to_string(),
523 from: "a".to_string(),
524 to: "b".to_string(),
525 relation: "uses".to_string(),
526 weight: 0.5,
527 namespace: "global".to_string(),
528 elapsed_ms: 0,
529 created_entities: vec![],
530 warnings: vec![],
531 };
532 let json = serde_json::to_value(&resp).expect("serialization");
533 assert!(
534 json.get("warnings").is_none(),
535 "empty warnings must be omitted"
536 );
537 }
538}