use std::path::PathBuf;
use regex::Regex;
use walkdir::{DirEntry, WalkDir};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum EntryType {
F,
D,
L,
}
#[derive(Debug, Clone, Copy)]
pub enum SizeCmp {
Gt(u64),
Lt(u64),
Ge(u64),
}
pub fn parse_size(spec: &str) -> Result<SizeCmp, String> {
let spec = spec.trim();
let (ctor, body): (fn(u64) -> SizeCmp, &str) = if let Some(r) = spec.strip_prefix('+') {
(SizeCmp::Gt, r)
} else if let Some(r) = spec.strip_prefix('-') {
(SizeCmp::Lt, r)
} else {
(SizeCmp::Ge, spec)
};
let body = body.trim();
if body.is_empty() {
return Err(format!("empty size value in '{spec}'"));
}
let last = body.chars().last().unwrap();
let (num_part, mult): (&str, u64) = match last.to_ascii_lowercase() {
'k' => (&body[..body.len() - 1], 1024),
'm' => (&body[..body.len() - 1], 1024 * 1024),
'g' => (&body[..body.len() - 1], 1024 * 1024 * 1024),
'b' => (&body[..body.len() - 1], 1),
_ => (body, 1),
};
let n: u64 = num_part
.trim()
.parse()
.map_err(|_| format!("invalid size number '{num_part}' in '{spec}'"))?;
let bytes = n
.checked_mul(mult)
.ok_or_else(|| format!("size too large: '{spec}'"))?;
Ok(ctor(bytes))
}
pub fn size_matches(cmp: &SizeCmp, len: u64) -> bool {
match *cmp {
SizeCmp::Gt(n) => len > n,
SizeCmp::Lt(n) => len < n,
SizeCmp::Ge(n) => len >= n,
}
}
fn is_hidden(entry: &DirEntry) -> bool {
entry.depth() > 0 && entry.file_name().to_string_lossy().starts_with('.')
}
fn entry_kind_matches(types: &[EntryType], entry: &DirEntry) -> bool {
if types.is_empty() {
return true;
}
let ft = entry.file_type();
types.iter().any(|t| match t {
EntryType::F => ft.is_file(),
EntryType::D => ft.is_dir(),
EntryType::L => ft.is_symlink(),
})
}
pub struct Selector {
pub base: PathBuf,
pub names: Option<Vec<Regex>>,
pub types: Vec<EntryType>,
pub size: Option<SizeCmp>,
pub hidden: bool,
pub follow: bool,
}
impl Selector {
pub fn walk(&self) -> impl Iterator<Item = Result<DirEntry, String>> + '_ {
WalkDir::new(&self.base)
.follow_links(self.follow)
.into_iter()
.filter_entry(move |e| self.hidden || !is_hidden(e))
.filter_map(move |res| self.evaluate(res))
}
fn evaluate(&self, res: walkdir::Result<DirEntry>) -> Option<Result<DirEntry, String>> {
let entry = match res {
Ok(e) => e,
Err(e) => return Some(Err(format!("traversal error: {e}"))),
};
if !entry_kind_matches(&self.types, &entry) {
return None;
}
if let Some(names) = &self.names {
let nm = entry.file_name().to_string_lossy();
if !names.iter().any(|r| r.is_match(&nm)) {
return None;
}
}
if let Some(cmp) = &self.size {
if !entry.file_type().is_file() {
return None;
}
match entry.metadata() {
Ok(m) => {
if !size_matches(cmp, m.len()) {
return None;
}
}
Err(e) => return Some(Err(format!("stat {}: {e}", entry.path().display()))),
}
}
Some(Ok(entry))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn size_grammar_directions() {
assert!(matches!(parse_size("+4k").unwrap(), SizeCmp::Gt(4096)));
assert!(matches!(parse_size("-2m").unwrap(), SizeCmp::Lt(2097152)));
assert!(matches!(parse_size("10").unwrap(), SizeCmp::Ge(10)));
assert!(parse_size("+x").is_err());
}
#[test]
fn size_matches_compares() {
assert!(size_matches(&SizeCmp::Gt(10), 11));
assert!(!size_matches(&SizeCmp::Gt(10), 10));
assert!(size_matches(&SizeCmp::Ge(10), 10));
assert!(size_matches(&SizeCmp::Lt(10), 9));
}
}