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}"
+ );
+}