purger_cli/
lib.rs

1use anyhow::Result;
2use clap::{Parser, Subcommand, ValueEnum};
3use std::io::{self, Write};
4use std::path::PathBuf;
5
6use purger_core::{
7    CleanStrategy, ProjectCleaner, ProjectFilter, ProjectScanner, cleaner::CleanConfig,
8    scanner::ScanConfig,
9};
10
11/// 扫描命令的参数配置
12#[derive(Debug)]
13struct ScanCommandArgs {
14    path: PathBuf,
15    max_depth: Option<usize>,
16    target_only: bool,
17    sort_by_size: bool,
18    keep_days: Option<u32>,
19    keep_size: Option<String>,
20    ignore_paths: Vec<PathBuf>,
21    no_parallel: bool,
22    follow_symlinks: bool,
23    include_hidden: bool,
24    no_gitignore: bool,
25}
26
27/// 清理命令的参数配置
28#[derive(Debug)]
29struct CleanCommandArgs {
30    path: PathBuf,
31    max_depth: Option<usize>,
32    strategy: CleanStrategyArg,
33    dry_run: bool,
34    keep_days: Option<u32>,
35    keep_size: Option<String>,
36    ignore_paths: Vec<PathBuf>,
37    no_parallel: bool,
38    follow_symlinks: bool,
39    include_hidden: bool,
40    no_gitignore: bool,
41    yes: bool,
42    keep_executable: bool,
43    executable_backup_dir: Option<PathBuf>,
44    timeout: u64,
45}
46
47/// 扫描配置创建参数
48#[derive(Debug)]
49struct ScanConfigArgs {
50    max_depth: Option<usize>,
51    keep_days: Option<u32>,
52    keep_size: Option<String>,
53    ignore_paths: Vec<PathBuf>,
54    no_parallel: bool,
55    follow_symlinks: bool,
56    include_hidden: bool,
57    no_gitignore: bool,
58}
59
60#[derive(Parser)]
61#[command(name = "purger")]
62#[command(about = "A tool for cleaning Rust project build directories")]
63#[command(version)]
64pub struct Cli {
65    #[command(subcommand)]
66    pub command: Commands,
67
68    /// Enable verbose logging
69    #[arg(short, long, global = true)]
70    pub verbose: bool,
71
72    /// Enable debug logging
73    #[arg(short, long, global = true)]
74    pub debug: bool,
75}
76
77#[derive(Subcommand)]
78pub enum Commands {
79    /// Scan for Rust projects in a directory
80    Scan {
81        /// Directory to scan
82        #[arg(default_value = ".")]
83        path: PathBuf,
84
85        /// Maximum depth to scan
86        #[arg(short, long)]
87        max_depth: Option<usize>,
88
89        /// Show only projects with target directories
90        #[arg(short, long)]
91        target_only: bool,
92
93        /// Sort by size (largest first)
94        #[arg(short = 'S', long)]
95        sort_by_size: bool,
96
97        /// Keep projects compiled in the last N days
98        #[arg(short = 'k', long)]
99        keep_days: Option<u32>,
100
101        /// Keep projects with target size smaller than this
102        #[arg(short = 's', long)]
103        keep_size: Option<String>,
104
105        /// Paths to ignore (can be specified multiple times)
106        #[arg(short = 'i', long = "ignore", action = clap::ArgAction::Append)]
107        ignore_paths: Vec<PathBuf>,
108
109        /// Disable parallel scanning
110        #[arg(long)]
111        no_parallel: bool,
112
113        /// Follow symlinks
114        #[arg(long)]
115        follow_symlinks: bool,
116
117        /// Don't ignore hidden files/directories
118        #[arg(long)]
119        include_hidden: bool,
120
121        /// Don't respect .gitignore files
122        #[arg(long)]
123        no_gitignore: bool,
124    },
125    /// Clean Rust projects
126    Clean {
127        /// Directory to scan and clean
128        #[arg(default_value = ".")]
129        path: PathBuf,
130
131        /// Maximum depth to scan
132        #[arg(short, long)]
133        max_depth: Option<usize>,
134
135        /// Clean strategy
136        #[arg(short = 'S', long, value_enum, default_value = "cargo-clean")]
137        strategy: CleanStrategyArg,
138
139        /// Dry run - show what would be cleaned without actually cleaning
140        #[arg(short = 'n', long)]
141        dry_run: bool,
142
143        /// Keep projects compiled in the last N days
144        #[arg(short = 'k', long)]
145        keep_days: Option<u32>,
146
147        /// Keep projects with target size smaller than this
148        #[arg(short = 's', long)]
149        keep_size: Option<String>,
150
151        /// Paths to ignore (can be specified multiple times)
152        #[arg(short = 'i', long = "ignore", action = clap::ArgAction::Append)]
153        ignore_paths: Vec<PathBuf>,
154
155        /// Disable parallel processing
156        #[arg(long)]
157        no_parallel: bool,
158
159        /// Follow symlinks
160        #[arg(long)]
161        follow_symlinks: bool,
162
163        /// Don't ignore hidden files/directories
164        #[arg(long)]
165        include_hidden: bool,
166
167        /// Don't respect .gitignore files
168        #[arg(long)]
169        no_gitignore: bool,
170
171        /// Skip confirmation prompt
172        #[arg(short = 'y', long)]
173        yes: bool,
174
175        /// Keep executable files (backup before cleaning)
176        #[arg(long)]
177        keep_executable: bool,
178
179        /// Directory to backup executables to
180        #[arg(long)]
181        executable_backup_dir: Option<PathBuf>,
182
183        /// Timeout for each project clean operation (seconds)
184        #[arg(long, default_value = "30")]
185        timeout: u64,
186    },
187}
188
189#[derive(Debug, Clone, ValueEnum)]
190pub enum CleanStrategyArg {
191    /// Use cargo clean command
192    #[value(name = "cargo-clean")]
193    CargoClean,
194    /// Directly delete target directories
195    #[value(name = "direct-delete")]
196    DirectDelete,
197}
198
199impl From<CleanStrategyArg> for CleanStrategy {
200    fn from(arg: CleanStrategyArg) -> Self {
201        match arg {
202            CleanStrategyArg::CargoClean => CleanStrategy::CargoClean,
203            CleanStrategyArg::DirectDelete => CleanStrategy::DirectDelete,
204        }
205    }
206}
207
208pub fn run_cli() -> Result<()> {
209    let cli = Cli::parse();
210
211    // 设置日志级别
212    let log_level = if cli.debug {
213        "debug"
214    } else if cli.verbose {
215        "info"
216    } else {
217        "warn"
218    };
219
220    tracing_subscriber::fmt()
221        .with_env_filter(format!("purger={log_level}"))
222        .init();
223
224    match cli.command {
225        Commands::Scan {
226            path,
227            max_depth,
228            target_only,
229            sort_by_size,
230            keep_days,
231            keep_size,
232            ignore_paths,
233            no_parallel,
234            follow_symlinks,
235            include_hidden,
236            no_gitignore,
237        } => handle_scan_command(ScanCommandArgs {
238            path,
239            max_depth,
240            target_only,
241            sort_by_size,
242            keep_days,
243            keep_size,
244            ignore_paths,
245            no_parallel,
246            follow_symlinks,
247            include_hidden,
248            no_gitignore,
249        }),
250        Commands::Clean {
251            path,
252            max_depth,
253            strategy,
254            dry_run,
255            keep_days,
256            keep_size,
257            ignore_paths,
258            no_parallel,
259            follow_symlinks,
260            include_hidden,
261            no_gitignore,
262            yes,
263            keep_executable,
264            executable_backup_dir,
265            timeout,
266        } => handle_clean_command(CleanCommandArgs {
267            path,
268            max_depth,
269            strategy,
270            dry_run,
271            keep_days,
272            keep_size,
273            ignore_paths,
274            no_parallel,
275            follow_symlinks,
276            include_hidden,
277            no_gitignore,
278            yes,
279            keep_executable,
280            executable_backup_dir,
281            timeout,
282        }),
283    }
284}
285
286fn handle_scan_command(args: ScanCommandArgs) -> Result<()> {
287    let config = create_scan_config(ScanConfigArgs {
288        max_depth: args.max_depth,
289        keep_days: args.keep_days,
290        keep_size: args.keep_size,
291        ignore_paths: args.ignore_paths,
292        no_parallel: args.no_parallel,
293        follow_symlinks: args.follow_symlinks,
294        include_hidden: args.include_hidden,
295        no_gitignore: args.no_gitignore,
296    })?;
297
298    let scanner = ProjectScanner::new(config.clone());
299    let mut projects = scanner.scan(&args.path)?;
300
301    if args.target_only {
302        projects = ProjectScanner::filter_with_target(projects);
303    }
304
305    if args.sort_by_size {
306        projects = ProjectScanner::sort_by_size(projects);
307    }
308
309    // 应用过滤器
310    if config.keep_days.is_some() || config.keep_size.is_some() || !config.ignore_paths.is_empty() {
311        let filter = ProjectFilter::new(config);
312        projects = filter.filter_projects(projects);
313    }
314
315    display_projects(&projects, &args.path)?;
316    Ok(())
317}
318
319fn handle_clean_command(args: CleanCommandArgs) -> Result<()> {
320    let scan_config = create_scan_config(ScanConfigArgs {
321        max_depth: args.max_depth,
322        keep_days: args.keep_days,
323        keep_size: args.keep_size.clone(),
324        ignore_paths: args.ignore_paths,
325        no_parallel: args.no_parallel,
326        follow_symlinks: args.follow_symlinks,
327        include_hidden: args.include_hidden,
328        no_gitignore: args.no_gitignore,
329    })?;
330
331    let scanner = ProjectScanner::new(scan_config.clone());
332    let mut projects = scanner.scan(&args.path)?;
333
334    // 只保留有target目录的项目
335    projects = ProjectScanner::filter_with_target(projects);
336
337    // 应用过滤器
338    if scan_config.keep_days.is_some()
339        || scan_config.keep_size.is_some()
340        || !scan_config.ignore_paths.is_empty()
341    {
342        let filter = ProjectFilter::new(scan_config);
343        projects = filter.filter_projects(projects);
344    }
345
346    if projects.is_empty() {
347        println!("No projects found to clean.");
348        return Ok(());
349    }
350
351    // 显示将要清理的项目
352    println!("Found {} projects to clean:", projects.len());
353    display_projects(&projects, &args.path)?;
354
355    // 确认清理
356    if !args.yes && !args.dry_run && !confirm_clean(&projects)? {
357        println!("Cleaning cancelled.");
358        return Ok(());
359    }
360
361    // 执行清理
362    let clean_config = CleanConfig {
363        strategy: args.strategy.into(),
364        dry_run: args.dry_run,
365        parallel: !args.no_parallel,
366        timeout_seconds: args.timeout,
367        keep_executable: args.keep_executable,
368        executable_backup_dir: args.executable_backup_dir,
369    };
370
371    let cleaner = ProjectCleaner::new(clean_config);
372    let result = cleaner.clean_projects(&projects);
373
374    // 显示结果
375    display_clean_result(&result);
376
377    Ok(())
378}
379
380fn create_scan_config(args: ScanConfigArgs) -> Result<ScanConfig> {
381    let keep_size_bytes = if let Some(size_str) = args.keep_size {
382        Some(purger_core::ProjectFilter::parse_size_string(&size_str)?)
383    } else {
384        None
385    };
386
387    Ok(ScanConfig {
388        max_depth: args.max_depth,
389        parallel: !args.no_parallel,
390        follow_links: args.follow_symlinks,
391        ignore_hidden: !args.include_hidden,
392        respect_gitignore: !args.no_gitignore,
393        keep_days: args.keep_days,
394        keep_size: keep_size_bytes,
395        ignore_paths: args.ignore_paths,
396    })
397}
398
399fn display_projects(
400    projects: &[purger_core::RustProject],
401    base_path: &std::path::Path,
402) -> Result<()> {
403    if projects.is_empty() {
404        println!("No projects found.");
405        return Ok(());
406    }
407
408    let total_size: u64 = projects.iter().map(|p| p.target_size).sum();
409
410    println!("\nFound {} projects:", projects.len());
411    println!("{:<40} {:<15} {:<20}", "Project", "Size", "Path");
412    println!("{}", "-".repeat(75));
413
414    for project in projects {
415        let relative_path = project.relative_path(base_path);
416        println!(
417            "{:<40} {:<15} {:<20}",
418            project.name,
419            project.formatted_size(),
420            relative_path.display()
421        );
422    }
423
424    println!("{}", "-".repeat(75));
425    println!("Total size: {}", purger_core::format_bytes(total_size));
426
427    Ok(())
428}
429
430fn confirm_clean(projects: &[purger_core::RustProject]) -> Result<bool> {
431    let total_size: u64 = projects.iter().map(|p| p.target_size).sum();
432
433    print!(
434        "\nThis will clean {} projects and free up {}. Continue? [y/N]: ",
435        projects.len(),
436        purger_core::format_bytes(total_size)
437    );
438
439    io::stdout().flush()?;
440
441    let mut input = String::new();
442    io::stdin().read_line(&mut input)?;
443
444    Ok(input.trim().to_lowercase() == "y" || input.trim().to_lowercase() == "yes")
445}
446
447fn display_clean_result(result: &purger_core::CleanResult) {
448    println!("\nCleaning completed!");
449    println!("Projects cleaned: {}", result.cleaned_projects);
450    println!("Size freed: {}", result.format_size());
451
452    if !result.failed_projects.is_empty() {
453        println!(
454            "\nFailed to clean {} projects:",
455            result.failed_projects.len()
456        );
457        for project in &result.failed_projects {
458            println!("  - {project}");
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use clap::Parser;
467    use std::path::PathBuf;
468    use tempfile::TempDir;
469
470    #[test]
471    fn test_cli_parse_scan_command() {
472        let args = vec![
473            "purger",
474            "scan",
475            "/tmp",
476            "--max-depth",
477            "3",
478            "--target-only",
479        ];
480        let cli = Cli::try_parse_from(args).unwrap();
481
482        match cli.command {
483            Commands::Scan {
484                path,
485                max_depth,
486                target_only,
487                ..
488            } => {
489                assert_eq!(path, PathBuf::from("/tmp"));
490                assert_eq!(max_depth, Some(3));
491                assert!(target_only);
492            }
493            _ => panic!("Expected Scan command"),
494        }
495    }
496
497    #[test]
498    fn test_cli_parse_clean_command() {
499        let args = vec![
500            "purger",
501            "clean",
502            "/tmp",
503            "--strategy",
504            "direct-delete",
505            "--dry-run",
506            "--yes",
507        ];
508        let cli = Cli::try_parse_from(args).unwrap();
509
510        match cli.command {
511            Commands::Clean {
512                path,
513                strategy,
514                dry_run,
515                yes,
516                ..
517            } => {
518                assert_eq!(path, PathBuf::from("/tmp"));
519                assert!(matches!(strategy, CleanStrategyArg::DirectDelete));
520                assert!(dry_run);
521                assert!(yes);
522            }
523            _ => panic!("Expected Clean command"),
524        }
525    }
526
527    #[test]
528    fn test_create_scan_config() {
529        let config = create_scan_config(ScanConfigArgs {
530            max_depth: Some(5),
531            keep_days: Some(7),
532            keep_size: Some("1MB".to_string()),
533            ignore_paths: vec![PathBuf::from("/ignore")],
534            no_parallel: false,
535            follow_symlinks: true,
536            include_hidden: false,
537            no_gitignore: true,
538        })
539        .unwrap();
540
541        assert_eq!(config.max_depth, Some(5));
542        assert_eq!(config.keep_days, Some(7));
543        assert_eq!(config.keep_size, Some(1_000_000));
544        assert_eq!(config.ignore_paths, vec![PathBuf::from("/ignore")]);
545        assert!(config.parallel);
546        assert!(config.follow_links);
547        assert!(config.ignore_hidden);
548        assert!(!config.respect_gitignore);
549    }
550
551    #[test]
552    fn test_clean_strategy_conversion() {
553        assert!(matches!(
554            CleanStrategy::from(CleanStrategyArg::CargoClean),
555            CleanStrategy::CargoClean
556        ));
557        assert!(matches!(
558            CleanStrategy::from(CleanStrategyArg::DirectDelete),
559            CleanStrategy::DirectDelete
560        ));
561    }
562
563    #[test]
564    fn test_display_projects_empty() {
565        let projects = vec![];
566        let temp_dir = TempDir::new().unwrap();
567        let result = display_projects(&projects, temp_dir.path());
568        assert!(result.is_ok());
569    }
570
571    #[test]
572    fn test_confirm_clean_calculation() {
573        use purger_core::RustProject;
574        use std::time::SystemTime;
575
576        let projects = [
577            RustProject {
578                path: PathBuf::from("/test1"),
579                name: "test1".to_string(),
580                target_size: 1000,
581                last_modified: SystemTime::now(),
582                is_workspace: false,
583                has_target: true,
584            },
585            RustProject {
586                path: PathBuf::from("/test2"),
587                name: "test2".to_string(),
588                target_size: 2000,
589                last_modified: SystemTime::now(),
590                is_workspace: false,
591                has_target: true,
592            },
593        ];
594
595        // 这个测试只验证函数不会panic,实际的用户输入测试比较复杂
596        // 在实际应用中,可能需要mock stdin
597        let total_size: u64 = projects.iter().map(|p| p.target_size).sum();
598        assert_eq!(total_size, 3000);
599    }
600}