sqlite_graphrag/commands/
unlink.rs1use 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 #[arg(long, alias = "source")]
16 pub from: String,
17 #[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 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}