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 {
246            conditions: vec![],
247        };
248        assert!(cond.evaluate(&newsletter_msg()));
249    }
250
251    #[test]
252    fn empty_or_is_false() {
253        let cond = Conditions::Or {
254            conditions: vec![],
255        };
256        assert!(!cond.evaluate(&newsletter_msg()));
257    }
258
259    // -- Field condition tests --
260
261    #[test]
262    fn from_field_matches_email() {
263        let cond = FieldCondition::From {
264            pattern: StringMatch::Contains("substack".into()),
265        };
266        assert!(cond.evaluate(&newsletter_msg()));
267    }
268
269    #[test]
270    fn to_field_matches_any_recipient() {
271        let cond = FieldCondition::To {
272            pattern: StringMatch::Exact("user@example.com".into()),
273        };
274        assert!(cond.evaluate(&newsletter_msg()));
275    }
276
277    #[test]
278    fn subject_field_matches() {
279        let cond = FieldCondition::Subject {
280            pattern: StringMatch::Contains("Rust".into()),
281        };
282        assert!(cond.evaluate(&newsletter_msg()));
283    }
284
285    #[test]
286    fn has_label_checks_label_list() {
287        let cond = FieldCondition::HasLabel {
288            label: "newsletters".into(),
289        };
290        assert!(cond.evaluate(&newsletter_msg()));
291
292        let cond = FieldCondition::HasLabel {
293            label: "work".into(),
294        };
295        assert!(!cond.evaluate(&newsletter_msg()));
296    }
297
298    #[test]
299    fn size_conditions_work() {
300        let msg = newsletter_msg(); // size 15000
301
302        let cond = FieldCondition::SizeGreaterThan { bytes: 10000 };
303        assert!(cond.evaluate(&msg));
304
305        let cond = FieldCondition::SizeGreaterThan { bytes: 20000 };
306        assert!(!cond.evaluate(&msg));
307
308        let cond = FieldCondition::SizeLessThan { bytes: 20000 };
309        assert!(cond.evaluate(&msg));
310    }
311
312    #[test]
313    fn date_conditions_work() {
314        let msg = newsletter_msg(); // date is Utc::now()
315        let past = chrono::Utc::now() - chrono::Duration::hours(1);
316        let future = chrono::Utc::now() + chrono::Duration::hours(1);
317
318        let cond = FieldCondition::DateAfter { date: past };
319        assert!(cond.evaluate(&msg));
320
321        let cond = FieldCondition::DateBefore { date: future };
322        assert!(cond.evaluate(&msg));
323    }
324
325    #[test]
326    fn body_contains_searches_text() {
327        let cond = FieldCondition::BodyContains {
328            pattern: StringMatch::Contains("weekly Rust digest".into()),
329        };
330        assert!(cond.evaluate(&newsletter_msg()));
331    }
332
333    #[test]
334    fn body_contains_returns_false_when_no_body() {
335        let mut msg = newsletter_msg();
336        msg.body = None;
337        let cond = FieldCondition::BodyContains {
338            pattern: StringMatch::Contains("anything".into()),
339        };
340        assert!(!cond.evaluate(&msg));
341    }
342
343    #[test]
344    fn has_attachment_checks_flag() {
345        assert!(!FieldCondition::HasAttachment.evaluate(&newsletter_msg()));
346
347        let mut msg = newsletter_msg();
348        msg.has_attachment = true;
349        assert!(FieldCondition::HasAttachment.evaluate(&msg));
350    }
351
352    // -- Serialization tests --
353
354    #[test]
355    fn conditions_roundtrip_through_json() {
356        let cond = Conditions::And {
357            conditions: vec![
358                Conditions::Field(FieldCondition::From {
359                    pattern: StringMatch::Glob("*@substack.com".into()),
360                }),
361                Conditions::Not {
362                    condition: Box::new(Conditions::Field(FieldCondition::IsStarred)),
363                },
364            ],
365        };
366        let json = serde_json::to_string(&cond).unwrap();
367        let parsed: Conditions = serde_json::from_str(&json).unwrap();
368        // Verify it still evaluates the same
369        assert!(parsed.evaluate(&newsletter_msg()));
370    }
371}