Skip to main content

sqlite_graphrag/commands/
unlink.rs

1//! Handler for the `unlink` CLI subcommand.
2
3use crate::cli::RelationKind;
4use crate::errors::AppError;
5use crate::i18n::errors_msg;
6use crate::output::{self, OutputFormat};
7use crate::paths::AppPaths;
8use crate::storage::connection::open_rw;
9use crate::storage::entities;
10use serde::Serialize;
11
12#[derive(clap::Args)]
13#[command(after_long_help = "EXAMPLES:\n  \
14    # Remove a relationship between two existing graph entities\n  \
15    sqlite-graphrag unlink --from oauth-flow --to refresh-tokens --relation related\n\n  \
16    # If either entity or the relationship does not exist, the command exits with code 4.\n\n  \
17NOTE:\n  \
18    --from and --to expect ENTITY names (graph nodes), not memory names.\n  \
19    To inspect current entities and relationships, run: sqlite-graphrag graph --format json")]
20pub struct UnlinkArgs {
21    /// Source ENTITY name (graph node, not memory). Also accepts the alias `--source`.
22    /// To list current entities run `graph --format json | jaq '.nodes[].name'`.
23    #[arg(long, alias = "source")]
24    pub from: String,
25    /// Target ENTITY name (graph node, not memory). Also accepts the alias `--target`.
26    #[arg(long, alias = "target")]
27    pub to: String,
28    #[arg(long, value_enum)]
29    pub relation: RelationKind,
30    #[arg(long)]
31    pub namespace: Option<String>,
32    #[arg(long, value_enum, default_value = "json")]
33    pub format: OutputFormat,
34    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
35    pub json: bool,
36    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
37    pub db: Option<String>,
38}
39
40#[derive(Serialize)]
41struct UnlinkResponse {
42    action: String,
43    relationship_id: i64,
44    from_name: String,
45    to_name: String,
46    relation: String,
47    namespace: String,
48    /// Total execution time in milliseconds from handler start to serialisation.
49    elapsed_ms: u64,
50}
51
52pub fn run(args: UnlinkArgs) -> Result<(), AppError> {
53    let inicio = std::time::Instant::now();
54    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
55    let paths = AppPaths::resolve(args.db.as_deref())?;
56
57    if !paths.db.exists() {
58        return Err(AppError::NotFound(errors_msg::database_not_found(
59            &paths.db.display().to_string(),
60        )));
61    }
62
63    let relation_str = args.relation.as_str();
64
65    let mut conn = open_rw(&paths.db)?;
66
67    let source_id = entities::find_entity_id(&conn, &namespace, &args.from)?
68        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.from, &namespace)))?;
69    let target_id = entities::find_entity_id(&conn, &namespace, &args.to)?
70        .ok_or_else(|| AppError::NotFound(errors_msg::entity_not_found(&args.to, &namespace)))?;
71
72    let rel = entities::find_relationship(&conn, source_id, target_id, relation_str)?.ok_or_else(
73        || {
74            AppError::NotFound(errors_msg::relationship_not_found(
75                &args.from,
76                relation_str,
77                &args.to,
78                &namespace,
79            ))
80        },
81    )?;
82
83    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
84    entities::delete_relationship_by_id(&tx, rel.id)?;
85    entities::recalculate_degree(&tx, source_id)?;
86    entities::recalculate_degree(&tx, target_id)?;
87    tx.commit()?;
88
89    let response = UnlinkResponse {
90        action: "deleted".to_string(),
91        relationship_id: rel.id,
92        from_name: args.from.clone(),
93        to_name: args.to.clone(),
94        relation: relation_str.to_string(),
95        namespace: namespace.clone(),
96        elapsed_ms: inicio.elapsed().as_millis() as u64,
97    };
98
99    match args.format {
100        OutputFormat::Json => output::emit_json(&response)?,
101        OutputFormat::Text | OutputFormat::Markdown => {
102            output::emit_text(&format!(
103                "deleted: {} --[{}]--> {} [{}]",
104                response.from_name, response.relation, response.to_name, response.namespace
105            ));
106        }
107    }
108
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::cli::RelationKind;
116
117    #[test]
118    fn unlink_response_serializa_todos_campos() {
119        let resp = UnlinkResponse {
120            action: "deleted".to_string(),
121            relationship_id: 99,
122            from_name: "entidade-a".to_string(),
123            to_name: "entidade-b".to_string(),
124            relation: "uses".to_string(),
125            namespace: "global".to_string(),
126            elapsed_ms: 5,
127        };
128        let json = serde_json::to_value(&resp).expect("serialização falhou");
129        assert_eq!(json["action"], "deleted");
130        assert_eq!(json["relationship_id"], 99i64);
131        assert_eq!(json["from_name"], "entidade-a");
132        assert_eq!(json["to_name"], "entidade-b");
133        assert_eq!(json["relation"], "uses");
134        assert_eq!(json["namespace"], "global");
135        assert_eq!(json["elapsed_ms"], 5u64);
136    }
137
138    #[test]
139    fn unlink_args_relation_kind_as_str_correto() {
140        assert_eq!(RelationKind::Uses.as_str(), "uses");
141        assert_eq!(RelationKind::DependsOn.as_str(), "depends_on");
142        assert_eq!(RelationKind::AppliesTo.as_str(), "applies_to");
143        assert_eq!(RelationKind::Causes.as_str(), "causes");
144        assert_eq!(RelationKind::Fixes.as_str(), "fixes");
145    }
146
147    #[test]
148    fn unlink_response_action_deve_ser_deleted() {
149        let resp = UnlinkResponse {
150            action: "deleted".to_string(),
151            relationship_id: 1,
152            from_name: "a".to_string(),
153            to_name: "b".to_string(),
154            relation: "related".to_string(),
155            namespace: "global".to_string(),
156            elapsed_ms: 0,
157        };
158        let json = serde_json::to_value(&resp).expect("serialização falhou");
159        assert_eq!(
160            json["action"], "deleted",
161            "ação de unlink deve sempre ser 'deleted'"
162        );
163    }
164
165    #[test]
166    fn unlink_response_relationship_id_positivo() {
167        let resp = UnlinkResponse {
168            action: "deleted".to_string(),
169            relationship_id: 42,
170            from_name: "origem".to_string(),
171            to_name: "destino".to_string(),
172            relation: "supports".to_string(),
173            namespace: "projeto".to_string(),
174            elapsed_ms: 3,
175        };
176        let json = serde_json::to_value(&resp).expect("serialização falhou");
177        assert!(
178            json["relationship_id"].as_i64().unwrap() > 0,
179            "relationship_id deve ser positivo após unlink"
180        );
181    }
182}