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#[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#[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#[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 #[arg(short, long, global = true)]
71 pub verbose: bool,
72
73 #[arg(short, long, global = true)]
75 pub debug: bool,
76}
77
78#[derive(Subcommand)]
79pub enum Commands {
80 Scan {
82 #[arg(default_value = ".")]
84 path: PathBuf,
85
86 #[arg(short, long)]
88 max_depth: Option<usize>,
89
90 #[arg(short, long)]
92 target_only: bool,
93
94 #[arg(short = 'S', long)]
96 sort_by_size: bool,
97
98 #[arg(short = 'k', long)]
100 keep_days: Option<u32>,
101
102 #[arg(short = 's', long)]
104 keep_size: Option<String>,
105
106 #[arg(short = 'i', long = "ignore", action = clap::ArgAction::Append)]
108 ignore_paths: Vec<PathBuf>,
109
110 #[arg(long)]
112 no_parallel: bool,
113
114 #[arg(long)]
116 follow_symlinks: bool,
117
118 #[arg(long)]
120 include_hidden: bool,
121
122 #[arg(long)]
124 no_gitignore: bool,
125 },
126 Clean {
128 #[arg(default_value = ".")]
130 path: PathBuf,
131
132 #[arg(short, long)]
134 max_depth: Option<usize>,
135
136 #[arg(short = 'S', long, value_enum, default_value = "cargo-clean")]
138 strategy: CleanStrategyArg,
139
140 #[arg(long, value_enum, default_value = "native")]
142 direct_delete_backend: DirectDeleteBackendArg,
143
144 #[arg(short = 'n', long)]
146 dry_run: bool,
147
148 #[arg(short = 'k', long)]
150 keep_days: Option<u32>,
151
152 #[arg(short = 's', long)]
154 keep_size: Option<String>,
155
156 #[arg(short = 'i', long = "ignore", action = clap::ArgAction::Append)]
158 ignore_paths: Vec<PathBuf>,
159
160 #[arg(long)]
162 no_parallel: bool,
163
164 #[arg(long)]
166 follow_symlinks: bool,
167
168 #[arg(long)]
170 include_hidden: bool,
171
172 #[arg(long)]
174 no_gitignore: bool,
175
176 #[arg(short = 'y', long)]
178 yes: bool,
179
180 #[arg(long)]
182 keep_executable: bool,
183
184 #[arg(long)]
186 executable_backup_dir: Option<PathBuf>,
187
188 #[arg(long, default_value = "0")]
190 timeout: u64,
191 },
192}
193
194#[derive(Debug, Clone, ValueEnum)]
195pub enum CleanStrategyArg {
196 #[value(name = "cargo-clean")]
198 CargoClean,
199 #[value(name = "direct-delete")]
201 DirectDelete,
202}
203
204#[derive(Debug, Clone, ValueEnum)]
205pub enum DirectDeleteBackendArg {
206 #[value(name = "native")]
208 Native,
209 #[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 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 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 projects = ProjectScanner::filter_with_target(projects);
362
363 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 println!("Found {} projects to clean:", projects.len());
379 display_projects(&projects, &args.path)?;
380
381 if !args.yes && !args.dry_run && !confirm_clean(&projects)? {
383 println!("Cleaning cancelled.");
384 return Ok(());
385 }
386
387 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 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, 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 let total_size: u64 = projects.iter().map(|p| p.target_size).sum();
636 assert_eq!(total_size, 3000);
637 }
638}