Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/diskern-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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 <dir>` | 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 <file>` | embedded | Load and validate an external rules database; embedded protected rules remain authoritative. |
Expand Down
41 changes: 36 additions & 5 deletions crates/diskern-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ enum Command {
/// Directories to scan
#[arg(required = true)]
roots: Vec<PathBuf>,
/// Directories to skip while scanning; may be repeated
#[arg(long, value_name = "DIR")]
exclude: Vec<PathBuf>,
/// Emit full JSON report instead of a summary
#[arg(long)]
json: bool,
Expand Down Expand Up @@ -198,22 +201,39 @@ fn load_rules(path: Option<&std::path::Path>) -> Result<RulesDb> {
Ok(rules.with_embedded_protected_rules())
}

fn scan_options(roots: Vec<PathBuf>, excludes: Vec<PathBuf>) -> Result<scanner::ScanOptions> {
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,
rules,
} => {
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);
Expand Down Expand Up @@ -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() {
Expand All @@ -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");
Expand Down
34 changes: 34 additions & 0 deletions crates/diskern-cli/tests/scan_roots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = &'a std::path::Path>,
) -> 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();
Expand Down Expand Up @@ -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}"
);
}
Loading