use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use gravityfile_core::units::{parse_age, parse_size};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Directory,
Symlink,
Other,
}
#[derive(Debug, Clone)]
pub struct SearchEntry {
pub path: PathBuf,
pub display: String,
pub kind: EntryKind,
pub size: u64,
pub modified: SystemTime,
}
impl SearchEntry {
#[cfg(test)]
pub fn named(path: PathBuf, display: String, is_dir: bool) -> Self {
Self {
path,
display,
kind: if is_dir {
EntryKind::Directory
} else {
EntryKind::File
},
size: 0,
modified: SystemTime::UNIX_EPOCH,
}
}
pub fn is_dir(&self) -> bool {
self.kind == EntryKind::Directory
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Filter {
min_size: Option<u64>,
max_size: Option<u64>,
older_than: Option<Duration>,
newer_than: Option<Duration>,
kinds: Vec<EntryKind>,
extensions: Vec<String>,
}
impl Filter {
pub fn parse(query: &str) -> (Self, String) {
let mut filter = Self::default();
let text: Vec<&str> = query
.split_whitespace()
.filter(|token| !filter.apply(token))
.collect();
(filter, text.join(" "))
}
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
fn apply(&mut self, token: &str) -> bool {
if let Some((key, values)) = token.split_once(':') {
let values: Vec<String> = values
.split(',')
.filter(|value| !value.is_empty())
.map(str::to_lowercase)
.collect();
match key {
"type" | "t" => {
let kinds: Option<Vec<EntryKind>> = values
.iter()
.map(|value| match value.as_str() {
"f" | "file" => Some(EntryKind::File),
"d" | "dir" | "directory" => Some(EntryKind::Directory),
"l" | "link" | "symlink" => Some(EntryKind::Symlink),
_ => None,
})
.collect();
match kinds {
Some(kinds) if !kinds.is_empty() => self.kinds.extend(kinds),
_ => return false,
}
}
"ext" | "e" if !values.is_empty() => self.extensions.extend(
values
.into_iter()
.map(|value| value.trim_start_matches('.').to_string()),
),
_ => return false,
}
return true;
}
for key in ["size", "age"] {
let Some(rest) = token.strip_prefix(key) else {
continue;
};
let (greater, inclusive, value) = if let Some(value) = rest.strip_prefix(">=") {
(true, true, value)
} else if let Some(value) = rest.strip_prefix('>') {
(true, false, value)
} else if let Some(value) = rest.strip_prefix("<=") {
(false, true, value)
} else if let Some(value) = rest.strip_prefix('<') {
(false, false, value)
} else {
return false;
};
if key == "size" {
let Ok(bytes) = parse_size(value) else {
return false;
};
if greater {
self.min_size = Some(if inclusive {
bytes
} else {
bytes.saturating_add(1)
});
} else if inclusive {
self.max_size = Some(bytes);
} else if let Some(below) = bytes.checked_sub(1) {
self.max_size = Some(below);
} else {
self.min_size = Some(1);
self.max_size = Some(0);
}
} else {
let Ok(age) = parse_age(value) else {
return false;
};
if greater {
self.older_than = Some(age);
} else {
self.newer_than = Some(age);
}
}
return true;
}
false
}
pub fn matches(&self, entry: &SearchEntry, now: SystemTime) -> bool {
if self.min_size.is_some_and(|min| entry.size < min)
|| self.max_size.is_some_and(|max| entry.size > max)
{
return false;
}
if self.older_than.is_some() || self.newer_than.is_some() {
let age = now.duration_since(entry.modified).unwrap_or_default();
if self.older_than.is_some_and(|older| age < older)
|| self.newer_than.is_some_and(|newer| age > newer)
{
return false;
}
}
if !self.kinds.is_empty() && !self.kinds.contains(&entry.kind) {
return false;
}
if !self.extensions.is_empty() {
let extension = Path::new(&entry.display)
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase());
if entry.is_dir()
|| !extension.is_some_and(|extension| self.extensions.contains(&extension))
{
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
const DAY: Duration = Duration::from_secs(86_400);
fn entry(display: &str, kind: EntryKind, size: u64, days_old: u64) -> SearchEntry {
SearchEntry {
path: PathBuf::from(display),
display: display.into(),
kind,
size,
modified: SystemTime::UNIX_EPOCH + DAY * 1000 - DAY * days_old as u32,
}
}
fn now() -> SystemTime {
SystemTime::UNIX_EPOCH + DAY * 1000
}
#[test]
fn facets_are_removed_and_the_rest_is_text() {
let (filter, text) = Filter::parse("cache size>100mb age>30d type:f ext:.Log,tmp build");
assert_eq!(text, "cache build");
assert_eq!(filter.min_size, Some(100_000_001), "strictly larger");
assert_eq!(filter.older_than, Some(DAY * 30));
assert_eq!(filter.kinds, [EntryKind::File]);
assert_eq!(filter.extensions, ["log", "tmp"]);
}
#[test]
fn malformed_facets_are_search_text() {
for query in [
"size>huge",
"age>3m",
"type:sock",
"ext:",
"colour:red",
"size=5",
] {
let (filter, text) = Filter::parse(query);
assert!(filter.is_empty(), "{query}");
assert_eq!(text, query);
}
}
#[test]
fn entries_must_pass_every_facet() {
let (filter, _) = Filter::parse("size>=1kb size<1mb age<10d type:f ext:log");
assert!(filter.matches(&entry("a/b.log", EntryKind::File, 5_000, 3), now()));
assert!(filter.matches(&entry("a/b.LOG", EntryKind::File, 1_000, 3), now()));
for rejected in [
entry("a/b.log", EntryKind::File, 999, 3),
entry("a/b.log", EntryKind::File, 1_000_000, 3),
entry("a/b.log", EntryKind::File, 5_000, 11),
entry("a/b.txt", EntryKind::File, 5_000, 3),
entry("a/b.log", EntryKind::Directory, 5_000, 3),
entry("a/b.log", EntryKind::Symlink, 5_000, 3),
] {
assert!(!filter.matches(&rejected, now()), "{rejected:?}");
}
}
#[test]
fn a_future_modification_time_is_brand_new() {
let (filter, _) = Filter::parse("age<1d");
let mut future = entry("x", EntryKind::File, 1, 0);
future.modified = now() + DAY;
assert!(filter.matches(&future, now()));
}
}