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 (extracted by GLiNER NER during `remember`)\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 Memory names are managed via remember/read/edit/forget; entities are auto-extracted\n \
39 by GLiNER NER from memory bodies or auto-created via --create-missing.")]
40pub struct LinkArgs {
41 #[arg(long, alias = "name")]
45 pub from: String,
46 #[arg(long)]
48 pub to: String,
49 #[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
54 pub relation: String,
55 #[arg(long)]
56 pub weight: Option<f64>,
57 #[arg(long)]
58 pub namespace: Option<String>,
59 #[arg(long, value_enum, default_value = "json")]
60 pub format: OutputFormat,
61 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
62 pub json: bool,
63 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
64 pub db: Option<String>,
65 #[arg(long, default_value_t = false)]
68 pub create_missing: bool,
69 #[arg(long, value_enum, default_value = "concept")]
71 pub entity_type: EntityType,
72 #[arg(
78 long,
79 default_value_t = false,
80 help = "Reject non-canonical relation types with exit 1"
81 )]
82 pub strict_relations: bool,
83}
84
85#[derive(Serialize)]
86struct LinkResponse {
87 action: String,
88 from: String,
89 to: String,
90 relation: String,
91 weight: f64,
92 namespace: String,
93 elapsed_ms: u64,
95 #[serde(skip_serializing_if = "Vec::is_empty")]
97 created_entities: Vec<String>,
98 #[serde(skip_serializing_if = "Vec::is_empty")]
100 warnings: Vec<String>,
101}
102
103pub fn run(args: LinkArgs) -> Result<(), AppError> {
104 let inicio = std::time::Instant::now();
105 tracing::debug!(target: "link", from = %args.from, to = %args.to, relation = %args.relation, "creating relationship");
106 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
107 let paths = AppPaths::resolve(args.db.as_deref())?;
108
109 if let Err(msg) = crate::storage::entities::validate_entity_name(&args.from) {
115 return Err(AppError::Validation(msg.to_string()));
116 }
117 if let Err(msg) = crate::storage::entities::validate_entity_name(&args.to) {
118 return Err(AppError::Validation(msg.to_string()));
119 }
120
121 let norm_from = crate::parsers::normalize_entity_name(&args.from);
122 let norm_to = crate::parsers::normalize_entity_name(&args.to);
123
124 if norm_from == norm_to {
125 return Err(AppError::Validation(validation::self_referential_link()));
126 }
127
128 let weight = args.weight.unwrap_or(DEFAULT_RELATION_WEIGHT);
129 if !(0.0..=1.0).contains(&weight) {
130 return Err(AppError::Validation(validation::invalid_link_weight(
131 weight,
132 )));
133 }
134 if weight >= 0.95 {
135 tracing::warn!(target: "link",
136 weight = weight,
137 "weight >= 0.95 compresses the scoring range; consider using a value below 0.95"
138 );
139 }
140 if weight <= 0.05 {
141 tracing::warn!(target: "link",
142 weight = weight,
143 "weight <= 0.05 may be too weak to influence traversal; consider using a value above 0.05"
144 );
145 }
146
147 crate::storage::connection::ensure_db_ready(&paths)?;
148
149 let mut warnings: Vec<String> = Vec::with_capacity(2);
150 let is_canonical = crate::parsers::is_canonical_relation(&args.relation);
151 if !is_canonical {
152 if args.strict_relations {
153 return Err(AppError::Validation(format!(
154 "non-canonical relation '{}': use --strict-relations=false or choose from: {}",
155 args.relation,
156 crate::parsers::CANONICAL_RELATIONS.join(", ")
157 )));
158 }
159 warnings.push(format!("non-canonical relation '{}'", args.relation));
160 tracing::warn!(target: "link",
161 relation = %args.relation,
162 "non-canonical relation accepted; consider using a well-known value"
163 );
164 }
165 let relation_str = &args.relation;
166
167 let mut conn = open_rw(&paths.db)?;
168 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
169
170 let mut created_entities: Vec<String> = Vec::with_capacity(2);
171
172 if args.entity_type.as_str() == "memory" {
173 tracing::warn!(target: "link",
174 entity_type = "memory",
175 "entity_type 'memory' may conflict with memory table semantics; consider using 'concept' or another type"
176 );
177 }
178
179 let source_id = match entities::find_entity_id(&tx, &namespace, &norm_from)? {
180 Some(id) => id,
181 None if args.create_missing => {
182 let new_entity = NewEntity {
183 name: norm_from.clone(),
184 entity_type: args.entity_type,
185 description: None,
186 };
187 created_entities.push(norm_from.clone());
188 entities::upsert_entity(&tx, &namespace, &new_entity)?
189 }
190 None => {
191 return Err(AppError::NotFound(errors_msg::entity_not_found(
192 &norm_from, &namespace,
193 )));
194 }
195 };
196
197 let target_id = match entities::find_entity_id(&tx, &namespace, &norm_to)? {
198 Some(id) => id,
199 None if args.create_missing => {
200 let new_entity = NewEntity {
201 name: norm_to.clone(),
202 entity_type: args.entity_type,
203 description: None,
204 };
205 created_entities.push(norm_to.clone());
206 entities::upsert_entity(&tx, &namespace, &new_entity)?
207 }
208 None => {
209 return Err(AppError::NotFound(errors_msg::entity_not_found(
210 &norm_to, &namespace,
211 )));
212 }
213 };
214
215 let (rel_id, was_created) = entities::create_or_fetch_relationship(
216 &tx,
217 &namespace,
218 source_id,
219 target_id,
220 relation_str,
221 weight,
222 None,
223 )?;
224
225 let actual_weight: f64 = tx.query_row(
226 "SELECT weight FROM relationships WHERE id = ?1",
227 params![rel_id],
228 |r| r.get(0),
229 )?;
230
231 if was_created {
232 entities::recalculate_degree(&tx, source_id)?;
233 entities::recalculate_degree(&tx, target_id)?;
234 }
235 tx.commit()?;
236
237 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
238
239 let action = if was_created {
240 "created".to_string()
241 } else {
242 "already_exists".to_string()
243 };
244
245 let response = LinkResponse {
246 action: action.clone(),
247 from: norm_from.clone(),
248 to: norm_to.clone(),
249 relation: relation_str.to_string(),
250 weight: actual_weight,
251 namespace: namespace.clone(),
252 elapsed_ms: inicio.elapsed().as_millis() as u64,
253 created_entities,
254 warnings,
255 };
256
257 match args.format {
258 OutputFormat::Json => output::emit_json(&response)?,
259 OutputFormat::Text | OutputFormat::Markdown => {
260 output::emit_text(&format!(
261 "{}: {} --[{}]--> {} [{}]",
262 action, response.from, response.relation, response.to, response.namespace
263 ));
264 }
265 }
266
267 Ok(())
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn link_response_without_redundant_aliases() {
276 let resp = LinkResponse {
278 action: "created".to_string(),
279 from: "entity-a".to_string(),
280 to: "entity-b".to_string(),
281 relation: "uses".to_string(),
282 weight: 1.0,
283 namespace: "default".to_string(),
284 elapsed_ms: 0,
285 created_entities: vec![],
286 warnings: vec![],
287 };
288 let json = serde_json::to_value(&resp).expect("serialization must work");
289 assert_eq!(json["from"], "entity-a");
290 assert_eq!(json["to"], "entity-b");
291 assert!(
292 json.get("source").is_none(),
293 "field 'source' was removed in P1-O"
294 );
295 assert!(
296 json.get("target").is_none(),
297 "field 'target' was removed in P1-O"
298 );
299 }
300
301 #[test]
302 fn link_response_serializes_all_fields() {
303 let resp = LinkResponse {
304 action: "already_exists".to_string(),
305 from: "origin".to_string(),
306 to: "destination".to_string(),
307 relation: "mentions".to_string(),
308 weight: 0.8,
309 namespace: "test".to_string(),
310 elapsed_ms: 5,
311 created_entities: vec![],
312 warnings: vec![],
313 };
314 let json = serde_json::to_value(&resp).expect("serialization must work");
315 assert!(json.get("action").is_some());
316 assert!(json.get("from").is_some());
317 assert!(json.get("to").is_some());
318 assert!(json.get("relation").is_some());
319 assert!(json.get("weight").is_some());
320 assert!(json.get("namespace").is_some());
321 assert!(json.get("elapsed_ms").is_some());
322 }
323
324 #[test]
325 fn link_response_omits_created_entities_when_empty() {
326 let resp = LinkResponse {
327 action: "created".to_string(),
328 from: "a".to_string(),
329 to: "b".to_string(),
330 relation: "uses".to_string(),
331 weight: 1.0,
332 namespace: "global".to_string(),
333 elapsed_ms: 0,
334 created_entities: vec![],
335 warnings: vec![],
336 };
337 let json = serde_json::to_value(&resp).expect("serialization");
338 assert!(
339 json.get("created_entities").is_none(),
340 "empty vec must be omitted"
341 );
342 }
343
344 #[test]
345 fn link_response_includes_created_entities_when_present() {
346 let resp = LinkResponse {
347 action: "created".to_string(),
348 from: "new-a".to_string(),
349 to: "new-b".to_string(),
350 relation: "depends-on".to_string(),
351 weight: 0.5,
352 namespace: "test".to_string(),
353 elapsed_ms: 1,
354 created_entities: vec!["new-a".to_string(), "new-b".to_string()],
355 warnings: vec![],
356 };
357 let json = serde_json::to_value(&resp).expect("serialization");
358 let created = json["created_entities"].as_array().expect("must be array");
359 assert_eq!(created.len(), 2);
360 assert_eq!(created[0], "new-a");
361 assert_eq!(created[1], "new-b");
362 }
363
364 #[test]
365 fn link_response_includes_warnings_when_non_canonical() {
366 let resp = LinkResponse {
367 action: "created".to_string(),
368 from: "a".to_string(),
369 to: "b".to_string(),
370 relation: "implements".to_string(),
371 weight: 0.5,
372 namespace: "global".to_string(),
373 elapsed_ms: 0,
374 created_entities: vec![],
375 warnings: vec!["non-canonical relation 'implements'".to_string()],
376 };
377 let json = serde_json::to_value(&resp).expect("serialization");
378 let w = json["warnings"]
379 .as_array()
380 .expect("warnings must be present");
381 assert_eq!(w.len(), 1);
382 assert!(w[0].as_str().unwrap().contains("implements"));
383 }
384
385 #[test]
386 fn link_response_omits_warnings_when_empty() {
387 let resp = LinkResponse {
388 action: "created".to_string(),
389 from: "a".to_string(),
390 to: "b".to_string(),
391 relation: "uses".to_string(),
392 weight: 0.5,
393 namespace: "global".to_string(),
394 elapsed_ms: 0,
395 created_entities: vec![],
396 warnings: vec![],
397 };
398 let json = serde_json::to_value(&resp).expect("serialization");
399 assert!(
400 json.get("warnings").is_none(),
401 "empty warnings must be omitted"
402 );
403 }
404}