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    if !paths.db.exists() {
77        return Err(AppError::NotFound(errors_msg::database_not_found(
78            &paths.db.display().to_string(),
79        )));
80    }
81
82    let relation_str = args.relation.as_str();
83
84    let mut conn = open_rw(&paths.db)?;
85
86    let source_id = entities::find_entity_id(&conn, &namespace, &args.from)?
87        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.from, &namespace)))?;
88    let target_id = entities::find_entity_id(&conn, &namespace, &args.to)?
89        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.to, &namespace)))?;
90
91    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
92    let (_rel_id, was_created) = entities::create_or_fetch_relationship(
93        &tx,
94        &namespace,
95        source_id,
96        target_id,
97        relation_str,
98        weight,
99        None,
100    )?;
101
102    if was_created {
103        entities::recalculate_degree(&tx, source_id)?;
104        entities::recalculate_degree(&tx, target_id)?;
105    }
106    tx.commit()?;
107
108    let action = if was_created {
109        "created".to_string()
110    } else {
111        "already_exists".to_string()
112    };
113
114    let response = LinkResponse {
115        action: action.clone(),
116        from: args.from.clone(),
117        to: args.to.clone(),
118        relation: relation_str.to_string(),
119        weight,
120        namespace: namespace.clone(),
121        elapsed_ms: inicio.elapsed().as_millis() as u64,
122    };
123
124    match args.format {
125        OutputFormat::Json => output::emit_json(&response)?,
126        OutputFormat::Text | OutputFormat::Markdown => {
127            output::emit_text(&format!(
128                "{}: {} --[{}]--> {} [{}]",
129                action, response.from, response.relation, response.to, response.namespace
130            ));
131        }
132    }
133
134    Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn link_response_sem_aliases_redundantes() {
143        // P1-O: campos source/target foram removidos do JSON de resposta.
144        let resp = LinkResponse {
145            action: "created".to_string(),
146            from: "entidade-a".to_string(),
147            to: "entidade-b".to_string(),
148            relation: "uses".to_string(),
149            weight: 1.0,
150            namespace: "default".to_string(),
151            elapsed_ms: 0,
152        };
153        let json = serde_json::to_value(&resp).expect("serialização deve funcionar");
154        assert_eq!(json["from"], "entidade-a");
155        assert_eq!(json["to"], "entidade-b");
156        assert!(
157            json.get("source").is_none(),
158            "campo 'source' foi removido em P1-O"
159        );
160        assert!(
161            json.get("target").is_none(),
162            "campo 'target' foi removido em P1-O"
163        );
164    }
165
166    #[test]
167    fn link_response_serializa_todos_campos() {
168        let resp = LinkResponse {
169            action: "already_exists".to_string(),
170            from: "origem".to_string(),
171            to: "destino".to_string(),
172            relation: "mentions".to_string(),
173            weight: 0.8,
174            namespace: "teste".to_string(),
175            elapsed_ms: 5,
176        };
177        let json = serde_json::to_value(&resp).expect("serialização deve funcionar");
178        assert!(json.get("action").is_some());
179        assert!(json.get("from").is_some());
180        assert!(json.get("to").is_some());
181        assert!(json.get("relation").is_some());
182        assert!(json.get("weight").is_some());
183        assert!(json.get("namespace").is_some());
184        assert!(json.get("elapsed_ms").is_some());
185    }
186}