diff --git a/CHANGELOG.md b/CHANGELOG.md index e302bc1..fab13b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to Diskern are documented here. The format follows ### Fixed +- `diskern scan` now rejects missing and nonexistent roots instead of + reporting a successful empty scan - A relative scan root no longer hides every finding a root-anchored rule would have made. `diskern scan tmp` from `/var` reported nothing to clean; roots are resolved to absolute paths before the walk diff --git a/crates/diskern-cli/src/main.rs b/crates/diskern-cli/src/main.rs index cd4a24f..7bb1b51 100644 --- a/crates/diskern-cli/src/main.rs +++ b/crates/diskern-cli/src/main.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand, ValueEnum}; use diskern_core::{report, rules::RulesDb, scanner, Category, Finding, Verdict}; use std::path::PathBuf; @@ -19,6 +19,7 @@ enum Command { /// Read-only scan: find duplicates, caches, and reclaimable space. Scan { /// Directories to scan + #[arg(required = true)] roots: Vec, /// Emit full JSON report instead of a summary #[arg(long)] @@ -217,6 +218,18 @@ fn load_rules(path: Option<&std::path::Path>) -> Result { Ok(rules.with_embedded_protected_rules()) } +fn validate_roots(roots: &[PathBuf]) -> Result<()> { + for root in roots { + if !root + .try_exists() + .with_context(|| format!("could not check scan root '{}'", root.display()))? + { + bail!("scan root does not exist: {}", root.display()); + } + } + Ok(()) +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -227,6 +240,7 @@ fn main() -> Result<()> { verdict, rules, } => { + validate_roots(&roots)?; let external_rules = rules.as_deref(); let rules_db = load_rules(external_rules)?; let opts = scanner::ScanOptions { @@ -305,7 +319,45 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::{human_bytes, plural}; + use super::{human_bytes, plural, validate_roots, Cli}; + use clap::{error::ErrorKind, Parser}; + use std::path::PathBuf; + + #[test] + fn scan_requires_at_least_one_root() { + let error = match Cli::try_parse_from(["diskern", "scan"]) { + Ok(_) => panic!("scan without roots should be rejected"), + Err(error) => error, + }; + + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + } + + #[test] + fn nonexistent_scan_root_is_rejected_before_scanning() { + let missing = PathBuf::from("diskern-test-root-that-does-not-exist"); + let error = validate_roots(std::slice::from_ref(&missing)).unwrap_err(); + + assert_eq!( + error.to_string(), + format!("scan root does not exist: {}", missing.display()) + ); + } + + #[test] + fn unreadable_scan_root_error_names_the_root() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("file"); + std::fs::write(&file, b"not a directory").unwrap(); + let invalid_root = file.join("child"); + + let error = validate_roots(std::slice::from_ref(&invalid_root)).unwrap_err(); + + assert_eq!( + error.to_string(), + format!("could not check scan root '{}'", invalid_root.display()) + ); + } #[test] fn plural_returns_empty_only_for_singular() {