Skip to main content

sqlite_graphrag/commands/
cleanup_orphans.rs

1//! Handler for the `cleanup-orphans` CLI subcommand.
2
3use 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")]
18/// Cleanup orphans args.
19pub struct CleanupOrphansArgs {
20    /// Namespace scope.
21    #[arg(long)]
22    pub namespace: Option<String>,
23    /// Show what would happen without making changes.
24    #[arg(long)]
25    pub dry_run: bool,
26    /// Yes.
27    #[arg(long)]
28    pub yes: bool,
29    /// Output format.
30    #[arg(long, value_enum, default_value = "json")]
31    pub format: OutputFormat,
32    /// Emit machine-readable JSON on stdout.
33    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
34    pub json: bool,
35    /// Path to the SQLite database file.
36    #[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    /// Total execution time in milliseconds from handler start to serialisation.
47    elapsed_ms: u64,
48}
49
50/// Run.
51pub 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}