Skip to main content

sqlite_graphrag/commands/
link.rs

1//! Handler for the `link` CLI subcommand.
2
3use crate::cli::RelationKind;
4use crate::constants::DEFAULT_RELATION_WEIGHT;
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 serde::Serialize;
12
13#[derive(clap::Args)]
14#[command(after_long_help = "EXAMPLES:\n  \
15    # Link two existing graph entities (extracted by BERT NER or created via prior `link`)\n  \
16    sqlite-graphrag link --from oauth-flow --to refresh-tokens --relation related\n\n  \
17    # If the entity does not exist, the command fails with exit 4.\n  \
18    # Entity names come from BERT NER extraction during `remember` (see `graph --format json`),\n  \
19    # NOT from memory names. To list current entities run:\n  \
20    sqlite-graphrag graph --format json | jaq '.nodes[].name'\n\n  \
21NOTE:\n  \
22    --from and --to expect ENTITY names (graph nodes), not memory names.\n  \
23    Memory names are managed via remember/read/edit/forget; entities are auto-extracted\n  \
24    by BERT NER from memory bodies (or created implicitly by prior `link` calls).")]
25pub struct LinkArgs {
26    /// Source ENTITY name (graph node, not memory). Entities are extracted by BERT NER during
27    /// `remember` or created implicitly by prior `link` calls. Use `graph --format json` to list
28    /// available entity names.
29    #[arg(long)]
30    pub from: String,
31    /// Target ENTITY name (graph node, not memory). See `--from` for sourcing entity names.
32    #[arg(long)]
33    pub to: String,
34    #[arg(long, value_enum)]
35    pub relation: RelationKind,
36    #[arg(long)]
37    pub weight: Option<f64>,
38    #[arg(long)]
39    pub namespace: Option<String>,
40    #[arg(long, value_enum, default_value = "json")]
41    pub format: OutputFormat,
42    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
43    pub json: bool,
44    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
45    pub db: Option<String>,
46}
47
48#[derive(Serialize)]
49struct LinkResponse {
50    action: String,
51    from: String,
52    to: String,
53    relation: String,
54    weight: f64,
55    namespace: String,
56    /// Total execution time in milliseconds from handler start to serialisation.
57    elapsed_ms: u64,
58}
59
60pub fn run(args: LinkArgs) -> Result<(), AppError> {
61    let inicio = std::time::Instant::now();
62    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
63    let paths = AppPaths::resolve(args.db.as_deref())?;
64
65    if args.from == args.to {
66        return Err(AppError::Validation(validation::self_referential_link()));
67    }
68
69    let weight = args.weight.unwrap_or(DEFAULT_RELATION_WEIGHT);
70    if !(0.0..=1.0).contains(&weight) {
71        return Err(AppError::Validation(validation::invalid_link_weight(
72            weight,
73        )));
74    }
75
76    crate::storage::connection::ensure_db_ready(&paths)?;
77
78    let relation_str = args.relation.as_str();
79
80    let mut conn = open_rw(&paths.db)?;
81
82    let source_id = entities::find_entity_id(&conn, &namespace, &args.from)?
83        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.from, &namespace)))?;
84    let target_id = entities::find_entity_id(&conn, &namespace, &args.to)?
85        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.to, &namespace)))?;
86
87    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
88    let (_rel_id, was_created) = entities::create_or_fetch_relationship(
89        &tx,
90        &namespace,
91        source_id,
92        target_id,
93        relation_str,
94        weight,
95        None,
96    )?;
97
98    if was_created {
99        entities::recalculate_degree(&tx, source_id)?;
100        entities::recalculate_degree(&tx, target_id)?;
101    }
102    tx.commit()?;
103
104    let action = if was_created {
105        "created".to_string()
106    } else {
107        "already_exists".to_string()
108    };
109
110    let response = LinkResponse {
111        action: action.clone(),
112        from: args.from.clone(),
113        to: args.to.clone(),
114        relation: relation_str.to_string(),
115        weight,
116        namespace: namespace.clone(),
117        elapsed_ms: inicio.elapsed().as_millis() as u64,
118    };
119
120    match args.format {
121        OutputFormat::Json => output::emit_json(&response)?,
122        OutputFormat::Text | OutputFormat::Markdown => {
123            output::emit_text(&format!(
124                "{}: {} --[{}]--> {} [{}]",
125                action, response.from, response.relation, response.to, response.namespace
126            ));
127        }
128    }
129
130    Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn link_response_without_redundant_aliases() {
139        // P1-O: source/target fields were removed from the JSON response.
140        let resp = LinkResponse {
141            action: "created".to_string(),
142            from: "entity-a".to_string(),
143            to: "entity-b".to_string(),
144            relation: "uses".to_string(),
145            weight: 1.0,
146            namespace: "default".to_string(),
147            elapsed_ms: 0,
148        };
149        let json = serde_json::to_value(&resp).expect("serialization must work");
150        assert_eq!(json["from"], "entity-a");
151        assert_eq!(json["to"], "entity-b");
152        assert!(
153            json.get("source").is_none(),
154            "field 'source' was removed in P1-O"
155        );
156        assert!(
157            json.get("target").is_none(),
158            "field 'target' was removed in P1-O"
159        );
160    }
161
162    #[test]
163    fn link_response_serializes_all_fields() {
164        let resp = LinkResponse {
165            action: "already_exists".to_string(),
166            from: "origin".to_string(),
167            to: "destination".to_string(),
168            relation: "mentions".to_string(),
169            weight: 0.8,
170            namespace: "test".to_string(),
171            elapsed_ms: 5,
172        };
173        let json = serde_json::to_value(&resp).expect("serialization must work");
174        assert!(json.get("action").is_some());
175        assert!(json.get("from").is_some());
176        assert!(json.get("to").is_some());
177        assert!(json.get("relation").is_some());
178        assert!(json.get("weight").is_some());
179        assert!(json.get("namespace").is_some());
180        assert!(json.get("elapsed_ms").is_some());
181    }
182}