clean_dev_dirs/cli.rs
1//! Command-line interface definition and argument parsing.
2//!
3//! This module defines all command-line arguments, options, and their validation
4//! using the [clap](https://docs.rs/clap/) library. It provides structured access
5//! to user input and handles argument conflicts and defaults.
6
7use std::path::PathBuf;
8
9use clap::Parser;
10
11/// Command-line arguments for filtering projects during cleanup.
12///
13/// These options control which projects are considered for cleaning based on
14/// size and modification time criteria.
15#[derive(Parser)]
16struct FilteringArgs {
17 /// Ignore projects with a build dir size smaller than the specified value
18 ///
19 /// Supports various size formats:
20 /// - Decimal: KB, MB, GB (base 1000)
21 /// - Binary: KiB, MiB, GiB (base 1024)
22 /// - Bytes: plain numbers
23 /// - Decimal values: 1.5MB, 2.5GiB, etc.
24 #[arg(short = 's', long, default_value = "0")]
25 keep_size: String,
26
27 /// Ignore projects that have been compiled in the last \[DAYS\] days
28 ///
29 /// Projects with build directories modified within this timeframe will be
30 /// skipped during cleanup. A value of 0 disables time-based filtering.
31 #[arg(short = 'd', long, default_value = "0")]
32 keep_days: u32,
33}
34
35/// Command-line arguments for controlling cleanup execution behavior.
36///
37/// These options determine how the cleanup process runs, including confirmation
38/// prompts, dry-run mode, and interactive selection.
39#[derive(Parser)]
40struct ExecutionArgs {
41 /// Don't ask for confirmation; Just clean all detected projects
42 ///
43 /// When enabled, it automatically proceeds with cleaning without any user prompts.
44 /// Use with caution as this will immediately delete build directories.
45 #[arg(short = 'y', long)]
46 yes: bool,
47
48 /// Collect the cleanable projects and list the reclaimable space
49 ///
50 /// When enabled, performs all scans and filtering but doesn't
51 /// delete any files. Useful for previewing what would be cleaned.
52 #[arg(long)]
53 dry_run: bool,
54
55 /// Use interactive project selection
56 ///
57 /// When enabled, it presents a list of found projects and allows the user to
58 /// select which ones to clean using an interactive interface.
59 #[arg(short = 'i', long)]
60 interactive: bool,
61}
62
63/// Command-line arguments for filtering projects by type.
64///
65/// These options restrict cleaning to specific project types. The arguments
66/// are mutually exclusive to prevent conflicting selections.
67#[derive(Parser)]
68#[allow(clippy::struct_excessive_bools)] // This is acceptable here due to the nature of the CLI
69struct ProjectTypeArgs {
70 /// Clean only Rust projects
71 ///
72 /// When enabled, only directories containing `Cargo.toml` and `target/`
73 /// will be considered for cleanup.
74 #[arg(long, conflicts_with_all = ["node_only", "python_only", "go_only"])]
75 rust_only: bool,
76
77 /// Clean only Node.js projects
78 ///
79 /// When enabled, only directories containing `package.json` and `node_modules/`
80 /// will be considered for cleanup.
81 #[arg(long, conflicts_with_all = ["rust_only", "python_only", "go_only"])]
82 node_only: bool,
83
84 /// Clean only Python projects
85 ///
86 /// When enabled, only directories containing Python configuration files
87 /// (requirements.txt, setup.py, pyproject.toml) and cache directories
88 /// (`__pycache__`, `.pytest_cache`, venv, .venv) will be considered for cleanup.
89 #[arg(long, conflicts_with_all = ["rust_only", "node_only", "go_only"])]
90 python_only: bool,
91
92 /// Clean only Go projects
93 ///
94 /// When enabled, only directories containing `go.mod` and `vendor/`
95 /// will be considered for cleanup.
96 #[arg(long, conflicts_with_all = ["rust_only", "node_only", "python_only"])]
97 go_only: bool,
98}
99
100/// Command-line arguments for controlling directory scanning behavior.
101///
102/// These options affect how directories are traversed and what information
103/// is collected during the scanning phase.
104#[derive(Parser)]
105struct ScanningArgs {
106 /// The number of threads to use for directory scanning
107 ///
108 /// A value of 0 uses the default number of threads (typically the number of CPU cores).
109 /// Higher values can improve scanning performance on systems with fast storage.
110 #[arg(short = 't', long, default_value = "0")]
111 threads: usize,
112
113 /// Show access errors that occur while scanning
114 ///
115 /// When enabled, displays errors encountered while accessing files or directories
116 /// during the scanning process. Useful for debugging permission issues.
117 #[arg(short = 'v', long)]
118 verbose: bool,
119
120 /// Directories to ignore by default
121 ///
122 /// These directories will be completely ignored during scanning. Can be specified
123 /// multiple times to ignore multiple directory patterns.
124 #[arg(long, action = clap::ArgAction::Append)]
125 ignore: Vec<PathBuf>,
126
127 /// Directories to skip during scanning
128 ///
129 /// These directories will be skipped during scans, but their parent directories
130 /// may still be processed. Can be specified multiple times.
131 #[arg(long, action = clap::ArgAction::Append)]
132 skip: Vec<PathBuf>,
133}
134
135/// Main command-line interface structure.
136///
137/// This struct defines the complete command-line interface for the clean-dev-dirs tool,
138/// combining all argument groups and providing the main entry point for command parsing.
139#[derive(Parser)]
140#[command(name = "clean-dev-dirs")]
141#[command(about = "Recursively clean Rust, Node.js, Python, and Go development directories")]
142pub(crate) struct Cli {
143 /// The directory to search for projects
144 ///
145 /// Specifies the root directory where the tool will recursively search for
146 /// development projects. Defaults to the current directory if not specified.
147 #[arg(default_value = ".")]
148 pub(crate) dir: PathBuf,
149
150 /// Project type to clean
151 #[command(flatten)]
152 project_type: ProjectTypeArgs,
153
154 /// Execution options
155 #[command(flatten)]
156 execution: ExecutionArgs,
157
158 /// Filtering options
159 #[command(flatten)]
160 filtering: FilteringArgs,
161
162 /// Scanning options
163 #[command(flatten)]
164 scanning: ScanningArgs,
165}
166
167/// Configuration for cleanup execution behavior.
168///
169/// This struct provides a simplified interface to execution-related options,
170/// extracted from the command-line arguments.
171#[derive(Clone)]
172#[allow(dead_code)] // This is part of the public API
173pub(crate) struct ExecutionOptions {
174 /// Whether to run in dry-run mode (no actual deletion)
175 pub(crate) dry_run: bool,
176
177 /// Whether to use interactive project selection
178 pub(crate) interactive: bool,
179}
180
181/// Configuration for project filtering criteria.
182///
183/// This struct contains the filtering options used to determine which projects
184/// should be considered for cleanup based on size and modification time.
185#[derive(Clone)]
186pub struct FilterOptions {
187 /// Minimum size threshold for build directories
188 pub keep_size: String,
189
190 /// Minimum age in days for projects to be considered
191 pub keep_days: u32,
192}
193
194/// Enumeration of supported project type filters.
195///
196/// This enum is used to restrict scanning and cleaning to specific types of
197/// development projects.
198#[derive(Clone, Copy, PartialEq, Debug)]
199pub enum ProjectFilter {
200 /// Include all supported project types (Rust, Node.js, Python, Go)
201 All,
202
203 /// Include only Rust projects (Cargo.toml + target/)
204 RustOnly,
205
206 /// Include only Node.js projects (package.json + `node_modules`/)
207 NodeOnly,
208
209 /// Include only Python projects (Python config files + cache dirs)
210 PythonOnly,
211
212 /// Include only Go projects (go.mod + vendor/)
213 GoOnly,
214}
215
216/// Configuration for directory scanning behavior.
217///
218/// This struct contains options that control how directories are traversed
219/// and what information is collected during the scanning process.
220#[derive(Clone)]
221pub struct ScanOptions {
222 /// Whether to show verbose output including scan errors
223 pub verbose: bool,
224
225 /// Number of threads to use for scanning (0 = default)
226 pub threads: usize,
227
228 /// List of directory patterns to skip during scanning
229 pub skip: Vec<PathBuf>,
230}
231
232impl Cli {
233 /// Extract project filter from command-line arguments.
234 ///
235 /// This method analyzes the project type flags and returns the appropriate
236 /// filter enum value. Only one project type can be selected at a time due
237 /// to the `conflicts_with_all` constraints in the argument definitions.
238 ///
239 /// # Returns
240 ///
241 /// - `ProjectFilter::RustOnly` if `--rust-only` is specified
242 /// - `ProjectFilter::NodeOnly` if `--node-only` is specified
243 /// - `ProjectFilter::PythonOnly` if `--python-only` is specified
244 /// - `ProjectFilter::GoOnly` if `--go-only` is specified
245 /// - `ProjectFilter::All` if no specific project type is specified
246 ///
247 /// # Examples
248 ///
249 /// ```no_run
250 /// # use clap::Parser;
251 /// # use crate::cli::{Cli, ProjectFilter};
252 /// let args = Cli::parse_from(&["clean-dev-dirs", "--rust-only"]);
253 /// assert_eq!(args.project_filter(), ProjectFilter::RustOnly);
254 /// ```
255 #[allow(dead_code)] // This is part of the public API
256 pub(crate) fn project_filter(&self) -> ProjectFilter {
257 if self.project_type.rust_only {
258 ProjectFilter::RustOnly
259 } else if self.project_type.node_only {
260 ProjectFilter::NodeOnly
261 } else if self.project_type.python_only {
262 ProjectFilter::PythonOnly
263 } else if self.project_type.go_only {
264 ProjectFilter::GoOnly
265 } else {
266 ProjectFilter::All
267 }
268 }
269
270 /// Extract execution options from command-line arguments.
271 ///
272 /// This method creates an `ExecutionOptions` struct containing the
273 /// execution-related settings specified by the user.
274 ///
275 /// # Returns
276 ///
277 /// An `ExecutionOptions` struct with the dry-run and interactive flags
278 /// extracted from the command-line arguments.
279 ///
280 /// # Examples
281 ///
282 /// ```no_run
283 /// # use clap::Parser;
284 /// # use crate::cli::Cli;
285 /// let args = Cli::parse_from(&["clean-dev-dirs", "--dry-run", "--interactive"]);
286 /// let options = args.execution_options();
287 /// assert!(options.dry_run);
288 /// assert!(options.interactive);
289 /// ```
290 #[allow(dead_code)] // This is part of the public API
291 pub(crate) fn execution_options(&self) -> ExecutionOptions {
292 ExecutionOptions {
293 dry_run: self.execution.dry_run,
294 interactive: self.execution.interactive,
295 }
296 }
297
298 /// Extract scanning options from command-line arguments.
299 ///
300 /// This method creates a `ScanOptions` struct containing the
301 /// scanning-related settings specified by the user.
302 ///
303 /// # Returns
304 ///
305 /// A `ScanOptions` struct with verbose, threads, and skip options
306 /// extracted from the command-line arguments.
307 ///
308 /// # Examples
309 ///
310 /// ```no_run
311 /// # use clap::Parser;
312 /// # use crate::cli::Cli;
313 /// let args = Cli::parse_from(&["clean-dev-dirs", "--verbose", "--threads", "4"]);
314 /// let options = args.scan_options();
315 /// assert!(options.verbose);
316 /// assert_eq!(options.threads, 4);
317 /// ```
318 #[allow(dead_code)] // This is part of the public API
319 pub(crate) fn scan_options(&self) -> ScanOptions {
320 ScanOptions {
321 verbose: self.scanning.verbose,
322 threads: self.scanning.threads,
323 skip: self.scanning.skip.clone(),
324 }
325 }
326
327 /// Extract filtering options from command-line arguments.
328 ///
329 /// This method creates a `FilterOptions` struct containing the
330 /// filtering criteria specified by the user.
331 ///
332 /// # Returns
333 ///
334 /// A `FilterOptions` struct with size and time filtering criteria
335 /// extracted from the command-line arguments.
336 ///
337 /// # Examples
338 ///
339 /// ```no_run
340 /// # use clap::Parser;
341 /// # use crate::cli::Cli;
342 /// let args = Cli::parse_from(&["clean-dev-dirs", "--keep-size", "100MB", "--keep-days", "30"]);
343 /// let options = args.filter_options();
344 /// assert_eq!(options.keep_size, "100MB");
345 /// assert_eq!(options.keep_days, 30);
346 /// ```
347 #[allow(dead_code)] // This is part of the public API
348 pub(crate) fn filter_options(&self) -> FilterOptions {
349 FilterOptions {
350 keep_size: self.filtering.keep_size.clone(),
351 keep_days: self.filtering.keep_days,
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use clap::Parser;
360
361 #[test]
362 fn test_default_values() {
363 let args = Cli::parse_from(["clean-dev-dirs"]);
364
365 assert_eq!(args.dir, PathBuf::from("."));
366 assert_eq!(args.project_filter(), ProjectFilter::All);
367
368 let exec_opts = args.execution_options();
369 assert!(!exec_opts.dry_run);
370 assert!(!exec_opts.interactive);
371
372 let scan_opts = args.scan_options();
373 assert!(!scan_opts.verbose);
374 assert_eq!(scan_opts.threads, 0);
375 assert!(scan_opts.skip.is_empty());
376
377 let filter_opts = args.filter_options();
378 assert_eq!(filter_opts.keep_size, "0");
379 assert_eq!(filter_opts.keep_days, 0);
380 }
381
382 #[test]
383 fn test_project_filters() {
384 let rust_args = Cli::parse_from(["clean-dev-dirs", "--rust-only"]);
385 assert_eq!(rust_args.project_filter(), ProjectFilter::RustOnly);
386
387 let node_args = Cli::parse_from(["clean-dev-dirs", "--node-only"]);
388 assert_eq!(node_args.project_filter(), ProjectFilter::NodeOnly);
389
390 let python_args = Cli::parse_from(["clean-dev-dirs", "--python-only"]);
391 assert_eq!(python_args.project_filter(), ProjectFilter::PythonOnly);
392
393 let go_args = Cli::parse_from(["clean-dev-dirs", "--go-only"]);
394 assert_eq!(go_args.project_filter(), ProjectFilter::GoOnly);
395
396 let all_args = Cli::parse_from(["clean-dev-dirs"]);
397 assert_eq!(all_args.project_filter(), ProjectFilter::All);
398 }
399
400 #[test]
401 fn test_execution_options() {
402 let args = Cli::parse_from(["clean-dev-dirs", "--dry-run", "--interactive", "--yes"]);
403 let exec_opts = args.execution_options();
404
405 assert!(exec_opts.dry_run);
406 assert!(exec_opts.interactive);
407 }
408
409 #[test]
410 fn test_scanning_options() {
411 let args = Cli::parse_from([
412 "clean-dev-dirs",
413 "--verbose",
414 "--threads",
415 "8",
416 "--skip",
417 "node_modules",
418 "--skip",
419 ".git",
420 ]);
421 let scan_opts = args.scan_options();
422
423 assert!(scan_opts.verbose);
424 assert_eq!(scan_opts.threads, 8);
425 assert_eq!(scan_opts.skip.len(), 2);
426 assert!(scan_opts.skip.contains(&PathBuf::from("node_modules")));
427 assert!(scan_opts.skip.contains(&PathBuf::from(".git")));
428 }
429
430 #[test]
431 fn test_filtering_options() {
432 let args = Cli::parse_from([
433 "clean-dev-dirs",
434 "--keep-size",
435 "100MB",
436 "--keep-days",
437 "30",
438 ]);
439 let filter_opts = args.filter_options();
440
441 assert_eq!(filter_opts.keep_size, "100MB");
442 assert_eq!(filter_opts.keep_days, 30);
443 }
444
445 #[test]
446 fn test_custom_directory() {
447 let args = Cli::parse_from(["clean-dev-dirs", "/custom/path"]);
448 assert_eq!(args.dir, PathBuf::from("/custom/path"));
449 }
450
451 #[test]
452 fn test_project_filter_equality() {
453 assert_eq!(ProjectFilter::All, ProjectFilter::All);
454 assert_eq!(ProjectFilter::RustOnly, ProjectFilter::RustOnly);
455 assert_eq!(ProjectFilter::NodeOnly, ProjectFilter::NodeOnly);
456 assert_eq!(ProjectFilter::PythonOnly, ProjectFilter::PythonOnly);
457 assert_eq!(ProjectFilter::GoOnly, ProjectFilter::GoOnly);
458
459 assert_ne!(ProjectFilter::All, ProjectFilter::RustOnly);
460 assert_ne!(ProjectFilter::RustOnly, ProjectFilter::NodeOnly);
461 assert_ne!(ProjectFilter::NodeOnly, ProjectFilter::PythonOnly);
462 assert_ne!(ProjectFilter::PythonOnly, ProjectFilter::GoOnly);
463 }
464
465 #[test]
466 fn test_execution_options_clone() {
467 let original = ExecutionOptions {
468 dry_run: true,
469 interactive: false,
470 };
471 let cloned = original.clone();
472
473 assert_eq!(original.dry_run, cloned.dry_run);
474 assert_eq!(original.interactive, cloned.interactive);
475 }
476
477 #[test]
478 fn test_filter_options_clone() {
479 let original = FilterOptions {
480 keep_size: "100MB".to_string(),
481 keep_days: 30,
482 };
483 let cloned = original.clone();
484
485 assert_eq!(original.keep_size, cloned.keep_size);
486 assert_eq!(original.keep_days, cloned.keep_days);
487 }
488
489 #[test]
490 fn test_scan_options_clone() {
491 let original = ScanOptions {
492 verbose: true,
493 threads: 4,
494 skip: vec![PathBuf::from("test")],
495 };
496 let cloned = original.clone();
497
498 assert_eq!(original.verbose, cloned.verbose);
499 assert_eq!(original.threads, cloned.threads);
500 assert_eq!(original.skip, cloned.skip);
501 }
502
503 #[test]
504 fn test_project_filter_copy() {
505 let original = ProjectFilter::RustOnly;
506 let copied = original;
507
508 assert_eq!(original, copied);
509 }
510
511 #[test]
512 fn test_short_flags() {
513 let args = Cli::parse_from([
514 "clean-dev-dirs",
515 "-s",
516 "50MB",
517 "-d",
518 "7",
519 "-t",
520 "2",
521 "-v",
522 "-i",
523 "-y",
524 ]);
525
526 let filter_opts = args.filter_options();
527 assert_eq!(filter_opts.keep_size, "50MB");
528 assert_eq!(filter_opts.keep_days, 7);
529
530 let scan_opts = args.scan_options();
531 assert_eq!(scan_opts.threads, 2);
532 assert!(scan_opts.verbose);
533
534 let exec_opts = args.execution_options();
535 assert!(exec_opts.interactive);
536 }
537
538 #[test]
539 fn test_multiple_skip_directories() {
540 let args = Cli::parse_from([
541 "clean-dev-dirs",
542 "--skip",
543 "node_modules",
544 "--skip",
545 ".git",
546 "--skip",
547 "target",
548 "--skip",
549 "__pycache__",
550 ]);
551
552 let scan_opts = args.scan_options();
553 assert_eq!(scan_opts.skip.len(), 4);
554
555 let expected_dirs = vec![
556 PathBuf::from("node_modules"),
557 PathBuf::from(".git"),
558 PathBuf::from("target"),
559 PathBuf::from("__pycache__"),
560 ];
561
562 for expected_dir in expected_dirs {
563 assert!(scan_opts.skip.contains(&expected_dir));
564 }
565 }
566
567 #[test]
568 fn test_complex_size_formats() {
569 let test_cases = vec![
570 ("100KB", "100KB"),
571 ("1.5MB", "1.5MB"),
572 ("2GiB", "2GiB"),
573 ("500000", "500000"),
574 ];
575
576 for (input, expected) in test_cases {
577 let args = Cli::parse_from(["clean-dev-dirs", "--keep-size", input]);
578 let filter_opts = args.filter_options();
579 assert_eq!(filter_opts.keep_size, expected);
580 }
581 }
582
583 #[test]
584 fn test_zero_values() {
585 let args = Cli::parse_from([
586 "clean-dev-dirs",
587 "--keep-size",
588 "0",
589 "--keep-days",
590 "0",
591 "--threads",
592 "0",
593 ]);
594
595 let filter_opts = args.filter_options();
596 assert_eq!(filter_opts.keep_size, "0");
597 assert_eq!(filter_opts.keep_days, 0);
598
599 let scan_opts = args.scan_options();
600 assert_eq!(scan_opts.threads, 0);
601 }
602}