Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ pub struct ScanOptions {
#[arg(long, value_name = "PATTERN")]
pub exclude: Vec<String>,

/// Ignore files with these extensions (comma-separated, e.g. "dmg,iso,pkg")
#[arg(long, value_name = "EXTS", value_delimiter = ',')]
pub ignore_ext: Vec<String>,

/// Only include files with these extensions (comma-separated, e.g. "dmg,iso,zip")
#[arg(long, value_name = "EXTS", value_delimiter = ',')]
pub only_ext: Vec<String>,
Comment thread
cursor[bot] marked this conversation as resolved.

/// Output results as JSON
#[arg(long)]
pub json: bool,
Expand Down
64 changes: 63 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ pub struct Config {
#[serde(default)]
pub custom_paths: Vec<CustomCleanPath>,

/// File extensions to ignore during scanning
#[serde(default)]
pub ignored_extensions: Vec<String>,

/// When set, only include files with these extensions
#[serde(default)]
pub only_extensions: Vec<String>,

/// Base path for scanning (default: home directory)
#[serde(skip)]
pub base_path: Option<PathBuf>,
Expand Down Expand Up @@ -139,6 +147,8 @@ impl Default for Config {
excluded_paths: Vec::new(),
cache_paths: Vec::new(),
custom_paths: Vec::new(),
ignored_extensions: Vec::new(),
only_extensions: Vec::new(),
base_path: None,
}
}
Expand All @@ -164,9 +174,18 @@ impl Config {
let contents = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;

let config: Config = toml::from_str(&contents)
let mut config: Config = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;

config
.ignored_extensions
.iter_mut()
.for_each(|ext| *ext = normalize_extension(ext));
config
.only_extensions
.iter_mut()
.for_each(|ext| *ext = normalize_extension(ext));

Ok(config)
}

Expand Down Expand Up @@ -213,6 +232,22 @@ impl Config {
self.excluded_paths.push(exclude.clone());
}
}

// Add CLI ignored extensions
for ext in &options.ignore_ext {
let ext_lower = normalize_extension(ext);
if !self.ignored_extensions.contains(&ext_lower) {
self.ignored_extensions.push(ext_lower);
}
}

// Add CLI only-ext filters
for ext in &options.only_ext {
let ext_lower = normalize_extension(ext);
if !self.only_extensions.contains(&ext_lower) {
self.only_extensions.push(ext_lower);
}
}
}

/// Get the base path for scanning
Expand All @@ -228,6 +263,29 @@ impl Config {
self.min_large_size_mb * 1024 * 1024
}

/// Check if a file should be skipped based on extension filters.
/// Handles both --ignore-ext (exclusion) and --only-ext (inclusion) logic.
pub fn should_skip_extension(&self, path: &std::path::Path) -> bool {
if self.ignored_extensions.is_empty() && self.only_extensions.is_empty() {
return false;
}

let ext = match path.extension() {
Some(e) => e.to_string_lossy().to_lowercase(),
None => return !self.only_extensions.is_empty(),
};

if self.ignored_extensions.iter().any(|ignored| ignored == &ext) {
return true;
}

if !self.only_extensions.is_empty() {
return !self.only_extensions.iter().any(|only| only == &ext);
}

false
}

/// Check if a path should be excluded
pub fn is_excluded(&self, path: &std::path::Path) -> bool {
let path_str = path.to_string_lossy();
Expand Down Expand Up @@ -289,6 +347,10 @@ fn parse_size_mb(s: &str) -> Option<u64> {
s.parse::<u64>().ok()
}

fn normalize_extension(ext: &str) -> String {
ext.trim_start_matches('.').to_lowercase()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing whitespace trim causes silent filter failures

Medium Severity · Logic Bug

normalize_extension only strips leading dots but doesn't trim whitespace. With clap's value_delimiter = ',', input like --ignore-ext "dmg, iso, pkg" produces ["dmg", " iso", " pkg"]. The leading space in " iso" survives normalization, so it never matches path.extension() which returns "iso" (no space). Extensions after the first one silently fail to filter. Adding .trim() before the dot-strip would fix this.

Fix in Cursor Fix in Web


#[cfg(test)]
mod tests {
use super::*;
Expand Down
8 changes: 7 additions & 1 deletion src/scan_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ fn options_fingerprint(options: &ScanOptions) -> String {
.unwrap_or_default();
let mut exclude = options.exclude.clone();
exclude.sort();
let mut ignore_ext = options.ignore_ext.clone();
ignore_ext.sort();
let mut only_ext = options.only_ext.clone();
only_ext.sort();
format!(
"path={} all={} cache={} trash={} temp={} downloads={} build={} large={} duplicates={} old={} min_age={:?} min_size={:?} project_age={:?} exclude={:?}",
"path={} all={} cache={} trash={} temp={} downloads={} build={} large={} duplicates={} old={} min_age={:?} min_size={:?} project_age={:?} exclude={:?} ignore_ext={:?} only_ext={:?}",
path,
options.all,
options.cache,
Expand All @@ -42,6 +46,8 @@ fn options_fingerprint(options: &ScanOptions) -> String {
options.min_size,
options.project_age,
exclude,
ignore_ext,
only_ext,
)
}

Expand Down
5 changes: 5 additions & 0 deletions src/scanner/downloads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ impl Scanner for DownloadsScanner {
continue;
}

// Extension filters only apply to files.
if entry.file_type().is_file() && config.should_skip_extension(&path) {
continue;
}

// Skip hidden files
if let Some(name) = path.file_name() {
if name.to_string_lossy().starts_with('.') {
Expand Down
5 changes: 5 additions & 0 deletions src/scanner/large_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ impl Scanner for LargeFilesScanner {
continue;
}

// Skip based on extension filters
if config.should_skip_extension(path) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension filtering inconsistently applied across file scanners

Low Severity · Logic Bug

should_skip_extension is applied to downloads, large files, and old files scanners, but not to the duplicates scanner — which also iterates individual files. A user running duster scan --all --only-ext dmg would still see duplicate results for non-.dmg files, contradicting the documented behavior of "only include files with these extensions."

Additional Locations (2)

Fix in Cursor Fix in Web


// Skip hidden files
if let Some(name) = path.file_name() {
if name.to_string_lossy().starts_with('.') {
Expand Down
5 changes: 5 additions & 0 deletions src/scanner/old_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ impl Scanner for OldFilesScanner {
continue;
}

// Skip based on extension filters
if config.should_skip_extension(path) {
continue;
}

// Skip hidden files
if let Some(name) = path.file_name() {
if name.to_string_lossy().starts_with('.') {
Expand Down