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