vx_cli/commands/
cleanup.rs

1// Cleanup command implementation
2
3use 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    // Scan for different types of cleanup targets
26    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
43    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    // Confirm cleanup unless forced
51    if !force && !confirm_cleanup(&cleanup_plan)? {
52        UI::info("Cleanup cancelled");
53        return Ok(());
54    }
55
56    // Execute cleanup
57    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    // Scan downloads cache
110    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    // Scan versions cache
121    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 now, just scan for old tool versions
174    // TODO: Implement proper orphan detection by checking virtual environment references
175    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    // Clean cache files
389    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    // Clean orphaned tools
401    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    // Clean temp files
413    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}