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#[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 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#[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 #[arg(short, long, global = true)]
70 pub verbose: bool,
71
72 #[arg(short, long, global = true)]
74 pub debug: bool,
75}
76
77#[derive(Subcommand)]
78pub enum Commands {
79 Scan {
81 #[arg(default_value = ".")]
83 path: PathBuf,
84
85 #[arg(short, long)]
87 max_depth: Option<usize>,
88
89 #[arg(short, long)]
91 target_only: bool,
92
93 #[arg(short = 'S', long)]
95 sort_by_size: bool,
96
97 #[arg(short = 'k', long)]
99 keep_days: Option<u32>,
100
101 #[arg(short = 's', long)]
103 keep_size: Option<String>,
104
105 #[arg(short = 'i', long = "ignore", action = clap::ArgAction::Append)]
107 ignore_paths: Vec<PathBuf>,
108
109 #[arg(long)]
111 no_parallel: bool,
112
113 #[arg(long)]
115 follow_symlinks: bool,
116
117 #[arg(long)]
119 include_hidden: bool,
120
121 #[arg(long)]
123 no_gitignore: bool,
124 },
125 Clean {
127 #[arg(default_value = ".")]
129 path: PathBuf,
130
131 #[arg(short, long)]
133 max_depth: Option<usize>,
134
135 #[arg(short = 'S', long, value_enum, default_value = "cargo-clean")]
137 strategy: CleanStrategyArg,
138
139 #[arg(short = 'n', long)]
141 dry_run: bool,
142
143 #[arg(short = 'k', long)]
145 keep_days: Option<u32>,
146
147 #[arg(short = 's', long)]
149 keep_size: Option<String>,
150
151 #[arg(short = 'i', long = "ignore", action = clap::ArgAction::Append)]
153 ignore_paths: Vec<PathBuf>,
154
155 #[arg(long)]
157 no_parallel: bool,
158
159 #[arg(long)]
161 follow_symlinks: bool,
162
163 #[arg(long)]
165 include_hidden: bool,
166
167 #[arg(long)]
169 no_gitignore: bool,
170
171 #[arg(short = 'y', long)]
173 yes: bool,
174
175 #[arg(long)]
177 keep_executable: bool,
178
179 #[arg(long)]
181 executable_backup_dir: Option<PathBuf>,
182
183 #[arg(long, default_value = "30")]
185 timeout: u64,
186 },
187}
188
189#[derive(Debug, Clone, ValueEnum)]
190pub enum CleanStrategyArg {
191 #[value(name = "cargo-clean")]
193 CargoClean,
194 #[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 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 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 projects = ProjectScanner::filter_with_target(projects);
336
337 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 println!("Found {} projects to clean:", projects.len());
353 display_projects(&projects, &args.path)?;
354
355 if !args.yes && !args.dry_run && !confirm_clean(&projects)? {
357 println!("Cleaning cancelled.");
358 return Ok(());
359 }
360
361 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 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 let total_size: u64 = projects.iter().map(|p| p.target_size).sum();
598 assert_eq!(total_size, 3000);
599 }
600}