use crate::format::Format;
use std::time::{Duration, SystemTime};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Cmp {
Lt,
Le,
Gt,
Ge,
}
impl Cmp {
fn test<T: PartialOrd>(self, a: T, b: T) -> bool {
match self {
Cmp::Lt => a < b,
Cmp::Le => a <= b,
Cmp::Gt => a > b,
Cmp::Ge => a >= b,
}
}
}
fn parse_kind(s: &str) -> Vec<Format> {
match s {
"folder" | "dir" | "directory" => vec![Format::Directory],
"image" | "img" | "photo" => vec![Format::Image],
"video" | "movie" | "vid" => vec![Format::Video],
"audio" | "music" | "sound" => vec![Format::Audio],
"pdf" => vec![Format::Pdf],
"sheet" | "spreadsheet" | "csv" | "excel" => vec![Format::Sheet],
"code" | "source" | "text" | "txt" => vec![Format::Text],
"markdown" | "md" => vec![Format::Markdown],
"doc" | "document" | "word" => vec![Format::Docx, Format::Doc],
"docx" => vec![Format::Docx],
"archive" | "zip" => vec![Format::Archive],
"binary" | "bin" => vec![Format::Binary],
_ => Vec::new(),
}
}
#[derive(Clone)]
pub struct Query {
terms: String,
kinds: Vec<Format>,
exts: Vec<String>,
size: Option<(Cmp, u64)>,
age: Option<(Cmp, Duration)>,
}
pub fn parse(raw: &str) -> Query {
let mut terms: Vec<&str> = Vec::new();
let mut kinds = Vec::new();
let mut exts = Vec::new();
let mut size = None;
let mut age = None;
for token in raw.split_whitespace() {
let Some((key, value)) = token.split_once(':') else {
terms.push(token);
continue;
};
if value.is_empty() {
terms.push(token);
continue;
}
match key.to_ascii_lowercase().as_str() {
"kind" | "type" => {
let mapped = parse_kind(&value.to_ascii_lowercase());
if mapped.is_empty() {
terms.push(token); } else {
kinds.extend(mapped);
}
}
"ext" => exts.push(value.trim_start_matches('.').to_ascii_lowercase()),
"size" => match parse_cmp(value, parse_size) {
Some(p) => size = Some(p),
None => terms.push(token),
},
"modified" | "date" | "age" => match parse_cmp(value, parse_duration) {
Some(p) => age = Some(p),
None => terms.push(token),
},
_ => terms.push(token), }
}
Query {
terms: terms.join(" "),
kinds,
exts,
size,
age,
}
}
impl Query {
pub fn has_predicates(&self) -> bool {
!self.kinds.is_empty() || !self.exts.is_empty() || self.size.is_some() || self.age.is_some()
}
pub fn matches(
&self,
name: &str,
format: Format,
size: u64,
modified: Option<SystemTime>,
) -> bool {
if !self.kinds.is_empty() && !self.kinds.contains(&format) {
return false;
}
if !self.exts.is_empty() {
let ext = name
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.unwrap_or_default();
if !self.exts.contains(&ext) {
return false;
}
}
if let Some((cmp, value)) = self.size {
if !cmp.test(size, value) {
return false;
}
}
if let Some((cmp, dur)) = self.age {
let item_age = modified.and_then(|t| t.elapsed().ok());
match item_age {
Some(a) if cmp.test(a, dur) => {}
_ => return false,
}
}
self.terms.is_empty() || subsequence(&self.terms.to_lowercase(), &name.to_lowercase())
}
}
fn parse_cmp<T>(value: &str, unit: impl Fn(&str) -> Option<T>) -> Option<(Cmp, T)> {
let (cmp, rest) = if let Some(r) = value.strip_prefix(">=") {
(Cmp::Ge, r)
} else if let Some(r) = value.strip_prefix("<=") {
(Cmp::Le, r)
} else if let Some(r) = value.strip_prefix('>') {
(Cmp::Gt, r)
} else if let Some(r) = value.strip_prefix('<') {
(Cmp::Lt, r)
} else {
(Cmp::Ge, value)
};
unit(rest).map(|v| (cmp, v))
}
fn parse_size(s: &str) -> Option<u64> {
let s = s.trim().to_ascii_lowercase();
let split = s.find(|c: char| c.is_alphabetic()).unwrap_or(s.len());
let (num, unit) = s.split_at(split);
let num: f64 = num.trim().parse().ok()?;
let mult = match unit.trim() {
"" | "b" => 1.0,
"k" | "kb" => 1024.0,
"m" | "mb" => 1024.0 * 1024.0,
"g" | "gb" => 1024.0 * 1024.0 * 1024.0,
"t" | "tb" => 1024.0_f64.powi(4),
_ => return None,
};
Some((num * mult) as u64)
}
fn parse_duration(s: &str) -> Option<Duration> {
let s = s.trim().to_ascii_lowercase();
let split = s.find(|c: char| c.is_alphabetic()).unwrap_or(s.len());
let (num, unit) = s.split_at(split);
let num: f64 = num.trim().parse().ok()?;
let secs = match unit.trim() {
"s" | "sec" => 1.0,
"m" | "min" => 60.0,
"h" | "hr" | "hour" => 3600.0,
"d" | "day" => 86_400.0,
"w" | "week" => 604_800.0,
"mo" | "month" => 2_592_000.0,
"y" | "year" => 31_536_000.0,
_ => return None,
};
Some(Duration::from_secs_f64(num * secs))
}
pub fn subsequence(needle: &str, haystack: &str) -> bool {
let mut h = haystack.chars();
needle.chars().all(|nc| h.any(|hc| hc == nc))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_text_and_predicates() {
let q = parse("report kind:pdf size:>1mb modified:<7d");
assert_eq!(q.terms, "report");
assert!(q.has_predicates());
assert!(matches!(q.size, Some((Cmp::Gt, _))));
}
#[test]
fn size_and_kind_filtering() {
let q = parse("kind:image size:>100kb");
assert!(q.matches("a.png", Format::Image, 200 * 1024, None));
assert!(!q.matches("a.png", Format::Image, 10 * 1024, None));
assert!(!q.matches("a.txt", Format::Text, 200 * 1024, None));
}
#[test]
fn kind_pdf_matches_pdf_not_text() {
let q = parse("kind:pdf");
assert!(q.matches("report.pdf", Format::Pdf, 0, None));
assert!(!q.matches("notes.txt", Format::Text, 0, None));
}
#[test]
fn kind_doc_matches_docx_and_doc() {
let q = parse("kind:doc");
assert!(q.matches("a.docx", Format::Docx, 0, None));
assert!(q.matches("a.doc", Format::Doc, 0, None));
assert!(!q.matches("a.pdf", Format::Pdf, 0, None));
}
#[test]
fn ext_filtering() {
let q = parse("ext:rs");
assert!(q.matches("main.rs", Format::Text, 0, None));
assert!(!q.matches("main.py", Format::Text, 0, None));
}
#[test]
fn modified_younger_than() {
let q = parse("modified:<7d");
let recent = SystemTime::now() - Duration::from_secs(86_400); let old = SystemTime::now() - Duration::from_secs(30 * 86_400); assert!(q.matches("a.txt", Format::Text, 0, Some(recent)));
assert!(!q.matches("a.txt", Format::Text, 0, Some(old)));
assert!(!q.matches("a.txt", Format::Text, 0, None));
}
#[test]
fn terms_and_predicate_together() {
let q = parse("report kind:pdf");
assert!(q.matches("annual-report.pdf", Format::Pdf, 0, None));
assert!(!q.matches("budget.pdf", Format::Pdf, 0, None));
assert!(!q.matches("report.txt", Format::Text, 0, None));
}
#[test]
fn bare_word_is_just_text() {
let q = parse("kind"); assert_eq!(q.terms, "kind");
assert!(!q.has_predicates());
}
#[test]
fn unknown_kind_becomes_free_text() {
let q = parse("kind:whatever");
assert_eq!(q.terms, "kind:whatever");
assert!(!q.has_predicates());
}
#[test]
fn unparseable_size_falls_back_to_text() {
let q = parse("size:huge");
assert_eq!(q.terms, "size:huge");
assert!(!q.has_predicates());
}
#[test]
fn unknown_key_is_free_text() {
let q = parse("http://example.com");
assert_eq!(q.terms, "http://example.com");
assert!(!q.has_predicates());
}
}