sqlite_graphrag/commands/
cleanup_orphans.rs1use crate::errors::AppError;
4use crate::output::{self, OutputFormat};
5use crate::paths::AppPaths;
6use crate::storage::connection::open_rw;
7use crate::storage::entities;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n \
12 # Remove orphan entities (no memories, no relationships) from the global namespace\n \
13 sqlite-graphrag cleanup-orphans\n\n \
14 # Preview which entities would be removed without deleting\n \
15 sqlite-graphrag cleanup-orphans --dry-run\n\n \
16 # Cleanup within a specific namespace\n \
17 sqlite-graphrag cleanup-orphans --namespace my-project --yes")]
18pub struct CleanupOrphansArgs {
20 #[arg(long)]
22 pub namespace: Option<String>,
23 #[arg(long)]
25 pub dry_run: bool,
26 #[arg(long)]
28 pub yes: bool,
29 #[arg(long, value_enum, default_value = "json")]
31 pub format: OutputFormat,
32 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
34 pub json: bool,
35 #[arg(long)]
37 pub db: Option<String>,
38}
39
40#[derive(Serialize)]
41struct CleanupResponse {
42 orphan_count: usize,
43 deleted: usize,
44 dry_run: bool,
45 namespace: Option<String>,
46 elapsed_ms: u64,
48}
49
50pub fn run(args: CleanupOrphansArgs) -> Result<(), AppError> {
52 let inicio = std::time::Instant::now();
53 let paths = AppPaths::resolve(args.db.as_deref())?;
54
55 crate::storage::connection::ensure_db_ready(&paths)?;
56
57 let mut conn = open_rw(&paths.db)?;
58
59 let orphan_ids = entities::find_orphan_entity_ids(&conn, args.namespace.as_deref())?;
60 let orphan_count = orphan_ids.len();
61
62 let deleted = if args.dry_run {
63 0
64 } else {
65 if orphan_count > 0 && !args.yes {
66 return Err(AppError::Validation(
67 crate::i18n::validation::refuse_delete_orphans_without_yes(orphan_count),
68 ));
69 }
70 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
71 let removed = entities::delete_entities_by_ids(&tx, &orphan_ids)?;
72 tx.commit()?;
73 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
74 removed
75 };
76
77 let response = CleanupResponse {
78 orphan_count,
79 deleted,
80 dry_run: args.dry_run,
81 namespace: args.namespace.clone(),
82 elapsed_ms: inicio.elapsed().as_millis() as u64,
83 };
84
85 match args.format {
86 OutputFormat::Json => output::emit_json(&response)?,
87 OutputFormat::Text | OutputFormat::Markdown => {
88 let ns = response.namespace.as_deref().unwrap_or("<all>");
89 output::emit_text(&format!(
90 "orphans: {} found, {} deleted (dry_run={}) [{}]",
91 response.orphan_count, response.deleted, response.dry_run, ns
92 ));
93 }
94 }
95
96 Ok(())
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn cleanup_response_serializes_dry_run_true() {
105 let resp = CleanupResponse {
106 orphan_count: 5,
107 deleted: 0,
108 dry_run: true,
109 namespace: Some("global".to_string()),
110 elapsed_ms: 12,
111 };
112 let json = serde_json::to_value(&resp).expect("serialization failed");
113 assert_eq!(json["orphan_count"], 5);
114 assert_eq!(json["deleted"], 0);
115 assert_eq!(json["dry_run"], true);
116 assert_eq!(json["namespace"], "global");
117 assert!(json["elapsed_ms"].is_number());
118 }
119
120 #[test]
121 fn cleanup_response_deleted_zero_when_dry_run() {
122 let resp = CleanupResponse {
123 orphan_count: 10,
124 deleted: 0,
125 dry_run: true,
126 namespace: None,
127 elapsed_ms: 5,
128 };
129 assert_eq!(resp.deleted, 0, "dry_run must keep deleted at 0");
130 assert_eq!(resp.orphan_count, 10);
131 }
132
133 #[test]
134 fn cleanup_response_namespace_none_serializes_null() {
135 let resp = CleanupResponse {
136 orphan_count: 0,
137 deleted: 0,
138 dry_run: false,
139 namespace: None,
140 elapsed_ms: 1,
141 };
142 let json = serde_json::to_value(&resp).expect("serialization failed");
143 assert!(
144 json["namespace"].is_null(),
145 "namespace None must serialize as null"
146 );
147 }
148
149 #[test]
150 fn cleanup_response_deleted_equals_orphan_count_when_executed() {
151 let resp = CleanupResponse {
152 orphan_count: 3,
153 deleted: 3,
154 dry_run: false,
155 namespace: Some("projeto".to_string()),
156 elapsed_ms: 20,
157 };
158 assert_eq!(
159 resp.deleted, resp.orphan_count,
160 "when running without dry_run, deleted must equal orphan_count"
161 );
162 }
163}