Skip to main content

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