diff --git a/rust/src/cli/config_cmd.rs b/rust/src/cli/config_cmd.rs index 46b9a713a5..62028c831b 100644 --- a/rust/src/cli/config_cmd.rs +++ b/rust/src/cli/config_cmd.rs @@ -915,16 +915,18 @@ pub fn cmd_cache(args: &[String]) { let orphans = crate::core::knowledge::maintenance::prune_orphaned_stores(); let removed = bm25.removed + graph.removed + archive_removed + orphans.removed as u32; + let failed = bm25.failed + graph.failed; let freed = bm25.bytes_freed + graph.bytes_freed + archive_freed + orphans.reclaimed_bytes; println!( - "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {}, archive: {}, orphaned stores: {})", + "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {}, archive: {}, orphaned stores: {}, failed: {})", removed, freed as f64 / 1_048_576.0, bm25.removed, graph.removed, archive_removed, orphans.removed, + failed, ); } _ => { @@ -951,13 +953,50 @@ pub fn cmd_cache(args: &[String]) { pub struct PruneResult { pub scanned: u32, pub removed: u32, + pub failed: u32, pub bytes_freed: u64, } +fn record_removed_file(result: &mut PruneResult, path: &std::path::Path, label: &str) { + let bytes = std::fs::metadata(path).map_or(0, |meta| meta.len()); + + match std::fs::remove_file(path) { + Ok(()) => { + result.bytes_freed += bytes; + result.removed += 1; + println!(" {label}: {}", path.display()); + } + Err(error) => { + result.failed += 1; + eprintln!(" Failed to remove {}: {error}", path.display()); + } + } +} + +fn record_removed_dir( + result: &mut PruneResult, + path: &std::path::Path, + bytes: u64, + message: impl FnOnce() -> String, +) { + match std::fs::remove_dir_all(path) { + Ok(()) => { + result.bytes_freed += bytes; + result.removed += 1; + println!(" {}", message()); + } + Err(error) => { + result.failed += 1; + eprintln!(" Failed to remove {}: {error}", path.display()); + } + } +} + pub fn prune_bm25_caches() -> PruneResult { let mut result = PruneResult { scanned: 0, removed: 0, + failed: 0, bytes_freed: 0, }; @@ -985,12 +1024,7 @@ pub fn prune_bm25_caches() -> PruneResult { ] { let quarantined = dir.join(q_name); if quarantined.exists() { - if let Ok(meta) = std::fs::metadata(&quarantined) { - result.bytes_freed += meta.len(); - } - let _ = std::fs::remove_file(&quarantined); - result.removed += 1; - println!(" Removed quarantined: {}", quarantined.display()); + record_removed_file(&mut result, &quarantined, "Removed quarantined"); } } @@ -1004,14 +1038,7 @@ pub fn prune_bm25_caches() -> PruneResult { if let Ok(meta) = std::fs::metadata(&index_path) && meta.len() > max_bytes { - result.bytes_freed += meta.len(); - let _ = std::fs::remove_file(&index_path); - result.removed += 1; - println!( - " Removed oversized ({:.1} MB): {}", - meta.len() as f64 / 1_048_576.0, - index_path.display() - ); + record_removed_file(&mut result, &index_path, "Removed oversized"); } let marker = dir.join("project_root.txt"); @@ -1019,15 +1046,14 @@ pub fn prune_bm25_caches() -> PruneResult { let root_path = std::path::Path::new(root_str.trim()); if !root_path.exists() { let freed = dir_size(&dir); - result.bytes_freed += freed; - let _ = std::fs::remove_dir_all(&dir); - result.removed += 1; - println!( - " Removed orphaned ({:.1} MB, project gone: {}): {}", - freed as f64 / 1_048_576.0, - root_str.trim(), - dir.display() - ); + record_removed_dir(&mut result, &dir, freed, || { + format!( + "Removed orphaned ({:.1} MB, project gone: {}): {}", + freed as f64 / 1_048_576.0, + root_str.trim(), + dir.display() + ) + }); } } } @@ -1039,6 +1065,7 @@ pub fn prune_graph_caches() -> PruneResult { let mut result = PruneResult { scanned: 0, removed: 0, + failed: 0, bytes_freed: 0, }; @@ -1072,15 +1099,14 @@ pub fn prune_graph_caches() -> PruneResult { && !std::path::Path::new(&root).exists() { let freed = dir_size(&dir); - result.bytes_freed += freed; - let _ = std::fs::remove_dir_all(&dir); - result.removed += 1; - println!( - " Removed orphaned graph ({:.1} MB, project gone: {}): {}", - freed as f64 / 1_048_576.0, - root, - dir.display() - ); + record_removed_dir(&mut result, &dir, freed, || { + format!( + "Removed orphaned graph ({:.1} MB, project gone: {}): {}", + freed as f64 / 1_048_576.0, + root, + dir.display() + ) + }); continue; } @@ -1091,14 +1117,13 @@ pub fn prune_graph_caches() -> PruneResult { && meta.len() > 100 * 1024 * 1024 { let freed = dir_size(&dir); - result.bytes_freed += freed; - let _ = std::fs::remove_dir_all(&dir); - result.removed += 1; - println!( - " Removed oversized graph ({:.1} MB): {}", - freed as f64 / 1_048_576.0, - dir.display() - ); + record_removed_dir(&mut result, &dir, freed, || { + format!( + "Removed oversized graph ({:.1} MB): {}", + freed as f64 / 1_048_576.0, + dir.display() + ) + }); } } diff --git a/rust/src/doctor/fix.rs b/rust/src/doctor/fix.rs index 17fc65260a..a04b8faa44 100644 --- a/rust/src/doctor/fix.rs +++ b/rust/src/doctor/fix.rs @@ -327,9 +327,10 @@ fn build_and_persist_fix_report( }, path: None, note: Some(format!( - "scanned {}, removed {}, freed {:.1} MB", + "scanned {}, removed {}, failed {}, freed {:.1} MB", prune_result.scanned, prune_result.removed, + prune_result.failed, prune_result.bytes_freed as f64 / 1_048_576.0 )), }); diff --git a/rust/tests/index_scoping_scenarios.rs b/rust/tests/index_scoping_scenarios.rs index 910f3b0ae1..9d113bfdbc 100644 --- a/rust/tests/index_scoping_scenarios.rs +++ b/rust/tests/index_scoping_scenarios.rs @@ -189,8 +189,39 @@ fn prune_caches_handles_empty_isolated_dir() { let result = lean_ctx::cli::prune_graph_caches(); assert_eq!(result.removed, 0); + assert_eq!(result.failed, 0); + assert_eq!(result.bytes_freed, 0); let result2 = lean_ctx::cli::prune_bm25_caches(); assert_eq!(result2.removed, 0); + assert_eq!(result2.failed, 0); + assert_eq!(result2.bytes_freed, 0); +} + +#[cfg(unix)] +#[test] +fn prune_bm25_counts_failed_remove_without_freed_bytes() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::new("LEAN_CTX_DATA_DIR", tmp.path().to_str().unwrap()); + let vectors_dir = tmp.path().join("vectors"); + let cache_dir = vectors_dir.join("blocked"); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write( + cache_dir.join("project_root.txt"), + "/definitely/missing/project/root", + ) + .unwrap(); + std::fs::write(cache_dir.join("bm25_index.bin"), b"keep").unwrap(); + std::fs::set_permissions(&vectors_dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let result = lean_ctx::cli::prune_bm25_caches(); + + std::fs::set_permissions(&vectors_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(result.removed, 0); + assert_eq!(result.failed, 1); + assert_eq!(result.bytes_freed, 0); + assert!(cache_dir.exists()); } /// Guards env var modifications so parallel tests don't race.