Skip to main content

mail_query/
display.rs

1//! `Display` impl for `QueryNode` — round-trips an AST back to a
2//! parseable query string.
3//!
4//! The contract is *structural* round-trip:
5//! `parse(node.to_string())? == node`, not byte-identical. We normalise
6//! whitespace, prefer explicit `AND`/`OR` keywords, and parenthesise
7//! every compound child to preserve precedence.
8
9use std::fmt;
10
11use crate::ast::{DateBound, DateValue, FilterKind, QueryField, QueryNode, RelativeUnit, SizeOp};
12
13impl fmt::Display for QueryNode {
14    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15        match self {
16            Self::Text(s) => write_term(f, s),
17            Self::Exact(s) => {
18                f.write_str("+")?;
19                f.write_str(s)
20            }
21            Self::Phrase(s) => write_quoted(f, s),
22            Self::Field { field, value } => {
23                write!(f, "{}:", field_name(*field))?;
24                write_value(f, value)
25            }
26            Self::Filter(kind) => write_filter(f, kind),
27            Self::Label(name) => {
28                f.write_str("label:")?;
29                write_value(f, name)
30            }
31            Self::DateRange { bound, date } => write_date(f, *bound, date),
32            Self::Size { op, bytes } => write_size(f, *op, *bytes),
33            Self::Near {
34                left,
35                right,
36                distance,
37            } => write!(f, "\"{left} AROUND {distance} {right}\""),
38            Self::And(left, right) => {
39                write_compound(f, left)?;
40                f.write_str(" AND ")?;
41                write_compound(f, right)
42            }
43            Self::Or(left, right) => {
44                write_compound(f, left)?;
45                f.write_str(" OR ")?;
46                write_compound(f, right)
47            }
48            Self::Not(inner) => {
49                f.write_str("-")?;
50                write_compound(f, inner)
51            }
52        }
53    }
54}
55
56/// Wrap `And`/`Or`/`Not` children in parens so re-parsing preserves
57/// precedence. Leaves render directly.
58fn write_compound(f: &mut fmt::Formatter<'_>, node: &QueryNode) -> fmt::Result {
59    match node {
60        QueryNode::And(..) | QueryNode::Or(..) | QueryNode::Not(..) => {
61            write!(f, "({node})")
62        }
63        other => write!(f, "{other}"),
64    }
65}
66
67fn write_term(f: &mut fmt::Formatter<'_>, s: &str) -> fmt::Result {
68    if needs_quoting(s) {
69        write_quoted(f, s)
70    } else {
71        f.write_str(s)
72    }
73}
74
75fn write_value(f: &mut fmt::Formatter<'_>, s: &str) -> fmt::Result {
76    if needs_quoting(s) {
77        write_quoted(f, s)
78    } else {
79        f.write_str(s)
80    }
81}
82
83fn needs_quoting(s: &str) -> bool {
84    s.is_empty()
85        || s.chars()
86            .any(|c| c.is_whitespace() || matches!(c, '"' | '(' | ')' | '{' | '}' | ':'))
87}
88
89fn write_quoted(f: &mut fmt::Formatter<'_>, s: &str) -> fmt::Result {
90    f.write_str("\"")?;
91    for c in s.chars() {
92        if c == '"' {
93            f.write_str("\\\"")?;
94        } else {
95            f.write_str(&c.to_string())?;
96        }
97    }
98    f.write_str("\"")
99}
100
101fn field_name(field: QueryField) -> &'static str {
102    match field {
103        QueryField::From => "from",
104        QueryField::To => "to",
105        QueryField::Cc => "cc",
106        QueryField::Bcc => "bcc",
107        QueryField::Subject => "subject",
108        QueryField::Body => "body",
109        QueryField::Filename => "filename",
110        QueryField::List => "list",
111        QueryField::DeliveredTo => "deliveredto",
112        QueryField::Rfc822MsgId => "rfc822msgid",
113    }
114}
115
116fn write_filter(f: &mut fmt::Formatter<'_>, kind: &FilterKind) -> fmt::Result {
117    match kind {
118        FilterKind::Unread => f.write_str("is:unread"),
119        FilterKind::Read => f.write_str("is:read"),
120        FilterKind::Starred => f.write_str("is:starred"),
121        FilterKind::Draft => f.write_str("is:draft"),
122        FilterKind::Sent => f.write_str("is:sent"),
123        FilterKind::Trash => f.write_str("is:trash"),
124        FilterKind::Spam => f.write_str("is:spam"),
125        FilterKind::Answered => f.write_str("is:answered"),
126        FilterKind::Inbox => f.write_str("is:inbox"),
127        FilterKind::Archived => f.write_str("is:archived"),
128        FilterKind::Anywhere => f.write_str("in:anywhere"),
129        FilterKind::HasAttachment => f.write_str("has:attachment"),
130        FilterKind::HasCalendar => f.write_str("has:calendar"),
131        FilterKind::HasUserLabels => f.write_str("has:userlabels"),
132        FilterKind::NoUserLabels => f.write_str("has:nouserlabels"),
133        FilterKind::HasDrive => f.write_str("has:drive"),
134        FilterKind::HasDocument => f.write_str("has:document"),
135        FilterKind::HasSpreadsheet => f.write_str("has:spreadsheet"),
136        FilterKind::HasPresentation => f.write_str("has:presentation"),
137        FilterKind::HasYoutube => f.write_str("has:youtube"),
138        FilterKind::HasInlineImage => f.write_str("has:inline"),
139        FilterKind::HasLink => f.write_str("has:link"),
140        FilterKind::HasLinkHeavy => f.write_str("has:link-heavy"),
141        FilterKind::NoLinks => f.write_str("has:link-none"),
142        FilterKind::Custom(name) => write!(f, "is:{name}"),
143    }
144}
145
146fn write_date(f: &mut fmt::Formatter<'_>, bound: DateBound, date: &DateValue) -> fmt::Result {
147    // Relative durations have their own operator (`older_than:` /
148    // `newer_than:`); other date values pair with `after:`/`before:`/`date:`.
149    match (bound, date) {
150        (DateBound::Before, DateValue::Relative { amount, unit }) => {
151            write!(f, "older_than:{amount}{}", unit_suffix(*unit))
152        }
153        (DateBound::After, DateValue::Relative { amount, unit }) => {
154            write!(f, "newer_than:{amount}{}", unit_suffix(*unit))
155        }
156        _ => {
157            let prefix = match bound {
158                DateBound::After => "after",
159                DateBound::Before => "before",
160                DateBound::Exact => "date",
161            };
162            f.write_str(prefix)?;
163            f.write_str(":")?;
164            write_date_value(f, date)
165        }
166    }
167}
168
169fn write_date_value(f: &mut fmt::Formatter<'_>, date: &DateValue) -> fmt::Result {
170    match date {
171        DateValue::Specific(d) => write!(f, "{}", d.format("%Y-%m-%d")),
172        DateValue::Today => f.write_str("today"),
173        DateValue::Yesterday => f.write_str("yesterday"),
174        DateValue::ThisWeek => f.write_str("this-week"),
175        DateValue::ThisMonth => f.write_str("this-month"),
176        // Unreachable in practice — write_date handles Relative above.
177        DateValue::Relative { amount, unit } => {
178            write!(f, "{amount}{}", unit_suffix(*unit))
179        }
180    }
181}
182
183fn unit_suffix(unit: RelativeUnit) -> &'static str {
184    match unit {
185        RelativeUnit::Day => "d",
186        RelativeUnit::Week => "w",
187        RelativeUnit::Month => "m",
188        RelativeUnit::Year => "y",
189    }
190}
191
192fn write_size(f: &mut fmt::Formatter<'_>, op: SizeOp, bytes: u64) -> fmt::Result {
193    let op_str = match op {
194        SizeOp::LessThan => "<",
195        SizeOp::LessThanOrEqual => "<=",
196        SizeOp::Equal => "=",
197        SizeOp::GreaterThan => ">",
198        SizeOp::GreaterThanOrEqual => ">=",
199    };
200    // Render in raw bytes; the parser accepts a bare integer.
201    write!(f, "size:{op_str}{bytes}")
202}