Skip to main content

rmut_core/
pattern.rs

1//! Mutt-style search/limit patterns. Terms: `~f` from, `~s` subject,
2//! `~b` body, `~t` to, `~c` cc, `~C` to-or-cc, `~e` sender, `~h` any
3//! header, `~i` Message-ID, `~x` References, `~d` date, `~r` received
4//! date, `~m` index range, `~z` size range, `~=` duplicate,
5//! `~N` new, `~F` flagged, `~D` deleted, `~U` unread, `~T` tagged,
6//! `~l` addressed to a known mailing list,
7//! `~p` addressed to me, `~P` sent by me, `~A` every message;
8//! a bare word matches subject or from (mutt's
9//! $simple_search). Adjacent terms AND, `|` ORs, `!` negates, `()`
10//! groups; string arguments are case-insensitive regexes (quote them
11//! to include spaces), `~d`/`~r` take `DD/MM/YYYY` ranges or `<`/`>`/`=`
12//! offsets like `<1w`.
13
14use chrono::{Datelike, Local, TimeZone};
15
16use crate::message::{self, Envelope};
17
18/// A string argument: tried as a case-insensitive regex; an invalid
19/// regex degrades to a case-insensitive substring match.
20#[derive(Debug, Clone)]
21pub struct Matcher {
22    raw: String,
23    re: Option<regex_lite::Regex>,
24}
25
26impl Matcher {
27    pub fn new(raw: &str) -> Matcher {
28        Matcher {
29            raw: raw.to_string(),
30            re: regex_lite::Regex::new(&format!("(?i){raw}")).ok(),
31        }
32    }
33
34    /// The pattern text as written (for server-side IMAP search).
35    pub fn raw(&self) -> &str {
36        &self.raw
37    }
38
39    pub fn is_match(&self, text: &str) -> bool {
40        match &self.re {
41            Some(re) => re.is_match(text),
42            None => text.to_lowercase().contains(&self.raw.to_lowercase()),
43        }
44    }
45
46    /// Byte ranges of every match in `text`, for highlighters. The
47    /// substring fallback compares ASCII-case-insensitively (which
48    /// keeps offsets valid, unlike full lowercasing).
49    pub fn find_ranges(&self, text: &str) -> Vec<(usize, usize)> {
50        if let Some(re) = &self.re {
51            return re.find_iter(text).map(|m| (m.start(), m.end())).collect();
52        }
53        let hay = text.to_ascii_lowercase();
54        let needle = self.raw.to_ascii_lowercase();
55        if needle.is_empty() {
56            return Vec::new();
57        }
58        let mut out = Vec::new();
59        let mut from = 0;
60        while let Some(pos) = hay[from..].find(&needle) {
61            let start = from + pos;
62            out.push((start, start + needle.len()));
63            from = start + needle.len();
64        }
65        out
66    }
67}
68
69impl PartialEq for Matcher {
70    fn eq(&self, other: &Self) -> bool {
71        self.raw == other.raw
72    }
73}
74impl Eq for Matcher {}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Pattern {
78    All(Vec<Pattern>),
79    Any(Vec<Pattern>),
80    Not(Box<Pattern>),
81    From(Matcher),
82    Subject(Matcher),
83    Body(Matcher),
84    To(Matcher),
85    Cc(Matcher),
86    /// `~C`: any To or Cc address.
87    Recipient(Matcher),
88    /// `~e`: the Sender header (read from disk on demand).
89    Sender(Matcher),
90    /// `~h`: any header line, as `Name: value` text (read from disk).
91    Header(Matcher),
92    /// `~i`: the Message-ID.
93    MessageId(Matcher),
94    /// `~x`: any References / In-Reply-To id.
95    References(Matcher),
96    /// `~B`: any header or the body (the whole message text).
97    Whole(Matcher),
98    /// `~y`: the X-Label header.
99    Label(Matcher),
100    /// `~L`: an address in From, To or Cc.
101    FromOrTo(Matcher),
102    /// `~R`: read (seen).
103    Read,
104    /// `~O`: old, meaning unread but not new this session.
105    Old,
106    /// `~Q`: replied to (the Answered flag).
107    Replied,
108    /// `~u`: addressed to a subscribed mailing list.
109    Subscribed,
110    /// `~(P)`: some message in the same thread matches P.
111    Thread(Box<Pattern>),
112    /// `~<(P)`: the immediate parent matches P.
113    Parent(Box<Pattern>),
114    /// `~>(P)`: an immediate child matches P.
115    Child(Box<Pattern>),
116    /// `~v`: the message heads a collapsed thread.
117    Collapsed,
118    /// `~$`: no parent and no children, in a threaded index.
119    Unreferenced,
120    /// `~d`: epoch-second bounds, min inclusive, max exclusive.
121    Date {
122        min: Option<i64>,
123        max: Option<i64>,
124    },
125    /// `~r`: the same bounds against the file's delivery time.
126    Received {
127        min: Option<i64>,
128        max: Option<i64>,
129    },
130    /// `~m`: index-number range, inclusive, over the numbering on
131    /// screen; `.` is the selected message and `$` the last one.
132    Number {
133        min: Option<Bound>,
134        max: Option<Bound>,
135    },
136    /// `~z`: size in bytes, min inclusive, max inclusive.
137    Size {
138        min: Option<u64>,
139        max: Option<u64>,
140    },
141    /// `~=`: the Message-ID occurs more than once in the mailbox.
142    Duplicate,
143    New,
144    Flagged,
145    Deleted,
146    Unread,
147    Tagged,
148    /// `~p`: addressed to one of my addresses.
149    ToMe,
150    /// `~P`: sent by me (From is one of my addresses).
151    FromMe,
152    /// `~l`: addressed to a known mailing list.
153    ToList,
154    /// Bare word: matches subject or from.
155    Default(Matcher),
156}
157
158/// One end of a `~m` range: a literal number, or mutt's `.` (the
159/// selected message) and `$` (the last), resolved at match time.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum Bound {
162    Num(usize),
163    Current,
164    Last,
165}
166
167impl Bound {
168    fn resolve(self, pos: &Position) -> usize {
169        match self {
170            Bound::Num(n) => n,
171            Bound::Current => pos.current,
172            Bound::Last => pos.last,
173        }
174    }
175}
176
177/// Which addresses count as mine: the exact ones the identity layers
178/// name (bare, lowercase) plus mutt's `alternates`, regexes over the
179/// bare address. Everything that asks "is this me?" goes through here:
180/// `~p` and `~P`, the `+`/`T`/`C`/`F` index marks, reverse_name, the
181/// group-reply dedup, and Mail-Followup-To.
182#[derive(Default, Clone, Copy)]
183pub struct Me<'a> {
184    /// My bare lowercase addresses.
185    pub addresses: &'a [String],
186    /// mutt's `alternates`: regexes for my other addresses.
187    pub alternates: &'a [Matcher],
188}
189
190impl<'a> Me<'a> {
191    pub fn new(addresses: &'a [String], alternates: &'a [Matcher]) -> Me<'a> {
192        Me {
193            addresses,
194            alternates,
195        }
196    }
197
198    /// Only the exact addresses, no alternates (tests, and callers
199    /// that have no config in reach).
200    pub fn addresses(addresses: &'a [String]) -> Me<'a> {
201        Me {
202            addresses,
203            alternates: &[],
204        }
205    }
206
207    pub fn is_me(&self, addr: &str) -> bool {
208        let bare = addr.trim().to_lowercase();
209        self.addresses.contains(&bare) || self.alternates.iter().any(|m| m.is_match(&bare))
210    }
211
212    /// True when any of these bare addresses is mine.
213    pub fn any<'b>(&self, addresses: impl IntoIterator<Item = &'b String>) -> bool {
214        addresses.into_iter().any(|a| self.is_me(a))
215    }
216
217    /// True when the message's From names one of my addresses (`~P`).
218    pub fn wrote(&self, from_header: &str) -> bool {
219        crate::compose::addresses(from_header)
220            .iter()
221            .any(|a| self.is_me(a))
222    }
223
224    pub fn is_empty(&self) -> bool {
225        self.addresses.is_empty() && self.alternates.is_empty()
226    }
227}
228
229/// Everything matching needs besides the message: my own addresses
230/// (`~p`, `~P`), the known mailing lists (`~l`), and the message's
231/// place in the list (`~m`, `~=`).
232#[derive(Default, Clone, Copy)]
233pub struct Scope<'a> {
234    pub me: Me<'a>,
235    /// Address patterns naming mailing lists, subscribed or not.
236    pub lists: &'a [Matcher],
237    /// The subscribed subset, for `~u`.
238    pub subscribed: &'a [Matcher],
239    pub position: Position,
240    /// The message's place in its thread, for `~(`, `~<`, `~>`, `~v`
241    /// and `~$`. None outside a threaded index, where `~(P)` means P
242    /// of the message itself and the rest are false.
243    pub thread: Option<ThreadView<'a>>,
244}
245
246/// One message's neighbours in the thread, by index into `envs`.
247#[derive(Clone, Copy)]
248pub struct ThreadView<'a> {
249    pub members: &'a [usize],
250    pub parent: Option<usize>,
251    pub children: &'a [usize],
252    pub collapsed: bool,
253    pub envs: &'a dyn EnvSource,
254}
255
256/// Where a thread term finds the other messages: a session's list,
257/// or a plain slice in a test.
258pub trait EnvSource {
259    fn envelope(&self, i: usize) -> Option<&Envelope>;
260}
261
262impl EnvSource for [Envelope] {
263    fn envelope(&self, i: usize) -> Option<&Envelope> {
264        self.get(i)
265    }
266}
267
268impl EnvSource for Vec<Envelope> {
269    fn envelope(&self, i: usize) -> Option<&Envelope> {
270        self.get(i)
271    }
272}
273
274impl<'a> Scope<'a> {
275    /// The common case: my addresses, nothing else known.
276    pub fn me(me: &'a [String]) -> Scope<'a> {
277        Scope {
278            me: Me::addresses(me),
279            ..Default::default()
280        }
281    }
282
283    /// True when any of these addresses names a known list.
284    pub fn any_list(&self, addresses: &[String]) -> bool {
285        addresses
286            .iter()
287            .any(|a| self.lists.iter().any(|m| m.is_match(a)))
288    }
289}
290
291/// What a message needs from the list around it: its index number for
292/// `~m` and whether its Message-ID repeats for `~=`. Matching without
293/// a list (a single color rule, say) leaves this at its default, where
294/// both terms are false.
295#[derive(Debug, Clone, Copy, Default)]
296pub struct Position {
297    /// 1-based number as shown in the index; 0 when not on screen.
298    pub number: usize,
299    /// The selected message's number, for `.`.
300    pub current: usize,
301    /// The last message's number, for `$`.
302    pub last: usize,
303    pub duplicate: bool,
304}
305
306// ---- parsing ----
307
308enum Tok {
309    Not,
310    Or,
311    LParen,
312    RParen,
313    Op(char),
314    Word(String),
315}
316
317fn lex(input: &str) -> Vec<Tok> {
318    let mut out = Vec::new();
319    let mut chars = input.chars().peekable();
320    while let Some(&c) = chars.peek() {
321        match c {
322            c if c.is_whitespace() => {
323                chars.next();
324            }
325            '!' => {
326                chars.next();
327                out.push(Tok::Not);
328            }
329            '|' => {
330                chars.next();
331                out.push(Tok::Or);
332            }
333            '(' => {
334                chars.next();
335                out.push(Tok::LParen);
336            }
337            ')' => {
338                chars.next();
339                out.push(Tok::RParen);
340            }
341            '~' => {
342                chars.next();
343                if let Some(op) = chars.next() {
344                    out.push(Tok::Op(op));
345                    // `~(`: the paren is the operator's own; the
346                    // group it opens is parsed as a term of its own.
347                    if op == '(' {
348                        out.push(Tok::LParen);
349                    }
350                }
351            }
352            '"' | '\'' => {
353                let quote = c;
354                chars.next();
355                let mut word = String::new();
356                for c in chars.by_ref() {
357                    if c == quote {
358                        break;
359                    }
360                    word.push(c);
361                }
362                out.push(Tok::Word(word));
363            }
364            _ => {
365                let mut word = String::new();
366                while let Some(&c) = chars.peek() {
367                    if c.is_whitespace() || matches!(c, '(' | ')' | '|') {
368                        break;
369                    }
370                    word.push(c);
371                    chars.next();
372                }
373                out.push(Tok::Word(word));
374            }
375        }
376    }
377    out
378}
379
380/// Top-level terms (implicitly ANDed, like mutt).
381pub fn parse(input: &str) -> Result<Vec<Pattern>, String> {
382    parse_at(input, Local::now().timestamp())
383}
384
385/// `now` anchors relative `~d` offsets (injectable for tests).
386fn parse_at(input: &str, now: i64) -> Result<Vec<Pattern>, String> {
387    let mut toks = lex(input).into_iter().peekable();
388    let top = parse_or(&mut toks, now)?;
389    if toks.next().is_some() {
390        return Err("unbalanced )".into());
391    }
392    Ok(match top {
393        Pattern::All(terms) => terms,
394        one => vec![one],
395    })
396}
397
398type Toks = std::iter::Peekable<std::vec::IntoIter<Tok>>;
399
400fn parse_or(toks: &mut Toks, now: i64) -> Result<Pattern, String> {
401    let mut terms = vec![parse_and(toks, now)?];
402    while matches!(toks.peek(), Some(Tok::Or)) {
403        toks.next();
404        terms.push(parse_and(toks, now)?);
405    }
406    Ok(if terms.len() == 1 {
407        terms.pop().expect("one term")
408    } else {
409        Pattern::Any(terms)
410    })
411}
412
413fn parse_and(toks: &mut Toks, now: i64) -> Result<Pattern, String> {
414    let mut terms = Vec::new();
415    while !matches!(toks.peek(), None | Some(Tok::Or) | Some(Tok::RParen)) {
416        terms.push(parse_unary(toks, now)?);
417    }
418    Ok(if terms.len() == 1 {
419        terms.pop().expect("one term")
420    } else {
421        Pattern::All(terms)
422    })
423}
424
425fn parse_unary(toks: &mut Toks, now: i64) -> Result<Pattern, String> {
426    match toks.next() {
427        Some(Tok::Not) => Ok(Pattern::Not(Box::new(parse_unary(toks, now)?))),
428        Some(Tok::LParen) => {
429            let inner = parse_or(toks, now)?;
430            match toks.next() {
431                Some(Tok::RParen) => Ok(inner),
432                _ => Err("missing )".into()),
433            }
434        }
435        Some(Tok::Op(op @ ('(' | '<' | '>'))) => {
436            match toks.next() {
437                Some(Tok::LParen) => {}
438                _ => return Err(format!("~{op} needs a (pattern)")),
439            }
440            let inner = Box::new(parse_or(toks, now)?);
441            match toks.next() {
442                Some(Tok::RParen) => {}
443                _ => return Err("missing )".into()),
444            }
445            Ok(match op {
446                '(' => Pattern::Thread(inner),
447                '<' => Pattern::Parent(inner),
448                _ => Pattern::Child(inner),
449            })
450        }
451        Some(Tok::Op(op)) => {
452            let mut arg = || match toks.next() {
453                Some(Tok::Word(w)) => Ok(w),
454                _ => Err(format!("~{op} needs an argument")),
455            };
456            Ok(match op {
457                'f' => Pattern::From(Matcher::new(&arg()?)),
458                's' => Pattern::Subject(Matcher::new(&arg()?)),
459                'b' => Pattern::Body(Matcher::new(&arg()?)),
460                't' => Pattern::To(Matcher::new(&arg()?)),
461                'c' => Pattern::Cc(Matcher::new(&arg()?)),
462                'C' => Pattern::Recipient(Matcher::new(&arg()?)),
463                'e' => Pattern::Sender(Matcher::new(&arg()?)),
464                'h' => Pattern::Header(Matcher::new(&arg()?)),
465                'i' => Pattern::MessageId(Matcher::new(&arg()?)),
466                'x' => Pattern::References(Matcher::new(&arg()?)),
467                'B' => Pattern::Whole(Matcher::new(&arg()?)),
468                'y' => Pattern::Label(Matcher::new(&arg()?)),
469                'L' => Pattern::FromOrTo(Matcher::new(&arg()?)),
470                'd' => date_term(&arg()?, now)?,
471                'r' => match date_term(&arg()?, now)? {
472                    Pattern::Date { min, max } => Pattern::Received { min, max },
473                    other => other,
474                },
475                'm' => number_term(&arg()?)?,
476                'z' => size_term(&arg()?)?,
477                '=' => Pattern::Duplicate,
478                'N' => Pattern::New,
479                'F' => Pattern::Flagged,
480                'D' => Pattern::Deleted,
481                'U' => Pattern::Unread,
482                'T' => Pattern::Tagged,
483                'A' => Pattern::All(Vec::new()),
484                'p' => Pattern::ToMe,
485                'P' => Pattern::FromMe,
486                'l' => Pattern::ToList,
487                'R' => Pattern::Read,
488                'O' => Pattern::Old,
489                'Q' => Pattern::Replied,
490                'u' => Pattern::Subscribed,
491                'v' => Pattern::Collapsed,
492                '$' => Pattern::Unreferenced,
493                // Recognised but not supported, named so the message
494                // says what it is rather than "unknown". ~S/~E have
495                // no in-memory state in rmut; the crypto and
496                // attachment-count terms wait for their own round;
497                // ~n/~H are mutt's scoring, a non-goal.
498                'S' | 'E' | 'g' | 'G' | 'V' | 'k' | 'X' | 'n' | 'H' => {
499                    return Err(format!("~{op} is not supported"));
500                }
501                other => return Err(format!("unknown pattern ~{other}")),
502            })
503        }
504        Some(Tok::Word(w)) => Ok(Pattern::Default(Matcher::new(&w))),
505        _ => Err("empty pattern group".into()),
506    }
507}
508
509// ---- ~d date specs ----
510
511/// `<1w` newer than, `>2d` older than, `=3d` exactly that day, or
512/// absolute `DD/MM/YYYY` (a single day, or a `-` range with either
513/// end open).
514fn date_term(spec: &str, now: i64) -> Result<Pattern, String> {
515    if let Some(rest) = spec.strip_prefix('<') {
516        return Ok(Pattern::Date {
517            min: Some(now - offset_secs(rest)?),
518            max: None,
519        });
520    }
521    if let Some(rest) = spec.strip_prefix('>') {
522        return Ok(Pattern::Date {
523            min: None,
524            max: Some(now - offset_secs(rest)?),
525        });
526    }
527    if let Some(rest) = spec.strip_prefix('=') {
528        let then = now - offset_secs(rest)?;
529        let date = Local
530            .timestamp_opt(then, 0)
531            .earliest()
532            .ok_or("bad offset")?
533            .date_naive();
534        let (min, max) = day_bounds_of(date)?;
535        return Ok(Pattern::Date {
536            min: Some(min),
537            max: Some(max),
538        });
539    }
540    match spec.split_once('-') {
541        Some((min, max)) => {
542            let min = if min.is_empty() {
543                None
544            } else {
545                Some(day_bounds(min, now)?.0)
546            };
547            let max = if max.is_empty() {
548                None
549            } else {
550                Some(day_bounds(max, now)?.1)
551            };
552            Ok(Pattern::Date { min, max })
553        }
554        None => {
555            let (min, max) = day_bounds(spec, now)?;
556            Ok(Pattern::Date {
557                min: Some(min),
558                max: Some(max),
559            })
560        }
561    }
562}
563
564/// "2w" → seconds; units y(ears) m(onths) w(eeks) d(ays) H(ours)
565/// M(inutes), months and years approximated like mutt.
566fn offset_secs(s: &str) -> Result<i64, String> {
567    let err = || format!("bad date offset {s:?} (want e.g. 1w, 2d, 3H)");
568    let unit = s.chars().last().ok_or_else(err)?;
569    let n: i64 = s[..s.len() - unit.len_utf8()].parse().map_err(|_| err())?;
570    let secs = match unit {
571        'y' => 365 * 86400,
572        'm' => 30 * 86400,
573        'w' => 7 * 86400,
574        'd' => 86400,
575        'H' => 3600,
576        'M' => 60,
577        _ => return Err(err()),
578    };
579    Ok(n * secs)
580}
581
582/// "DD[/MM[/YYYY]]" → that local day as [start, end) epoch seconds;
583/// missing month/year come from `now`, 2-digit years mean 20xx.
584fn day_bounds(s: &str, now: i64) -> Result<(i64, i64), String> {
585    let err = || format!("bad date {s:?} (want DD/MM/YYYY)");
586    let today = Local
587        .timestamp_opt(now, 0)
588        .earliest()
589        .ok_or_else(err)?
590        .date_naive();
591    let mut parts = s.split('/');
592    let day: u32 = parts.next().unwrap_or("").parse().map_err(|_| err())?;
593    let month: u32 = match parts.next() {
594        Some(m) => m.parse().map_err(|_| err())?,
595        None => today.month(),
596    };
597    let year: i32 = match parts.next() {
598        Some(y) => {
599            let y: i32 = y.parse().map_err(|_| err())?;
600            if y < 100 { 2000 + y } else { y }
601        }
602        None => today.year(),
603    };
604    if parts.next().is_some() {
605        return Err(err());
606    }
607    let date = chrono::NaiveDate::from_ymd_opt(year, month, day).ok_or_else(err)?;
608    day_bounds_of(date)
609}
610
611fn day_bounds_of(date: chrono::NaiveDate) -> Result<(i64, i64), String> {
612    let midnight = date.and_hms_opt(0, 0, 0).ok_or("bad date")?;
613    let start = Local
614        .from_local_datetime(&midnight)
615        .earliest()
616        .ok_or("bad date")?
617        .timestamp();
618    Ok((start, start + 86400))
619}
620
621// ---- ~m index ranges and ~z sizes ----
622
623/// `~m 10-20`, `~m 5`, `~m 5-`, `~m -20`, and mutt's `.` (selected)
624/// and `$` (last) at either end.
625fn number_term(spec: &str) -> Result<Pattern, String> {
626    let end = |text: &str| -> Result<Bound, String> {
627        match text {
628            "." => Ok(Bound::Current),
629            "$" => Ok(Bound::Last),
630            n => n
631                .parse()
632                .map(Bound::Num)
633                .map_err(|_| format!("~m wants a number, . or $, got {n:?}")),
634        }
635    };
636    match spec.split_once('-') {
637        Some((min, max)) => Ok(Pattern::Number {
638            min: (!min.is_empty()).then(|| end(min)).transpose()?,
639            max: (!max.is_empty()).then(|| end(max)).transpose()?,
640        }),
641        None => {
642            let one = end(spec)?;
643            Ok(Pattern::Number {
644                min: Some(one),
645                max: Some(one),
646            })
647        }
648    }
649}
650
651/// `~z >100K`, `~z <2M`, `~z 10K-1M`, `~z 500`. K/M/G suffixes are
652/// mutt's (powers of 1024), case-insensitive.
653fn size_term(spec: &str) -> Result<Pattern, String> {
654    if let Some(rest) = spec.strip_prefix('>') {
655        return Ok(Pattern::Size {
656            min: Some(bytes(rest)?),
657            max: None,
658        });
659    }
660    if let Some(rest) = spec.strip_prefix('<') {
661        return Ok(Pattern::Size {
662            min: None,
663            max: Some(bytes(rest)?),
664        });
665    }
666    match spec.split_once('-') {
667        Some((min, max)) => Ok(Pattern::Size {
668            min: (!min.is_empty()).then(|| bytes(min)).transpose()?,
669            max: (!max.is_empty()).then(|| bytes(max)).transpose()?,
670        }),
671        None => {
672            let exact = bytes(spec)?;
673            Ok(Pattern::Size {
674                min: Some(exact),
675                max: Some(exact),
676            })
677        }
678    }
679}
680
681fn bytes(spec: &str) -> Result<u64, String> {
682    let spec = spec.trim();
683    let err = || format!("~z wants a size like 100K, got {spec:?}");
684    let (digits, scale) = match spec.chars().last().map(|c| c.to_ascii_uppercase()) {
685        Some('K') => (&spec[..spec.len() - 1], 1024),
686        Some('M') => (&spec[..spec.len() - 1], 1024 * 1024),
687        Some('G') => (&spec[..spec.len() - 1], 1024 * 1024 * 1024),
688        _ => (spec, 1),
689    };
690    digits
691        .trim()
692        .parse::<u64>()
693        .map(|n| n * scale)
694        .map_err(|_| err())
695}
696
697// ---- matching ----
698
699/// Answers `~b` for a message without the local file read: Some
700/// when a server-side search already knows, None to fall back.
701pub type BodyOracle<'a> = &'a dyn Fn(&Envelope, &Matcher) -> Option<bool>;
702
703/// Per-message context: disk reads happen at most once.
704struct Ctx<'a> {
705    env: &'a Envelope,
706    scope: Scope<'a>,
707    body: Option<String>,
708    sender: Option<String>,
709    headers: Option<String>,
710    received: Option<i64>,
711    oracle: Option<BodyOracle<'a>>,
712}
713
714/// AND of the top-level patterns. `me` are my own bare lowercase
715/// addresses, for `~p`.
716pub fn matches(patterns: &[Pattern], env: &Envelope, me: &[String]) -> bool {
717    matches_in(patterns, env, Scope::me(me), None)
718}
719
720/// Like `matches`, with `~b` optionally answered by `oracle`
721/// (server-side IMAP search) instead of reading the file.
722pub fn matches_via(
723    patterns: &[Pattern],
724    env: &Envelope,
725    me: &[String],
726    oracle: Option<BodyOracle>,
727) -> bool {
728    matches_in(patterns, env, Scope::me(me), oracle)
729}
730
731/// The full entry point: `scope` answers the terms a lone message
732/// cannot (`~p`, `~l`, `~m`, `~=`).
733pub fn matches_in<'a>(
734    patterns: &[Pattern],
735    env: &'a Envelope,
736    scope: Scope<'a>,
737    oracle: Option<BodyOracle<'a>>,
738) -> bool {
739    let mut ctx = Ctx {
740        env,
741        scope,
742        body: None,
743        sender: None,
744        headers: None,
745        received: None,
746        oracle,
747    };
748    patterns.iter().all(|p| eval(p, &mut ctx))
749}
750
751/// Every `~b` argument in the pattern, for pre-resolving on a server.
752pub fn body_terms(patterns: &[Pattern]) -> Vec<String> {
753    let mut out = Vec::new();
754    fn walk(p: &Pattern, out: &mut Vec<String>) {
755        match p {
756            Pattern::All(terms) | Pattern::Any(terms) => {
757                terms.iter().for_each(|t| walk(t, out));
758            }
759            Pattern::Not(term)
760            | Pattern::Thread(term)
761            | Pattern::Parent(term)
762            | Pattern::Child(term) => {
763                walk(term, out);
764            }
765            Pattern::Body(m) if !out.contains(&m.raw) => out.push(m.raw.clone()),
766            _ => {}
767        }
768    }
769    patterns.iter().for_each(|p| walk(p, &mut out));
770    out
771}
772
773/// A thread term's inner pattern over another message of the thread:
774/// the same me and lists, but no position and no thread of its own,
775/// so `~m`, `~=` and a nested thread term read as the lone-message
776/// case.
777fn eval_peer(inner: &Pattern, view: ThreadView, i: usize, ctx: &Ctx) -> bool {
778    let Some(env) = view.envs.envelope(i) else {
779        return false;
780    };
781    let mut peer = Ctx {
782        env,
783        scope: Scope {
784            me: ctx.scope.me,
785            lists: ctx.scope.lists,
786            subscribed: ctx.scope.subscribed,
787            position: Position::default(),
788            thread: None,
789        },
790        body: None,
791        sender: None,
792        headers: None,
793        received: None,
794        oracle: ctx.oracle,
795    };
796    eval(inner, &mut peer)
797}
798
799fn eval(p: &Pattern, ctx: &mut Ctx) -> bool {
800    let env = ctx.env;
801    match p {
802        Pattern::All(terms) => terms.iter().all(|t| eval(t, ctx)),
803        Pattern::Any(terms) => terms.iter().any(|t| eval(t, ctx)),
804        Pattern::Not(term) => !eval(term, ctx),
805        Pattern::From(m) => m.is_match(&env.from) || m.is_match(&env.from_full),
806        Pattern::Subject(m) => m.is_match(&env.subject),
807        Pattern::Default(m) => {
808            m.is_match(&env.subject) || m.is_match(&env.from) || m.is_match(&env.from_full)
809        }
810        Pattern::To(m) => env.to.iter().any(|a| m.is_match(a)),
811        Pattern::Cc(m) => env.cc.iter().any(|a| m.is_match(a)),
812        Pattern::Recipient(m) => env.to.iter().chain(&env.cc).any(|a| m.is_match(a)),
813        Pattern::Sender(m) => {
814            let sender = ctx.sender.get_or_insert_with(|| {
815                message::first_header(&env.file.path, "Sender").unwrap_or_default()
816            });
817            !sender.is_empty() && m.is_match(sender)
818        }
819        Pattern::Header(m) => {
820            let headers = ctx
821                .headers
822                .get_or_insert_with(|| message::header_text(&env.file.path).unwrap_or_default());
823            m.is_match(headers)
824        }
825        Pattern::MessageId(m) => env.msg_id.as_deref().is_some_and(|id| m.is_match(id)),
826        Pattern::References(m) => env.references.iter().any(|id| m.is_match(id)),
827        Pattern::Whole(m) => {
828            if m.is_match(&env.from_full)
829                || m.is_match(&env.subject)
830                || env.to.iter().chain(&env.cc).any(|a| m.is_match(a))
831            {
832                return true;
833            }
834            let headers = ctx
835                .headers
836                .get_or_insert_with(|| message::header_text(&env.file.path).unwrap_or_default());
837            if m.is_match(headers) {
838                return true;
839            }
840            let body = ctx
841                .body
842                .get_or_insert_with(|| message::body_text(&env.file.path).unwrap_or_default());
843            m.is_match(body)
844        }
845        Pattern::Label(m) => env.label.as_deref().is_some_and(|l| m.is_match(l)),
846        Pattern::FromOrTo(m) => {
847            m.is_match(&env.from)
848                || m.is_match(&env.from_full)
849                || env.to.iter().chain(&env.cc).any(|a| m.is_match(a))
850        }
851        Pattern::Read => env.file.flags.seen,
852        Pattern::Old => !env.file.is_new && !env.file.flags.seen,
853        Pattern::Replied => env.file.flags.answered,
854        Pattern::Subscribed => env
855            .to
856            .iter()
857            .chain(&env.cc)
858            .any(|a| ctx.scope.subscribed.iter().any(|m| m.is_match(a))),
859        Pattern::Date { min, max } => {
860            min.is_none_or(|min| env.date >= min) && max.is_none_or(|max| env.date < max)
861        }
862        Pattern::Received { min, max } => {
863            // Delivery time, which for a maildir is the file's mtime;
864            // the Date header stands in when the file is unreadable.
865            let at = *ctx.received.get_or_insert_with(|| {
866                std::fs::metadata(&env.file.path)
867                    .and_then(|m| m.modified())
868                    .ok()
869                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
870                    .map(|d| d.as_secs() as i64)
871                    .unwrap_or(env.date)
872            });
873            min.is_none_or(|min| at >= min) && max.is_none_or(|max| at < max)
874        }
875        Pattern::Number { min, max } => {
876            let n = ctx.scope.position.number;
877            n > 0
878                && min.is_none_or(|b| n >= b.resolve(&ctx.scope.position))
879                && max.is_none_or(|b| n <= b.resolve(&ctx.scope.position))
880        }
881        Pattern::Size { min, max } => {
882            let size = env.file.size;
883            min.is_none_or(|min| size >= min) && max.is_none_or(|max| size <= max)
884        }
885        Pattern::Duplicate => ctx.scope.position.duplicate,
886        Pattern::Thread(inner) => match ctx.scope.thread {
887            Some(t) => t.members.iter().any(|&i| eval_peer(inner, t, i, ctx)),
888            None => eval(inner, ctx),
889        },
890        Pattern::Parent(inner) => match ctx.scope.thread {
891            Some(t) => t.parent.is_some_and(|i| eval_peer(inner, t, i, ctx)),
892            None => false,
893        },
894        Pattern::Child(inner) => match ctx.scope.thread {
895            Some(t) => t.children.iter().any(|&i| eval_peer(inner, t, i, ctx)),
896            None => false,
897        },
898        Pattern::Collapsed => ctx.scope.thread.is_some_and(|t| t.collapsed),
899        Pattern::Unreferenced => ctx
900            .scope
901            .thread
902            .is_some_and(|t| t.parent.is_none() && t.children.is_empty()),
903        Pattern::New => env.file.is_new,
904        Pattern::Flagged => env.file.flags.flagged,
905        Pattern::Deleted => env.file.flags.deleted,
906        Pattern::Unread => !env.file.flags.seen,
907        Pattern::Tagged => env.tagged,
908        Pattern::ToMe => ctx.scope.me.any(env.to.iter().chain(&env.cc)),
909        Pattern::FromMe => ctx.scope.me.wrote(&env.from_full),
910        Pattern::ToList => env
911            .to
912            .iter()
913            .chain(&env.cc)
914            .any(|a| ctx.scope.lists.iter().any(|m| m.is_match(a))),
915        Pattern::Body(m) => {
916            if let Some(answer) = ctx.oracle.and_then(|oracle| oracle(env, m)) {
917                return answer;
918            }
919            let body = ctx
920                .body
921                .get_or_insert_with(|| message::body_text(&env.file.path).unwrap_or_default());
922            m.is_match(body)
923        }
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use crate::maildir::{Flags, MailFile};
931
932    fn env(from: &str, subject: &str, is_new: bool, flags: Flags) -> Envelope {
933        Envelope {
934            file: MailFile {
935                path: "/nonexistent".into(),
936                is_new,
937                flags,
938                size: 0,
939            },
940            from: crate::message::short_from(from),
941            from_full: from.into(),
942            subject: subject.into(),
943            date: 0,
944            msg_id: None,
945            references: vec![],
946            tagged: false,
947            to: vec![],
948            cc: vec![],
949            lines: Some(0),
950            list: None,
951            label: None,
952            broken: false,
953        }
954    }
955
956    fn ok(input: &str) -> Vec<Pattern> {
957        parse(input).unwrap_or_else(|e| panic!("parse {input:?}: {e}"))
958    }
959
960    #[test]
961    fn parse_mixed_terms() {
962        assert_eq!(
963            ok("~f jane ~N lunch"),
964            vec![
965                Pattern::From(Matcher::new("jane")),
966                Pattern::New,
967                Pattern::Default(Matcher::new("lunch")),
968            ]
969        );
970        assert_eq!(ok(""), vec![]);
971        // Quotes keep spaces together.
972        assert_eq!(
973            ok("~s \"pizza friday\""),
974            vec![Pattern::Subject(Matcher::new("pizza friday"))]
975        );
976    }
977
978    #[test]
979    fn patterns_v3_parse() {
980        assert_eq!(
981            ok("~i msg1"),
982            vec![Pattern::MessageId(Matcher::new("msg1"))]
983        );
984        assert_eq!(
985            ok("~x parent"),
986            vec![Pattern::References(Matcher::new("parent"))]
987        );
988        assert_eq!(
989            ok("~h x-spam"),
990            vec![Pattern::Header(Matcher::new("x-spam"))]
991        );
992        assert_eq!(ok("~="), vec![Pattern::Duplicate]);
993        assert_eq!(
994            ok("~m 10-20"),
995            vec![Pattern::Number {
996                min: Some(Bound::Num(10)),
997                max: Some(Bound::Num(20)),
998            }]
999        );
1000        assert_eq!(
1001            ok("~m .-$"),
1002            vec![Pattern::Number {
1003                min: Some(Bound::Current),
1004                max: Some(Bound::Last),
1005            }]
1006        );
1007        assert_eq!(
1008            ok("~m 7"),
1009            vec![Pattern::Number {
1010                min: Some(Bound::Num(7)),
1011                max: Some(Bound::Num(7)),
1012            }]
1013        );
1014        assert_eq!(
1015            ok("~z >100K"),
1016            vec![Pattern::Size {
1017                min: Some(102400),
1018                max: None,
1019            }]
1020        );
1021        assert_eq!(
1022            ok("~z 1K-2M"),
1023            vec![Pattern::Size {
1024                min: Some(1024),
1025                max: Some(2 * 1024 * 1024),
1026            }]
1027        );
1028        // ~r shares the ~d spec vocabulary but keeps its own variant.
1029        assert!(matches!(
1030            ok("~r <1w").as_slice(),
1031            [Pattern::Received { .. }]
1032        ));
1033        assert!(parse("~m nonsense").is_err());
1034        assert!(parse("~z 10X").is_err());
1035    }
1036
1037    #[test]
1038    fn patterns_v3_match() {
1039        let mut e = env("jane@x", "lunch", false, Flags::default());
1040        e.msg_id = Some("msg1@example.com".into());
1041        e.references = vec!["parent@example.com".into()];
1042        e.file.size = 150 * 1024;
1043        assert!(matches(&ok("~i msg1"), &e, &[]));
1044        assert!(!matches(&ok("~i other"), &e, &[]));
1045        assert!(matches(&ok("~x parent"), &e, &[]));
1046        assert!(matches(&ok("~z >100K"), &e, &[]));
1047        assert!(!matches(&ok("~z >1M"), &e, &[]));
1048        assert!(matches(&ok("~z 100K-200K"), &e, &[]));
1049        // ~m and ~= are false without a list around the message.
1050        assert!(!matches(&ok("~m 1"), &e, &[]));
1051        assert!(!matches(&ok("~="), &e, &[]));
1052        let pos = Position {
1053            number: 3,
1054            current: 5,
1055            last: 9,
1056            duplicate: true,
1057        };
1058        let scope = Scope {
1059            position: pos,
1060            ..Default::default()
1061        };
1062        let at = |p: &str| matches_in(&ok(p), &e, scope, None);
1063        assert!(at("~m 3"));
1064        assert!(at("~m 1-3"));
1065        assert!(at("~m -3"));
1066        assert!(!at("~m 4-"));
1067        assert!(at("~m 1-."));
1068        assert!(!at("~m .-$"));
1069        assert!(at("~="));
1070        assert!(!matches_in(&ok("~m 3"), &e, Scope::default(), None));
1071        // ~l needs the configured list patterns to say anything.
1072        e.to = vec!["dev@lists.example.com".into()];
1073        assert!(!matches(&ok("~l"), &e, &[]));
1074        let lists = [Matcher::new("@lists\\.example\\.com")];
1075        let scope = Scope {
1076            lists: &lists,
1077            ..Default::default()
1078        };
1079        assert!(matches_in(&ok("~l"), &e, scope, None));
1080        assert!(scope.any_list(&["dev@lists.example.com".to_string()]));
1081    }
1082
1083    #[test]
1084    fn thread_terms_look_at_the_neighbours() {
1085        // A thread of three: root (from jane), reply (from bob),
1086        // reply to the reply (from jane again). Views are built the
1087        // way a session builds them, over a slice.
1088        let envs = vec![
1089            env("jane@example.com", "root", false, Flags::default()),
1090            env("bob@example.com", "reply", false, Flags::default()),
1091            env("jane@example.com", "again", false, Flags::default()),
1092        ];
1093        let members = [0usize, 1, 2];
1094        let children_of_root = [1usize];
1095        let children_of_reply = [2usize];
1096        let view = |i: usize| ThreadView {
1097            members: &members,
1098            parent: match i {
1099                0 => None,
1100                1 => Some(0),
1101                _ => Some(1),
1102            },
1103            children: match i {
1104                0 => &children_of_root[..],
1105                1 => &children_of_reply[..],
1106                _ => &[],
1107            },
1108            collapsed: i == 0,
1109            envs: &envs,
1110        };
1111        let at = |pat: &str, i: usize| {
1112            let scope = Scope {
1113                thread: Some(view(i)),
1114                ..Default::default()
1115            };
1116            matches_in(&ok(pat), &envs[i], scope, None)
1117        };
1118        // ~(P): anyone in the thread.
1119        assert!(at("~(~f bob)", 0));
1120        assert!(at("~(~f bob)", 2));
1121        assert!(!at("~(~f alice)", 1));
1122        // ~<(P): the parent; ~>(P): a child.
1123        assert!(at("~<(~f jane)", 1));
1124        assert!(!at("~<(~f jane)", 2));
1125        assert!(!at("~<(~A)", 0));
1126        assert!(at("~>(~f bob)", 0));
1127        assert!(!at("~>(~f jane)", 0), "a grandchild is not a child");
1128        // ~v and ~$, and the terms compose like any other.
1129        assert!(at("~v", 0));
1130        assert!(!at("~v", 1));
1131        assert!(!at("~$", 0), "it has children");
1132        assert!(at("!~$ ~(~s again)", 2));
1133        // Without a thread view, ~(P) is P of the message and the
1134        // rest are false.
1135        let alone = |pat: &str, i: usize| matches_in(&ok(pat), &envs[i], Scope::default(), None);
1136        assert!(alone("~(~f bob)", 1));
1137        assert!(!alone("~(~f bob)", 0));
1138        assert!(!alone("~<(~A)", 1));
1139        assert!(!alone("~$", 0));
1140        // Spelling: the paren belongs to the operator.
1141        assert!(parse("~(").is_err());
1142        assert!(parse("~<~f x").is_err());
1143        assert!(parse("~(~f x").is_err());
1144    }
1145
1146    #[test]
1147    fn parse_reports_errors() {
1148        assert!(parse("~S").is_err()); // recognised but unsupported
1149        assert!(parse("~g").is_err());
1150        assert!(parse("~f").is_err());
1151        assert!(parse("~x").is_err()); // still needs an argument
1152        assert!(parse("(~N").is_err());
1153        assert!(parse("~N)").is_err());
1154        assert!(parse("~d nonsense").is_err());
1155    }
1156
1157    #[test]
1158    fn matches_is_case_insensitive_and_anded() {
1159        let e = env("Jane Doe", "Lunch on Friday", true, Flags::default());
1160        assert!(matches(&ok("~f JANE"), &e, &[]));
1161        assert!(matches(&ok("~f jane ~s lunch"), &e, &[]));
1162        assert!(!matches(&ok("~f jane ~s dinner"), &e, &[]));
1163        assert!(matches(&ok("friday"), &e, &[]));
1164        assert!(matches(&ok("doe"), &e, &[])); // Default also matches from
1165        assert!(matches(&ok("~N"), &e, &[]));
1166        assert!(!matches(&ok("~F"), &e, &[]));
1167        assert!(matches(&[], &e, &[])); // empty pattern matches everything
1168    }
1169
1170    #[test]
1171    fn from_matches_the_whole_header() {
1172        // Like mutt: ~f (and a bare word) match the address too, not
1173        // just the displayed name.
1174        let e = env(
1175            "Jane Doe <jane@example.com>",
1176            "Lunch",
1177            false,
1178            Flags::default(),
1179        );
1180        assert!(matches(&ok("~f jane@example.com"), &e, &[]));
1181        assert!(matches(&ok("~f example"), &e, &[]));
1182        assert!(matches(&ok("~f \"jane doe\""), &e, &[]));
1183        assert!(matches(&ok("example.com"), &e, &[]));
1184        assert!(!matches(&ok("~f petr@example.com"), &e, &[]));
1185        // A pre-1.24 header cache entry has no full header stored;
1186        // the short form still matches.
1187        let mut old = env(
1188            "Jane Doe <jane@example.com>",
1189            "Lunch",
1190            false,
1191            Flags::default(),
1192        );
1193        old.from_full = String::new();
1194        assert!(matches(&ok("~f doe"), &old, &[]));
1195        assert!(!matches(&ok("~f jane@example.com"), &old, &[]));
1196    }
1197
1198    #[test]
1199    fn not_or_and_grouping() {
1200        let e = env("Jane", "Lunch", true, Flags::default());
1201        assert!(matches(&ok("!~F"), &e, &[]));
1202        assert!(!matches(&ok("!~N"), &e, &[]));
1203        assert!(matches(&ok("~f jane | ~f petr"), &e, &[]));
1204        assert!(matches(&ok("~f petr | ~f jane"), &e, &[]));
1205        assert!(!matches(&ok("~f petr | ~f alice"), &e, &[]));
1206        // AND binds tighter than OR.
1207        assert!(matches(&ok("~f petr ~s x | ~f jane ~s lunch"), &e, &[]));
1208        assert!(!matches(&ok("~f petr (~s x | ~s lunch)"), &e, &[]));
1209        assert!(matches(&ok("~f jane (~s x | ~s lunch)"), &e, &[]));
1210        assert!(matches(&ok("!(~f petr | ~f alice)"), &e, &[]));
1211    }
1212
1213    #[test]
1214    fn body_terms_collect_and_the_oracle_answers() {
1215        let pats = ok("~b invoice ~s x !(~b a | ~b invoice)");
1216        assert_eq!(body_terms(&pats), vec!["invoice", "a"]);
1217        // The oracle's verdict replaces the (missing) local body read.
1218        let e = env("Jane", "report", false, Flags::default());
1219        let pats = ok("~b invoice");
1220        assert!(!matches(&pats, &e, &[]));
1221        let yes = |_: &Envelope, m: &Matcher| Some(m.raw() == "invoice");
1222        assert!(matches_via(&pats, &e, &[], Some(&yes)));
1223        // None falls back to the local read (empty body: no match).
1224        let dunno = |_: &Envelope, _: &Matcher| None;
1225        assert!(!matches_via(&pats, &e, &[], Some(&dunno)));
1226    }
1227
1228    #[test]
1229    fn string_arguments_are_regexes() {
1230        let e = env("Jane", "Re: budget", false, Flags::default());
1231        assert!(matches(&ok("~s ^re:"), &e, &[]));
1232        assert!(!matches(&ok("~s ^budget"), &e, &[]));
1233        assert!(matches(&ok("~s bud.et"), &e, &[]));
1234        assert!(matches(&ok("~s \"re:.*budget\""), &e, &[]));
1235        // An invalid regex still works as a plain substring.
1236        let e2 = env("Jane", "cost [draft]", false, Flags::default());
1237        assert!(matches(&ok("~s \"[draft\""), &e2, &[]));
1238    }
1239
1240    #[test]
1241    fn recipients_and_to_me() {
1242        let mut e = env("Jane", "s", false, Flags::default());
1243        e.to = vec!["team@example.com".into(), "me@example.com".into()];
1244        e.cc = vec!["boss@example.com".into()];
1245        assert!(matches(&ok("~t team@"), &e, &[]));
1246        assert!(!matches(&ok("~t boss@"), &e, &[]));
1247        assert!(matches(&ok("~c boss@"), &e, &[]));
1248        assert!(matches(&ok("~C boss@"), &e, &[]));
1249        assert!(matches(&ok("~C team@"), &e, &[]));
1250        let me = vec!["me@example.com".to_string()];
1251        assert!(matches(&ok("~p"), &e, &me));
1252        assert!(!matches(&ok("~p"), &e, &["other@example.com".to_string()]));
1253        assert!(!matches(&ok("~p"), &e, &[]));
1254    }
1255
1256    #[test]
1257    fn alternates_widen_who_counts_as_me() {
1258        let mut e = env("Jane Doe <jane@example.com>", "s", false, Flags::default());
1259        e.to = vec!["j.doe@old.example.com".into()];
1260        // The exact list does not have it; an alternate regex does.
1261        assert!(!matches(&ok("~p"), &e, &["me@example.com".to_string()]));
1262        let alternates = vec![Matcher::new("@old\\.example\\.com$")];
1263        let scope = Scope {
1264            me: Me::new(&[], &alternates),
1265            ..Default::default()
1266        };
1267        assert!(matches_in(&ok("~p"), &e, scope, None));
1268        // ~P reads the From header, so an alternate there is me too.
1269        assert!(!matches_in(&ok("~P"), &e, scope, None));
1270        let mine = vec!["jane@example.com".to_string()];
1271        let scope = Scope {
1272            me: Me::addresses(&mine),
1273            ..Default::default()
1274        };
1275        assert!(matches_in(&ok("~P"), &e, scope, None));
1276        assert!(!matches_in(&ok("~p"), &e, scope, None));
1277    }
1278
1279    #[test]
1280    fn sender_header_is_read_from_disk() {
1281        let dir = tempfile::tempdir().unwrap();
1282        let path = dir.path().join("msg");
1283        std::fs::write(
1284            &path,
1285            "From: jane@example.com\nSender: list-bot@example.com\nSubject: s\n\nbody\n",
1286        )
1287        .unwrap();
1288        let mut e = env("jane", "s", false, Flags::default());
1289        e.file.path = path;
1290        assert!(matches(&ok("~e list-bot"), &e, &[]));
1291        assert!(!matches(&ok("~e jane"), &e, &[]));
1292        // No Sender header at all: ~e never matches.
1293        assert!(!matches(
1294            &ok("~e .*"),
1295            &env("j", "s", false, Flags::default()),
1296            &[]
1297        ));
1298    }
1299
1300    #[test]
1301    fn date_offsets_and_ranges() {
1302        let now = 1_800_000_000i64; // fixed "now" for the offsets
1303        let mut e = env("Jane", "s", false, Flags::default());
1304        e.date = now - 3 * 86400; // three days ago
1305        let m = |input: &str, e: &Envelope| matches(&parse_at(input, now).unwrap(), e, &[]);
1306        assert!(m("~d <1w", &e));
1307        assert!(!m("~d <2d", &e));
1308        assert!(m("~d >2d", &e));
1309        assert!(!m("~d >1w", &e));
1310        assert!(m("~d =3d", &e));
1311        assert!(!m("~d =2d", &e));
1312        assert!(m("!~d <2d", &e));
1313        // Absolute days, resolved in the local timezone.
1314        let day = chrono::Local.timestamp_opt(e.date, 0).unwrap();
1315        let spec = day.format("%d/%m/%Y").to_string();
1316        assert!(m(&format!("~d {spec}"), &e));
1317        assert!(m(&format!("~d {spec}-"), &e));
1318        assert!(m(&format!("~d -{spec}"), &e));
1319        let before = day - chrono::Duration::days(2);
1320        assert!(!m(&format!("~d -{}", before.format("%d/%m/%Y")), &e));
1321        assert!(m(&format!("~d {}-", before.format("%d/%m/%Y")), &e));
1322        // Two-digit years and short forms parse.
1323        assert!(parse_at("~d 1/1/26-31/12/26", now).is_ok());
1324        assert!(parse_at("~d 15", now).is_ok());
1325    }
1326
1327    #[test]
1328    fn tagged_pattern_reads_the_runtime_mark() {
1329        let mut e = env("Jane", "s", false, Flags::default());
1330        assert!(!matches(&ok("~T"), &e, &[]));
1331        e.tagged = true;
1332        assert!(matches(&ok("~T"), &e, &[]));
1333    }
1334}