mod parse;
mod subtree;
use std::collections::HashSet;
use anyhow::{anyhow, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use crate::classify::Category;
use crate::entry::Entry;
pub(crate) use parse::{parse_duration_days, parse_size};
pub(crate) use subtree::{precompute_subtree_match, subtree_visible, SubtreeMatch};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EntryType {
File,
Dir,
}
impl std::fmt::Display for EntryType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
EntryType::File => "file",
EntryType::Dir => "dir",
})
}
}
impl std::str::FromStr for EntryType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"file" => Ok(EntryType::File),
"dir" => Ok(EntryType::Dir),
_ => Err(format!(
"invalid entry type '{s}' (expected 'file' or 'dir')"
)),
}
}
}
#[derive(Debug, Default)]
pub(crate) struct Filter {
categories: HashSet<Category>,
type_: Option<EntryType>,
min_size: Option<u64>,
names: Option<GlobSet>,
changed_within_days: Option<u64>,
changed_before_days: Option<u64>,
}
pub(crate) struct FilterInputs {
pub(crate) categories: Vec<Category>,
pub(crate) type_: Option<EntryType>,
pub(crate) min_size: Option<String>,
pub(crate) names: Vec<String>,
pub(crate) changed_within: Option<String>,
pub(crate) changed_before: Option<String>,
}
impl Filter {
pub(crate) fn from_inputs(inputs: FilterInputs) -> Result<Self> {
let categories: HashSet<Category> = inputs.categories.into_iter().collect();
let names = if inputs.names.is_empty() {
None
} else {
let mut builder = GlobSetBuilder::new();
for pat in &inputs.names {
let glob = Glob::new(pat)
.map_err(|e| anyhow!("--name '{pat}' is not a valid glob: {e}"))?;
builder.add(glob);
}
Some(builder.build()?)
};
let min_size = inputs.min_size.as_deref().map(parse_size).transpose()?;
let changed_within_days = inputs
.changed_within
.as_deref()
.map(parse_duration_days)
.transpose()?;
let changed_before_days = inputs
.changed_before
.as_deref()
.map(parse_duration_days)
.transpose()?;
Ok(Self {
categories,
type_: inputs.type_,
min_size,
names,
changed_within_days,
changed_before_days,
})
}
pub(crate) fn is_empty(&self) -> bool {
self.categories.is_empty()
&& self.type_.is_none()
&& self.min_size.is_none()
&& self.names.is_none()
&& self.changed_within_days.is_none()
&& self.changed_before_days.is_none()
}
pub(crate) fn matches(&self, entry: &Entry) -> bool {
if !self.categories.is_empty() && !self.categories.contains(&entry.category) {
return false;
}
if let Some(t) = self.type_ {
match t {
EntryType::File if entry.is_dir() => return false,
EntryType::Dir if !entry.is_dir() => return false,
_ => {}
}
}
if let Some(min) = self.min_size {
if entry.size < min {
return false;
}
}
if let Some(globs) = &self.names {
if !globs.is_match(&entry.name) {
return false;
}
}
if let Some(threshold) = self.changed_within_days {
match entry.modified_days_ago {
Some(d) if d <= threshold => {}
_ => return false,
}
}
if let Some(threshold) = self.changed_before_days {
match entry.modified_days_ago {
Some(d) if d > threshold => {}
_ => return false,
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classify::Category;
fn dir(name: &str, cat: Category, children: Vec<Entry>) -> Entry {
Entry::dir(name.to_string(), cat, None, children)
}
fn file(name: &str, size: u64, cat: Category, days_ago: Option<u64>) -> Entry {
Entry::file(name.to_string(), size, cat, days_ago)
}
fn empty_inputs() -> FilterInputs {
FilterInputs {
categories: vec![],
type_: None,
min_size: None,
names: vec![],
changed_within: None,
changed_before: None,
}
}
#[test]
fn empty_filter_matches_everything() {
let f = Filter::from_inputs(empty_inputs()).unwrap();
assert!(f.is_empty());
let e = file("a.txt", 10, Category::Other, Some(0));
assert!(f.matches(&e));
}
#[test]
fn category_and_type_filters_combine_with_and() {
let inputs = FilterInputs {
categories: vec![Category::Cache],
type_: Some(EntryType::File),
..empty_inputs()
};
let f = Filter::from_inputs(inputs).unwrap();
assert!(f.matches(&file("a", 0, Category::Cache, None)));
assert!(!f.matches(&file("a", 0, Category::Build, None))); assert!(!f.matches(&dir("a", Category::Cache, vec![]))); }
#[test]
fn min_size_filter() {
let inputs = FilterInputs {
min_size: Some("1K".into()),
..empty_inputs()
};
let f = Filter::from_inputs(inputs).unwrap();
assert!(f.matches(&file("a", 1024, Category::Other, None)));
assert!(f.matches(&file("a", 2048, Category::Other, None)));
assert!(!f.matches(&file("a", 1023, Category::Other, None)));
}
#[test]
fn name_filter_combines_globs_with_or() {
let inputs = FilterInputs {
names: vec!["*.log".into(), "*.tmp".into()],
..empty_inputs()
};
let f = Filter::from_inputs(inputs).unwrap();
assert!(f.matches(&file("server.log", 0, Category::Log, None)));
assert!(f.matches(&file("scratch.tmp", 0, Category::Other, None)));
assert!(!f.matches(&file("README.md", 0, Category::Other, None)));
}
#[test]
fn mtime_filters() {
let within = Filter::from_inputs(FilterInputs {
changed_within: Some("7d".into()),
..empty_inputs()
})
.unwrap();
let before = Filter::from_inputs(FilterInputs {
changed_before: Some("30d".into()),
..empty_inputs()
})
.unwrap();
let recent = file("a", 0, Category::Other, Some(3));
let mid = file("a", 0, Category::Other, Some(15));
let stale = file("a", 0, Category::Other, Some(60));
assert!(within.matches(&recent));
assert!(!within.matches(&mid));
assert!(!within.matches(&stale));
assert!(!before.matches(&recent));
assert!(!before.matches(&mid));
assert!(before.matches(&stale));
}
}