1use crate::ui::UI;
4use std::fs;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8use vx_core::{Result, VxEnvironment, VxError};
9
10pub async fn handle(
11 dry_run: bool,
12 cache_only: bool,
13 orphaned_only: bool,
14 force: bool,
15 older_than: Option<u32>,
16 verbose: bool,
17) -> Result<()> {
18 let spinner = UI::new_spinner("Scanning cleanup targets...");
19
20 let env = VxEnvironment::new()?;
21 let vx_home = VxEnvironment::get_vx_home()?;
22
23 let mut cleanup_plan = CleanupPlan::new();
24
25 if !orphaned_only {
27 scan_cache_files(&mut cleanup_plan, &vx_home, older_than, verbose).await?;
28 scan_temp_files(&mut cleanup_plan, &vx_home, older_than, verbose).await?;
29 }
30
31 if !cache_only {
32 scan_orphaned_tools(&mut cleanup_plan, &env, older_than, verbose).await?;
33 }
34
35 spinner.finish_and_clear();
36
37 if cleanup_plan.is_empty() {
38 UI::success("โ
No cleanup needed - everything is clean!");
39 return Ok(());
40 }
41
42 display_cleanup_plan(&cleanup_plan, dry_run);
44
45 if dry_run {
46 UI::info("Run 'vx cleanup' to execute cleanup operations");
47 return Ok(());
48 }
49
50 if !force && !confirm_cleanup(&cleanup_plan)? {
52 UI::info("Cleanup cancelled");
53 return Ok(());
54 }
55
56 execute_cleanup(&cleanup_plan, verbose).await
58}
59
60#[derive(Debug, Default)]
61struct CleanupPlan {
62 cache_files: Vec<CleanupItem>,
63 orphaned_tools: Vec<CleanupItem>,
64 temp_files: Vec<CleanupItem>,
65}
66
67#[derive(Debug)]
68struct CleanupItem {
69 path: PathBuf,
70 size: u64,
71 description: String,
72}
73
74impl CleanupPlan {
75 fn new() -> Self {
76 Self::default()
77 }
78
79 fn is_empty(&self) -> bool {
80 self.cache_files.is_empty() && self.orphaned_tools.is_empty() && self.temp_files.is_empty()
81 }
82
83 fn total_size(&self) -> u64 {
84 self.cache_files.iter().map(|i| i.size).sum::<u64>()
85 + self.orphaned_tools.iter().map(|i| i.size).sum::<u64>()
86 + self.temp_files.iter().map(|i| i.size).sum::<u64>()
87 }
88
89 fn total_items(&self) -> usize {
90 self.cache_files.len() + self.orphaned_tools.len() + self.temp_files.len()
91 }
92}
93
94async fn scan_cache_files(
95 plan: &mut CleanupPlan,
96 vx_home: &Path,
97 older_than: Option<u32>,
98 verbose: bool,
99) -> Result<()> {
100 let cache_dir = vx_home.join("cache");
101 if !cache_dir.exists() {
102 return Ok(());
103 }
104
105 if verbose {
106 UI::info("Scanning cache files...");
107 }
108
109 let downloads_dir = cache_dir.join("downloads");
111 if downloads_dir.exists() {
112 scan_directory(
113 &mut plan.cache_files,
114 &downloads_dir,
115 "Download cache",
116 older_than,
117 )?;
118 }
119
120 let versions_dir = cache_dir.join("versions");
122 if versions_dir.exists() {
123 scan_directory(
124 &mut plan.cache_files,
125 &versions_dir,
126 "Version cache",
127 older_than,
128 )?;
129 }
130
131 Ok(())
132}
133
134async fn scan_temp_files(
135 plan: &mut CleanupPlan,
136 vx_home: &Path,
137 older_than: Option<u32>,
138 verbose: bool,
139) -> Result<()> {
140 let temp_dir = vx_home.join("tmp");
141 if !temp_dir.exists() {
142 return Ok(());
143 }
144
145 if verbose {
146 UI::info("Scanning temporary files...");
147 }
148
149 scan_directory(
150 &mut plan.temp_files,
151 &temp_dir,
152 "Temporary files",
153 older_than,
154 )?;
155 Ok(())
156}
157
158async fn scan_orphaned_tools(
159 plan: &mut CleanupPlan,
160 env: &VxEnvironment,
161 older_than: Option<u32>,
162 verbose: bool,
163) -> Result<()> {
164 if verbose {
165 UI::info("Scanning for orphaned tool versions...");
166 }
167
168 let tools_dir = env.get_base_install_dir();
169 if !tools_dir.exists() {
170 return Ok(());
171 }
172
173 for entry in fs::read_dir(&tools_dir).map_err(|e| VxError::Other {
176 message: format!("Failed to read tools directory: {}", e),
177 })? {
178 let entry = entry.map_err(|e| VxError::Other {
179 message: format!("Failed to read directory entry: {}", e),
180 })?;
181
182 if entry
183 .file_type()
184 .map_err(|e| VxError::Other {
185 message: format!("Failed to get file type: {}", e),
186 })?
187 .is_dir()
188 {
189 let tool_dir = entry.path();
190 scan_tool_versions(plan, &tool_dir, older_than)?;
191 }
192 }
193
194 Ok(())
195}
196
197fn scan_tool_versions(
198 plan: &mut CleanupPlan,
199 tool_dir: &Path,
200 older_than: Option<u32>,
201) -> Result<()> {
202 let tool_name = tool_dir
203 .file_name()
204 .and_then(|n| n.to_str())
205 .unwrap_or("unknown");
206
207 for entry in fs::read_dir(tool_dir).map_err(|e| VxError::Other {
208 message: format!("Failed to read tool directory: {}", e),
209 })? {
210 let entry = entry.map_err(|e| VxError::Other {
211 message: format!("Failed to read directory entry: {}", e),
212 })?;
213
214 if entry
215 .file_type()
216 .map_err(|e| VxError::Other {
217 message: format!("Failed to get file type: {}", e),
218 })?
219 .is_dir()
220 {
221 let version_dir = entry.path();
222 let version = version_dir
223 .file_name()
224 .and_then(|n| n.to_str())
225 .unwrap_or("unknown");
226
227 if should_cleanup_path(&version_dir, older_than)? {
228 let size = calculate_directory_size(&version_dir)?;
229 plan.orphaned_tools.push(CleanupItem {
230 path: version_dir.clone(),
231 size,
232 description: format!("{}@{} (potentially orphaned)", tool_name, version),
233 });
234 }
235 }
236 }
237
238 Ok(())
239}
240
241fn scan_directory(
242 items: &mut Vec<CleanupItem>,
243 dir: &Path,
244 description: &str,
245 older_than: Option<u32>,
246) -> Result<()> {
247 if should_cleanup_path(dir, older_than)? {
248 let size = calculate_directory_size(dir)?;
249 items.push(CleanupItem {
250 path: dir.to_path_buf(),
251 size,
252 description: description.to_string(),
253 });
254 }
255
256 Ok(())
257}
258
259fn should_cleanup_path(path: &Path, older_than: Option<u32>) -> Result<bool> {
260 if let Some(days) = older_than {
261 let metadata = fs::metadata(path).map_err(|e| VxError::Other {
262 message: format!("Failed to get metadata for {}: {}", path.display(), e),
263 })?;
264
265 let modified = metadata.modified().map_err(|e| VxError::Other {
266 message: format!("Failed to get modification time: {}", e),
267 })?;
268
269 let now = SystemTime::now();
270 let age = now.duration_since(modified).map_err(|e| VxError::Other {
271 message: format!("Failed to calculate file age: {}", e),
272 })?;
273
274 let max_age = std::time::Duration::from_secs(days as u64 * 24 * 60 * 60);
275 Ok(age > max_age)
276 } else {
277 Ok(true)
278 }
279}
280
281fn calculate_directory_size(dir: &Path) -> Result<u64> {
282 let mut total_size = 0;
283
284 if dir.is_file() {
285 return Ok(fs::metadata(dir)
286 .map_err(|e| VxError::Other {
287 message: format!("Failed to get file metadata: {}", e),
288 })?
289 .len());
290 }
291
292 for entry in walkdir::WalkDir::new(dir) {
293 let entry = entry.map_err(|e| VxError::Other {
294 message: format!("Failed to walk directory: {}", e),
295 })?;
296
297 if entry.file_type().is_file() {
298 total_size += entry
299 .metadata()
300 .map_err(|e| VxError::Other {
301 message: format!("Failed to get metadata: {}", e),
302 })?
303 .len();
304 }
305 }
306
307 Ok(total_size)
308}
309
310fn display_cleanup_plan(plan: &CleanupPlan, dry_run: bool) {
311 if dry_run {
312 UI::info("๐ Cleanup plan preview:");
313 } else {
314 UI::info(&format!(
315 "Will clean {} items ({}):",
316 plan.total_items(),
317 format_size(plan.total_size())
318 ));
319 }
320
321 println!();
322
323 if !plan.cache_files.is_empty() {
324 println!("Cache files:");
325 for item in &plan.cache_files {
326 println!(" ๐ {} ({})", item.description, format_size(item.size));
327 if dry_run {
328 println!(" {}", item.path.display());
329 }
330 }
331 println!();
332 }
333
334 if !plan.orphaned_tools.is_empty() {
335 println!("Orphaned tool versions:");
336 for item in &plan.orphaned_tools {
337 println!(" ๐๏ธ {} ({})", item.description, format_size(item.size));
338 if dry_run {
339 println!(" {}", item.path.display());
340 }
341 }
342 println!();
343 }
344
345 if !plan.temp_files.is_empty() {
346 println!("Temporary files:");
347 for item in &plan.temp_files {
348 println!(" ๐๏ธ {} ({})", item.description, format_size(item.size));
349 if dry_run {
350 println!(" {}", item.path.display());
351 }
352 }
353 println!();
354 }
355
356 println!(
357 "Total space to be freed: {}",
358 format_size(plan.total_size())
359 );
360}
361
362fn confirm_cleanup(plan: &CleanupPlan) -> Result<bool> {
363 let items_text = if plan.total_items() == 1 {
364 "1 item".to_string()
365 } else {
366 format!("{} items", plan.total_items())
367 };
368
369 print!(
370 "Confirm cleanup of {} ({})? [y/N]: ",
371 format_size(plan.total_size()),
372 items_text
373 );
374 io::stdout().flush().unwrap();
375
376 let mut input = String::new();
377 io::stdin().read_line(&mut input).unwrap();
378
379 Ok(input.trim().to_lowercase().starts_with('y'))
380}
381
382async fn execute_cleanup(plan: &CleanupPlan, verbose: bool) -> Result<()> {
383 UI::info("๐งน Executing cleanup...");
384
385 let mut cleaned_size = 0u64;
386 let mut cleaned_items = 0usize;
387
388 for item in &plan.cache_files {
390 if verbose {
391 UI::info(&format!("Cleaning {}", item.description));
392 }
393
394 if remove_path(&item.path)? {
395 cleaned_size += item.size;
396 cleaned_items += 1;
397 }
398 }
399
400 for item in &plan.orphaned_tools {
402 if verbose {
403 UI::info(&format!("Cleaning {}", item.description));
404 }
405
406 if remove_path(&item.path)? {
407 cleaned_size += item.size;
408 cleaned_items += 1;
409 }
410 }
411
412 for item in &plan.temp_files {
414 if verbose {
415 UI::info(&format!("Cleaning {}", item.description));
416 }
417
418 if remove_path(&item.path)? {
419 cleaned_size += item.size;
420 cleaned_items += 1;
421 }
422 }
423
424 UI::success(&format!(
425 "โ
Cleanup completed! Freed {} from {} items",
426 format_size(cleaned_size),
427 cleaned_items
428 ));
429
430 Ok(())
431}
432
433fn remove_path(path: &Path) -> Result<bool> {
434 if !path.exists() {
435 return Ok(false);
436 }
437
438 if path.is_dir() {
439 fs::remove_dir_all(path).map_err(|e| VxError::Other {
440 message: format!("Failed to remove directory {}: {}", path.display(), e),
441 })?;
442 } else {
443 fs::remove_file(path).map_err(|e| VxError::Other {
444 message: format!("Failed to remove file {}: {}", path.display(), e),
445 })?;
446 }
447
448 Ok(true)
449}
450
451fn format_size(bytes: u64) -> String {
452 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
453 let mut size = bytes as f64;
454 let mut unit_index = 0;
455
456 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
457 size /= 1024.0;
458 unit_index += 1;
459 }
460
461 if unit_index == 0 {
462 format!("{} {}", bytes, UNITS[unit_index])
463 } else {
464 format!("{:.1} {}", size, UNITS[unit_index])
465 }
466}