Skip to main content

mxr_rules/
condition.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Composable condition tree. Evaluated recursively.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(tag = "type", rename_all = "snake_case")]
7pub enum Conditions {
8    And { conditions: Vec<Conditions> },
9    Or { conditions: Vec<Conditions> },
10    Not { condition: Box<Conditions> },
11    Field(FieldCondition),
12}
13
14/// Leaf-level condition against a single message field.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "field", rename_all = "snake_case")]
17pub enum FieldCondition {
18    From { pattern: StringMatch },
19    To { pattern: StringMatch },
20    Subject { pattern: StringMatch },
21    HasLabel { label: String },
22    HasAttachment,
23    SizeGreaterThan { bytes: u64 },
24    SizeLessThan { bytes: u64 },
25    DateAfter { date: DateTime<Utc> },
26    DateBefore { date: DateTime<Utc> },
27    IsUnread,
28    IsStarred,
29    HasUnsubscribe,
30    BodyContains { pattern: StringMatch },
31}
32
33/// How to match a string field.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type", content = "value", rename_all = "snake_case")]
36pub enum StringMatch {
37    Exact(String),
38    Contains(String),
39    Regex(String),
40    Glob(String),
41}
42
43/// A message-like view for condition evaluation.
44/// The engine evaluates conditions against this trait so it
45/// doesn't depend on mxr-core's concrete Envelope type directly.
46pub trait MessageView {
47    fn sender_email(&self) -> &str;
48    fn to_emails(&self) -> &[String];
49    fn subject(&self) -> &str;
50    fn labels(&self) -> &[String];
51    fn has_attachment(&self) -> bool;
52    fn size_bytes(&self) -> u64;
53    fn date(&self) -> DateTime<Utc>;
54    fn is_unread(&self) -> bool;
55    fn is_starred(&self) -> bool;
56    fn has_unsubscribe(&self) -> bool;
57    fn body_text(&self) -> Option<&str>;
58}
59
60impl StringMatch {
61    /// Evaluate this match against a haystack string.
62    pub fn matches(&self, haystack: &str) -> bool {
63        match self {
64            StringMatch::Exact(s) => haystack == s,
65            StringMatch::Contains(s) => haystack.to_lowercase().contains(&s.to_lowercase()),
66            StringMatch::Regex(pattern) => regex::Regex::new(pattern)
67                .map(|re| re.is_match(haystack))
68                .unwrap_or(false),
69            StringMatch::Glob(pattern) => glob_match::glob_match(pattern, haystack),
70        }
71    }
72}
73
74impl Conditions {
75    /// Recursively evaluate the condition tree against a message.
76    pub fn evaluate(&self, msg: &dyn MessageView) -> bool {
77        match self {
78            Conditions::And { conditions } => conditions.iter().all(|c| c.evaluate(msg)),
79            Conditions::Or { conditions } => conditions.iter().any(|c| c.evaluate(msg)),
80            Conditions::Not { condition } => !condition.evaluate(msg),
81            Conditions::Field(field) => field.evaluate(msg),
82        }
83    }
84}
85
86impl FieldCondition {
87    pub fn evaluate(&self, msg: &dyn MessageView) -> bool {
88        match self {
89            FieldCondition::From { pattern } => pattern.matches(msg.sender_email()),
90            FieldCondition::To { pattern } => msg.to_emails().iter().any(|e| pattern.matches(e)),
91            FieldCondition::Subject { pattern } => pattern.matches(msg.subject()),
92            FieldCondition::HasLabel { label } => msg.labels().iter().any(|l| l == label),
93            FieldCondition::HasAttachment => msg.has_attachment(),
94            FieldCondition::SizeGreaterThan { bytes } => msg.size_bytes() > *bytes,
95            FieldCondition::SizeLessThan { bytes } => msg.size_bytes() < *bytes,
96            FieldCondition::DateAfter { date } => msg.date() > *date,
97            FieldCondition::DateBefore { date } => msg.date() < *date,
98            FieldCondition::IsUnread => msg.is_unread(),
99            FieldCondition::IsStarred => msg.is_starred(),
100            FieldCondition::HasUnsubscribe => msg.has_unsubscribe(),
101            FieldCondition::BodyContains { pattern } => {
102                msg.body_text().is_some_and(|body| pattern.matches(body))
103            }
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::tests::*;
112
113    // -- StringMatch tests --
114
115    #[test]
116    fn exact_match_requires_full_equality() {
117        let m = StringMatch::Exact("alice@example.com".into());
118        assert!(m.matches("alice@example.com"));
119        assert!(!m.matches("ALICE@example.com"));
120        assert!(!m.matches("alice@example.com "));
121    }
122
123    #[test]
124    fn contains_match_is_case_insensitive() {
125        let m = StringMatch::Contains("invoice".into());
126        assert!(m.matches("Re: Invoice #2847"));
127        assert!(m.matches("INVOICE attached"));
128        assert!(m.matches("Your invoice is ready"));
129        assert!(!m.matches("Receipt attached"));
130    }
131
132    #[test]
133    fn contains_match_handles_unicode() {
134        let m = StringMatch::Contains("café".into());
135        assert!(m.matches("Meeting at Café Luna"));
136    }
137
138    #[test]
139    fn glob_match_with_wildcards() {
140        let m = StringMatch::Glob("*@substack.com".into());
141        assert!(m.matches("newsletter@substack.com"));
142        assert!(m.matches("alice@substack.com"));
143        assert!(!m.matches("newsletter@gmail.com"));
144    }
145
146    #[test]
147    fn glob_match_with_question_mark() {
148        let m = StringMatch::Glob("user?@example.com".into());
149        assert!(m.matches("user1@example.com"));
150        assert!(m.matches("userA@example.com"));
151        assert!(!m.matches("user12@example.com"));
152    }
153
154    #[test]
155    fn regex_match_complex_pattern() {
156        let m = StringMatch::Regex(r"(?i)invoice\s*#\d+".into());
157        assert!(m.matches("Re: Invoice #2847"));
158        assert!(m.matches("invoice#100"));
159        assert!(!m.matches("Receipt attached"));
160    }
161
162    #[test]
163    fn regex_invalid_pattern_returns_false() {
164        let m = StringMatch::Regex(r"[invalid".into());
165        assert!(!m.matches("anything"));
166    }
167
168    // -- Condition composition tests --
169
170    #[test]
171    fn and_condition_requires_all_true() {
172        let cond = Conditions::And {
173            conditions: vec![
174                Conditions::Field(FieldCondition::HasLabel {
175                    label: "newsletters".into(),
176                }),
177                Conditions::Field(FieldCondition::HasUnsubscribe),
178            ],
179        };
180        assert!(cond.evaluate(&newsletter_msg()));
181    }
182
183    #[test]
184    fn and_condition_short_circuits_on_false() {
185        let cond = Conditions::And {
186            conditions: vec![
187                Conditions::Field(FieldCondition::IsStarred),
188                Conditions::Field(FieldCondition::HasUnsubscribe),
189            ],
190        };
191        assert!(!cond.evaluate(&newsletter_msg()));
192    }
193
194    #[test]
195    fn or_condition_succeeds_on_any_true() {
196        let cond = Conditions::Or {
197            conditions: vec![
198                Conditions::Field(FieldCondition::IsStarred),
199                Conditions::Field(FieldCondition::HasUnsubscribe),
200            ],
201        };
202        assert!(cond.evaluate(&newsletter_msg()));
203    }
204
205    #[test]
206    fn or_condition_fails_when_all_false() {
207        let cond = Conditions::Or {
208            conditions: vec![
209                Conditions::Field(FieldCondition::IsStarred),
210                Conditions::Field(FieldCondition::HasAttachment),
211            ],
212        };
213        assert!(!cond.evaluate(&newsletter_msg()));
214    }
215
216    #[test]
217    fn not_condition_inverts() {
218        let cond = Conditions::Not {
219            condition: Box::new(Conditions::Field(FieldCondition::IsStarred)),
220        };
221        assert!(cond.evaluate(&newsletter_msg())); // not starred
222    }
223
224    #[test]
225    fn nested_conditions_evaluate_correctly() {
226        // (from matches *@substack.com AND has_unsub) OR is_starred
227        let cond = Conditions::Or {
228            conditions: vec![
229                Conditions::And {
230                    conditions: vec![
231                        Conditions::Field(FieldCondition::From {
232                            pattern: StringMatch::Glob("*@substack.com".into()),
233                        }),
234                        Conditions::Field(FieldCondition::HasUnsubscribe),
235                    ],
236                },
237                Conditions::Field(FieldCondition::IsStarred),
238            ],
239        };
240        assert!(cond.evaluate(&newsletter_msg()));
241    }
242
243    #[test]
244    fn empty_and_is_vacuously_true() {
245        let cond = Conditions::And { conditions: vec![] };
246        assert!(cond.evaluate(&newsletter_msg()));
247    }
248
249    #[test]
250    fn empty_or_is_false() {
251        let cond = Conditions::Or { conditions: vec![] };
252        assert!(!cond.evaluate(&newsletter_msg()));
253    }
254
255    // -- Field condition tests --
256
257    #[test]
258    fn from_field_matches_email() {
259        let cond = FieldCondition::From {
260            pattern: StringMatch::Contains("substack".into()),
261        };
262        assert!(cond.evaluate(&newsletter_msg()));
263    }
264
265    #[test]
266    fn to_field_matches_any_recipient() {
267        let cond = FieldCondition::To {
268            pattern: StringMatch::Exact("user@example.com".into()),
269        };
270        assert!(cond.evaluate(&newsletter_msg()));
271    }
272
273    #[test]
274    fn subject_field_matches() {
275        let cond = FieldCondition::Subject {
276            pattern: StringMatch::Contains("Rust".into()),
277        };
278        assert!(cond.evaluate(&newsletter_msg()));
279    }
280
281    #[test]
282    fn has_label_checks_label_list() {
283        let cond = FieldCondition::HasLabel {
284            label: "newsletters".into(),
285        };
286        assert!(cond.evaluate(&newsletter_msg()));
287
288        let cond = FieldCondition::HasLabel {
289            label: "work".into(),
290        };
291        assert!(!cond.evaluate(&newsletter_msg()));
292    }
293
294    #[test]
295    fn size_conditions_work() {
296        let msg = newsletter_msg(); // size 15000
297
298        let cond = FieldCondition::SizeGreaterThan { bytes: 10000 };
299        assert!(cond.evaluate(&msg));
300
301        let cond = FieldCondition::SizeGreaterThan { bytes: 20000 };
302        assert!(!cond.evaluate(&msg));
303
304        let cond = FieldCondition::SizeLessThan { bytes: 20000 };
305        assert!(cond.evaluate(&msg));
306    }
307
308    #[test]
309    fn date_conditions_work() {
310        let msg = newsletter_msg(); // date is Utc::now()
311        let past = chrono::Utc::now() - chrono::Duration::hours(1);
312        let future = chrono::Utc::now() + chrono::Duration::hours(1);
313
314        let cond = FieldCondition::DateAfter { date: past };
315        assert!(cond.evaluate(&msg));
316
317        let cond = FieldCondition::DateBefore { date: future };
318        assert!(cond.evaluate(&msg));
319    }
320
321    #[test]
322    fn body_contains_searches_text() {
323        let cond = FieldCondition::BodyContains {
324            pattern: StringMatch::Contains("weekly Rust digest".into()),
325        };
326        assert!(cond.evaluate(&newsletter_msg()));
327    }
328
329    #[test]
330    fn body_contains_returns_false_when_no_body() {
331        let mut msg = newsletter_msg();
332        msg.body = None;
333        let cond = FieldCondition::BodyContains {
334            pattern: StringMatch::Contains("anything".into()),
335        };
336        assert!(!cond.evaluate(&msg));
337    }
338
339    #[test]
340    fn has_attachment_checks_flag() {
341        assert!(!FieldCondition::HasAttachment.evaluate(&newsletter_msg()));
342
343        let mut msg = newsletter_msg();
344        msg.has_attachment = true;
345        assert!(FieldCondition::HasAttachment.evaluate(&msg));
346    }
347
348    // -- Serialization tests --
349
350    #[test]
351    fn conditions_roundtrip_through_json() {
352        let cond = Conditions::And {
353            conditions: vec![
354                Conditions::Field(FieldCondition::From {
355                    pattern: StringMatch::Glob("*@substack.com".into()),
356                }),
357                Conditions::Not {
358                    condition: Box::new(Conditions::Field(FieldCondition::IsStarred)),
359                },
360            ],
361        };
362        let json = serde_json::to_string(&cond).unwrap();
363        let parsed: Conditions = serde_json::from_str(&json).unwrap();
364        // Verify it still evaluates the same
365        assert!(parsed.evaluate(&newsletter_msg()));
366    }
367}