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