use anyhow::{bail, Result};
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, PartialEq)]
pub enum Wanted {
Anything,
AnythingBut(HashSet<String>),
OneOf(HashSet<String>),
}
const WILDCARD: &str = "*";
const NOT: char = '!';
impl Wanted {
pub fn accepts(&self, path: &Path) -> bool {
let extension = || path.extension().and_then(|s| s.to_str()).map(|e| e.to_lowercase());
match self {
Wanted::Anything => true,
Wanted::AnythingBut(refused) => extension().is_none_or(|e| !refused.contains(e.as_str())),
Wanted::OneOf(wanted) => extension().is_some_and(|e| wanted.contains(e.as_str())),
}
}
pub fn is_a_guess_at_images(&self) -> bool {
matches!(self, Wanted::OneOf(_))
}
pub fn describe(&self) -> String {
match self {
Wanted::Anything => "Searching every file, whatever its extension (-x '*').".into(),
Wanted::AnythingBut(set) => {
format!("Searching every file except these extensions: {:?}", sorted(set))
}
Wanted::OneOf(set) => format!("Searching extensions: {:?}", sorted(set)),
}
}
}
pub fn names_an_image(path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.is_some_and(|e| crate::decode::EXTENSIONS.contains(&e.to_lowercase().as_str()))
}
fn sorted(set: &HashSet<String>) -> Vec<&str> {
let mut shown: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
shown.sort_unstable();
shown
}
pub fn normalize(requested: &[String]) -> Result<(Wanted, Option<String>)> {
let mut wanted: HashSet<String> = HashSet::new();
let mut refused: HashSet<String> = HashSet::new();
for entry in requested {
let entry = entry.trim();
let (into, entry) = match entry.strip_prefix(NOT) {
Some(rest) => (&mut refused, rest.trim()),
None => (&mut wanted, entry),
};
let entry = entry.strip_prefix("*.").unwrap_or(entry);
let entry = entry.trim_start_matches('.').to_lowercase();
if !entry.is_empty() {
into.insert(entry);
}
}
if refused.contains(WILDCARD) {
bail!("--extensions excludes every file (-x '!*' matches nothing).");
}
if wanted.contains(WILDCARD) || (wanted.is_empty() && !refused.is_empty()) {
return Ok((
if refused.is_empty() { Wanted::Anything } else { Wanted::AnythingBut(refused) },
None,
));
}
let mut inert: Vec<String> = refused.difference(&wanted).map(|e| format!("{NOT}{e}")).collect();
inert.sort_unstable();
let warning = (!inert.is_empty()).then(|| {
format!(
"Note: --extensions {:?} matched nothing in {:?} and took nothing away. -x REPLACES \
the default list rather than narrowing it, so this walk is exactly that list; '{}' \
on its own means every file except that one.",
inert,
sorted(&wanted),
inert[0]
)
});
wanted.retain(|e| !refused.contains(e));
if wanted.is_empty() {
bail!(
"No extensions to search for (--extensions was empty, or every extension in it was \
excluded)."
);
}
Ok((Wanted::OneOf(wanted), warning))
}
#[cfg(test)]
mod tests {
use super::*;
fn of(entries: &[&str]) -> Wanted {
normalize(&entries.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap().0
}
fn takes(w: &Wanted, name: &str) -> bool {
w.accepts(Path::new(name))
}
#[test]
fn the_default_list_takes_images_and_nothing_bare() {
let w = of(&crate::decode::EXTENSIONS);
assert!(takes(&w, "a/photo.JPG"));
assert!(takes(&w, "a/photo.heic"));
assert!(!takes(&w, "a/notes.txt"));
assert!(!takes(&w, "a/beach"));
}
#[test]
fn spellings_of_one_extension_agree() {
for spelt in ["png", ".png", "PNG", "*.png", " png "] {
assert_eq!(of(&[spelt]), of(&["png"]), "{spelt}");
}
}
#[test]
fn the_wildcard_reaches_a_file_with_no_extension() {
let w = of(&["*"]);
assert_eq!(w, Wanted::Anything);
assert!(takes(&w, "a/beach"));
assert!(takes(&w, "a/notes.txt"));
assert!(!w.is_a_guess_at_images());
}
#[test]
fn the_wildcard_widens_whatever_it_is_written_beside() {
assert_eq!(of(&["jpg", "*"]), Wanted::Anything);
}
#[test]
fn an_exception_alone_is_every_file_but_that_one() {
let w = of(&["!gif"]);
assert!(takes(&w, "a/beach"));
assert!(takes(&w, "a/photo.jpg"));
assert!(!takes(&w, "a/anim.GIF"));
}
#[test]
fn an_exception_takes_one_back_out_of_a_list() {
let w = of(&["jpg", "png", "!png"]);
assert!(takes(&w, "a.jpg"));
assert!(!takes(&w, "a.png"));
}
#[test]
fn an_exception_that_takes_nothing_away_is_said() {
let (_, warning) = normalize(&["jpg".into(), "!gif".into()]).unwrap();
assert!(warning.unwrap().contains("!gif"));
}
#[test]
fn only_an_image_extension_names_an_image() {
assert!(names_an_image(Path::new("a/b.JPEG")));
assert!(!names_an_image(Path::new("a/beach")));
assert!(!names_an_image(Path::new("a/notes.txt")));
}
#[test]
fn walks_that_match_nothing_are_refused() {
assert!(normalize(&["!*".into()]).is_err());
assert!(normalize(&["jpg".into(), "!jpg".into()]).is_err());
assert!(normalize(&["".into()]).is_err());
}
}