sqlite_graphrag/commands/
cleanup_orphans.rs1use crate::errors::AppError;
2use crate::i18n::erros;
3use crate::output::{self, OutputFormat};
4use crate::paths::AppPaths;
5use crate::storage::connection::open_rw;
6use crate::storage::entities;
7use serde::Serialize;
8
9#[derive(clap::Args)]
10pub struct CleanupOrphansArgs {
11 #[arg(long)]
12 pub namespace: Option<String>,
13 #[arg(long)]
14 pub dry_run: bool,
15 #[arg(long)]
16 pub yes: bool,
17 #[arg(long, value_enum, default_value = "json")]
18 pub format: OutputFormat,
19 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
20 pub json: bool,
21 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
22 pub db: Option<String>,
23}
24
25#[derive(Serialize)]
26struct CleanupResponse {
27 orphan_count: usize,
28 deleted: usize,
29 dry_run: bool,
30 namespace: Option<String>,
31 elapsed_ms: u64,
33}
34
35pub fn run(args: CleanupOrphansArgs) -> Result<(), AppError> {
36 let inicio = std::time::Instant::now();
37 let paths = AppPaths::resolve(args.db.as_deref())?;
38
39 if !paths.db.exists() {
40 return Err(AppError::NotFound(erros::banco_nao_encontrado(
41 &paths.db.display().to_string(),
42 )));
43 }
44
45 let mut conn = open_rw(&paths.db)?;
46
47 let orphan_ids = entities::find_orphan_entity_ids(&conn, args.namespace.as_deref())?;
48 let orphan_count = orphan_ids.len();
49
50 let deleted = if args.dry_run {
51 0
52 } else {
53 if orphan_count > 0 && !args.yes {
54 output::emit_progress(&format!(
55 "removing {orphan_count} orphan entities (use --yes to skip this notice)"
56 ));
57 }
58 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
59 let removed = entities::delete_entities_by_ids(&tx, &orphan_ids)?;
60 tx.commit()?;
61 removed
62 };
63
64 let response = CleanupResponse {
65 orphan_count,
66 deleted,
67 dry_run: args.dry_run,
68 namespace: args.namespace.clone(),
69 elapsed_ms: inicio.elapsed().as_millis() as u64,
70 };
71
72 match args.format {
73 OutputFormat::Json => output::emit_json(&response)?,
74 OutputFormat::Text | OutputFormat::Markdown => {
75 let ns = response.namespace.as_deref().unwrap_or("<all>");
76 output::emit_text(&format!(
77 "orphans: {} found, {} deleted (dry_run={}) [{}]",
78 response.orphan_count, response.deleted, response.dry_run, ns
79 ));
80 }
81 }
82
83 Ok(())
84}
85
86#[cfg(test)]
87mod testes {
88 use super::*;
89
90 #[test]
91 fn cleanup_response_serializa_dry_run_true() {
92 let resp = CleanupResponse {
93 orphan_count: 5,
94 deleted: 0,
95 dry_run: true,
96 namespace: Some("global".to_string()),
97 elapsed_ms: 12,
98 };
99 let json = serde_json::to_value(&resp).expect("serialização falhou");
100 assert_eq!(json["orphan_count"], 5);
101 assert_eq!(json["deleted"], 0);
102 assert_eq!(json["dry_run"], true);
103 assert_eq!(json["namespace"], "global");
104 assert!(json["elapsed_ms"].is_number());
105 }
106
107 #[test]
108 fn cleanup_response_deleted_zero_quando_dry_run() {
109 let resp = CleanupResponse {
110 orphan_count: 10,
111 deleted: 0,
112 dry_run: true,
113 namespace: None,
114 elapsed_ms: 5,
115 };
116 assert_eq!(resp.deleted, 0, "dry_run deve manter deleted em 0");
117 assert_eq!(resp.orphan_count, 10);
118 }
119
120 #[test]
121 fn cleanup_response_namespace_none_serializa_null() {
122 let resp = CleanupResponse {
123 orphan_count: 0,
124 deleted: 0,
125 dry_run: false,
126 namespace: None,
127 elapsed_ms: 1,
128 };
129 let json = serde_json::to_value(&resp).expect("serialização falhou");
130 assert!(
131 json["namespace"].is_null(),
132 "namespace None deve serializar como null"
133 );
134 }
135
136 #[test]
137 fn cleanup_response_deleted_igual_orphan_count_quando_executado() {
138 let resp = CleanupResponse {
139 orphan_count: 3,
140 deleted: 3,
141 dry_run: false,
142 namespace: Some("projeto".to_string()),
143 elapsed_ms: 20,
144 };
145 assert_eq!(
146 resp.deleted, resp.orphan_count,
147 "ao executar sem dry_run, deleted deve igualar orphan_count"
148 );
149 }
150}