diff --git a/crates/diskern-cli/README.md b/crates/diskern-cli/README.md index d356cd7..a2b6334 100644 --- a/crates/diskern-cli/README.md +++ b/crates/diskern-cli/README.md @@ -14,6 +14,9 @@ diskern scan ~/Downloads --verdict safe # Every finding, not just the first few per category diskern scan ~ --top 0 +# Skip large directories while scanning their parent +diskern scan ~ --exclude ~/Videos --exclude ~/VMs + # Full JSON report (for scripting / piping into jq) diskern scan ~/Downloads --json ``` @@ -51,6 +54,7 @@ nothing will offer to move it. | Flag | Default | Effect | | ----------- | ------- | ------------------------------------------------------- | | `--top N` | `5` | Findings shown per category; `0` shows every one. | +| `--exclude ` | platform defaults | Skip a directory while scanning; repeat the flag to skip more. User excludes are added to the built-in protected excludes. | | `--verdict` | all | `safe`, `review`, `risky` or `protected`. Duplicate sets have no verdict, so they are omitted when this is set. | | `--json` | off | Full report as JSON; the flags above don't apply. | | `--rules ` | embedded | Load and validate an external rules database; embedded protected rules remain authoritative. | diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index 96d5d6f..4fb9281 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -21,6 +21,9 @@ enum Command { /// Directories to scan #[arg(required = true)] roots: Vec, + /// Directories to skip while scanning; may be repeated + #[arg(long, value_name = "DIR")] + exclude: Vec, /// Emit full JSON report instead of a summary #[arg(long)] json: bool, @@ -198,11 +201,31 @@ fn load_rules(path: Option<&std::path::Path>) -> Result { Ok(rules.with_embedded_protected_rules()) } +fn scan_options(roots: Vec, excludes: Vec) -> Result { + let mut opts = scanner::ScanOptions { + roots, + ..Default::default() + }; + + for exclude in excludes { + let absolute = std::path::absolute(&exclude).with_context(|| { + format!( + "could not normalize exclude path '{}'; check that the path is valid", + exclude.display() + ) + })?; + opts.excludes.push(absolute.to_string_lossy().into_owned()); + } + + Ok(opts) +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Command::Scan { roots, + exclude, json, top, verdict, @@ -210,10 +233,7 @@ fn main() -> Result<()> { } => { let external_rules = rules.as_deref(); let rules_db = load_rules(external_rules)?; - let opts = scanner::ScanOptions { - roots, - ..Default::default() - }; + let opts = scan_options(roots, exclude)?; let progress = Arc::new(scanner::ScanProgress::default()); let entries = scanner::scan(&opts, progress)?; let report = report::build(entries, &rules_db); @@ -286,8 +306,9 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::{human_bytes, plural, Cli}; + use super::{human_bytes, plural, scan_options, Cli}; use clap::{error::ErrorKind, Parser}; + use std::path::PathBuf; #[test] fn scan_requires_at_least_one_root() { @@ -299,6 +320,16 @@ mod tests { assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); } + #[test] + fn scan_options_append_cli_excludes_to_defaults() { + let before = diskern_core::scanner::ScanOptions::default().excludes.len(); + let opts = scan_options(vec![PathBuf::from(".")], vec![PathBuf::from("target")]).unwrap(); + + assert_eq!(opts.roots, vec![PathBuf::from(".")]); + assert_eq!(opts.excludes.len(), before + 1); + assert!(opts.excludes.last().unwrap().ends_with("target")); + } + #[test] fn plural_returns_empty_only_for_singular() { assert_eq!(plural(0), "s"); diff --git a/crates/diskern-cli/tests/scan_roots.rs b/crates/diskern-cli/tests/scan_roots.rs index 5f775c9..3b4cdd3 100644 --- a/crates/diskern-cli/tests/scan_roots.rs +++ b/crates/diskern-cli/tests/scan_roots.rs @@ -16,6 +16,18 @@ fn run_scan_roots<'a>( command.output().expect("diskern should start") } +fn run_scan_with_excludes<'a>( + root: &std::path::Path, + excludes: impl IntoIterator, +) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_diskern")); + command.arg("scan").arg(root); + for exclude in excludes { + command.arg("--exclude").arg(exclude); + } + command.output().expect("diskern should start") +} + #[test] fn missing_scan_root_fails_and_names_the_root() { let temp = tempdir().unwrap(); @@ -69,3 +81,25 @@ fn nested_scan_roots_are_counted_once_in_the_summary() { "expected nested roots to count two files once, got: {stdout}" ); } + +#[test] +fn scan_excludes_cli_directories() { + let temp = tempdir().unwrap(); + let skipped = temp.path().join("skip-me"); + std::fs::create_dir(&skipped).unwrap(); + std::fs::write(temp.path().join("keep.bin"), b"keep").unwrap(); + std::fs::write(skipped.join("skip.bin"), b"skip").unwrap(); + + let output = run_scan_with_excludes(temp.path(), [skipped.as_path()]); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Scanned 1 files."), + "expected excluded directory to be skipped, got: {stdout}" + ); +}