use std::cmp::Ordering;
use anyhow::{Result, bail};
use chrono::{Duration, NaiveDate};
use crate::email::{
address::Address,
envelope::Envelope,
flag::{Flag, IanaFlag},
search::{
filter::query::SearchEmailsFilterQuery,
sort::query::{SearchEmailsSorter, SearchEmailsSorterKind, SearchEmailsSorterOrder},
},
};
pub(super) fn filter_to_q(filter: &SearchEmailsFilterQuery) -> Result<String> {
match filter {
SearchEmailsFilterQuery::And(lhs, rhs) => {
Ok(format!("{} {}", filter_to_q(lhs)?, filter_to_q(rhs)?))
}
SearchEmailsFilterQuery::Or(lhs, rhs) => {
Ok(format!("{{{} {}}}", filter_to_q(lhs)?, filter_to_q(rhs)?))
}
SearchEmailsFilterQuery::Not(inner) => not_to_q(inner),
SearchEmailsFilterQuery::Date(date) => Ok(format!(
"after:{} before:{}",
date_q(*date - Duration::days(1)),
date_q(*date + Duration::days(1)),
)),
SearchEmailsFilterQuery::AfterDate(date) => Ok(format!("after:{}", date_q(*date))),
SearchEmailsFilterQuery::From(pattern) => Ok(format!("from:{}", value_q(pattern))),
SearchEmailsFilterQuery::To(pattern) => Ok(format!("to:{}", value_q(pattern))),
SearchEmailsFilterQuery::Subject(pattern) => Ok(format!("subject:{}", value_q(pattern))),
SearchEmailsFilterQuery::Body(pattern) => Ok(value_q(pattern)),
SearchEmailsFilterQuery::Flag(flag) => flag_q(flag),
}
}
fn not_to_q(filter: &SearchEmailsFilterQuery) -> Result<String> {
match filter {
SearchEmailsFilterQuery::Not(inner) => filter_to_q(inner),
SearchEmailsFilterQuery::And(lhs, rhs) => {
Ok(format!("{{{} {}}}", not_to_q(lhs)?, not_to_q(rhs)?))
}
SearchEmailsFilterQuery::Or(lhs, rhs) => {
Ok(format!("{} {}", not_to_q(lhs)?, not_to_q(rhs)?))
}
SearchEmailsFilterQuery::Date(date) => Ok(format!(
"{{-after:{} -before:{}}}",
date_q(*date - Duration::days(1)),
date_q(*date + Duration::days(1)),
)),
SearchEmailsFilterQuery::AfterDate(date) => Ok(format!("-after:{}", date_q(*date))),
SearchEmailsFilterQuery::From(pattern) => Ok(format!("-from:{}", value_q(pattern))),
SearchEmailsFilterQuery::To(pattern) => Ok(format!("-to:{}", value_q(pattern))),
SearchEmailsFilterQuery::Subject(pattern) => Ok(format!("-subject:{}", value_q(pattern))),
SearchEmailsFilterQuery::Body(pattern) => Ok(format!("-{}", value_q(pattern))),
SearchEmailsFilterQuery::Flag(flag) => Ok(format!("-{}", flag_q(flag)?)),
}
}
fn value_q(pattern: &str) -> String {
if pattern.contains([' ', '\t', '"']) {
format!("\"{}\"", pattern.replace('"', "\\\""))
} else {
pattern.to_string()
}
}
fn date_q(date: NaiveDate) -> String {
date.format("%Y/%m/%d").to_string()
}
fn flag_q(flag: &Flag) -> Result<String> {
match flag.iana() {
Some(IanaFlag::Seen) => Ok("is:read".into()),
Some(IanaFlag::Flagged) => Ok("is:starred".into()),
Some(IanaFlag::Draft) => Ok("is:draft".into()),
Some(IanaFlag::Important) => Ok("is:important".into()),
Some(IanaFlag::Junk) => Ok("in:spam".into()),
Some(IanaFlag::Answered) => {
bail!("Gmail search offers no operator for the answered flag")
}
_ => bail!(
"Gmail search offers no operator for the {} flag",
flag.raw()
),
}
}
pub(super) fn sort_envelopes(envelopes: &mut [Envelope], sorters: &[SearchEmailsSorter]) {
envelopes.sort_by(|lhs, rhs| {
for sorter in sorters {
let ordering = match sorter.0 {
SearchEmailsSorterKind::Date => lhs.date.cmp(&rhs.date),
SearchEmailsSorterKind::From => compare_addresses(&lhs.from, &rhs.from),
SearchEmailsSorterKind::To => compare_addresses(&lhs.to, &rhs.to),
SearchEmailsSorterKind::Subject => {
lhs.subject.to_lowercase().cmp(&rhs.subject.to_lowercase())
}
};
let ordering = match sorter.1 {
SearchEmailsSorterOrder::Ascending => ordering,
SearchEmailsSorterOrder::Descending => ordering.reverse(),
};
if ordering != Ordering::Equal {
return ordering;
}
}
Ordering::Equal
});
}
fn compare_addresses(lhs: &[Address], rhs: &[Address]) -> Ordering {
let key = |addrs: &[Address]| addrs.first().map(|addr| addr.email.to_lowercase());
key(lhs).cmp(&key(rhs))
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
use crate::email::search::query::SearchEmailsQuery;
fn q(query: &str) -> String {
let query = SearchEmailsQuery::from_str(query).unwrap();
filter_to_q(&query.filter.unwrap()).unwrap()
}
fn q_err(query: &str) -> String {
let query = SearchEmailsQuery::from_str(query).unwrap();
filter_to_q(&query.filter.unwrap()).unwrap_err().to_string()
}
#[test]
fn leaf_conditions() {
assert_eq!(q("from alice"), "from:alice");
assert_eq!(q("to bob@example.org"), "to:bob@example.org");
assert_eq!(q("subject hello"), "subject:hello");
assert_eq!(q("body world"), "world");
}
#[test]
fn patterns_with_spaces_get_quoted() {
assert_eq!(value_q("john doe"), "\"john doe\"");
assert_eq!(value_q("said \"hi\""), "\"said \\\"hi\\\"\"");
assert_eq!(value_q("alice"), "alice");
assert_eq!(q("from alice"), "from:alice");
}
#[test]
fn conjunctions_flatten_into_whitespace() {
assert_eq!(q("from f and to t"), "from:f to:t");
assert_eq!(q("from f and to t and subject s"), "from:f to:t subject:s",);
}
#[test]
fn disjunctions_become_brace_groups() {
assert_eq!(q("from f or to t"), "{from:f to:t}");
}
#[test]
fn negated_leaves_get_a_dash() {
assert_eq!(q("not from f"), "-from:f");
assert_eq!(q("not flag seen"), "-is:read");
assert_eq!(q("not flag flagged"), "-is:starred");
assert_eq!(q("not flag draft"), "-is:draft");
assert_eq!(q("not after 2026-09-08"), "-after:2026/09/08");
}
#[test]
fn double_negation_cancels() {
assert_eq!(q("not not from f"), "from:f");
assert_eq!(q("not not flag seen"), "is:read");
}
#[test]
fn negated_compounds_expand_through_de_morgan() {
assert_eq!(q("not (from f or to t)"), "-from:f -to:t");
assert_eq!(q("not (from f and to t)"), "{-from:f -to:t}");
assert_eq!(q("not (not from f and not to t)"), "{from:f to:t}");
}
#[test]
fn exact_dates_bracket_the_day() {
assert_eq!(q("date 2026-09-08"), "after:2026/09/07 before:2026/09/09",);
assert_eq!(
q("not date 2026-09-08"),
"{-after:2026/09/07 -before:2026/09/09}",
);
}
#[test]
fn flag_operators() {
assert_eq!(q("flag seen"), "is:read");
assert_eq!(q("flag flagged"), "is:starred");
assert_eq!(q("flag draft"), "is:draft");
}
#[test]
fn unsupported_flags_bail() {
assert_eq!(
q_err("flag answered"),
"Gmail search offers no operator for the answered flag",
);
}
#[test]
fn the_unread_count_query_translates() {
assert_eq!(q("not flag seen"), "-is:read");
}
}