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 {
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)]
73 pub weight: Option<f64>,
74 #[arg(long)]
76 pub namespace: Option<String>,
77 #[arg(long, value_enum, default_value = "json")]
79 pub format: OutputFormat,
80 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
82 pub json: bool,
83 #[arg(long)]
85 pub db: Option<String>,
86 #[arg(long, default_value_t = false)]
89 pub create_missing: bool,
90 #[arg(long, value_enum, default_value = "concept")]
92 pub entity_type: EntityType,
93 #[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 elapsed_ms: u64,
116 #[serde(skip_serializing_if = "Vec::is_empty")]
118 created_entities: Vec<String>,
119 #[serde(skip_serializing_if = "Vec::is_empty")]
121 warnings: Vec<String>,
122}
123
124pub 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 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 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
341fn 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 assert!(crate::storage::entities::validate_entity_name("89975").is_err());
412 }
413
414 #[test]
415 fn link_response_without_redundant_aliases() {
416 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}