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}