Skip to main content

mxr_rules/
engine.rs

1use serde::Serialize;
2use crate::condition::MessageView;
3use crate::{Rule, RuleAction, RuleId};
4
5/// The rule engine: evaluates rules against messages.
6pub struct RuleEngine {
7    rules: Vec<Rule>,
8}
9
10/// Result of evaluating all rules against a single message.
11#[derive(Debug, Clone, Serialize)]
12pub struct EvaluationResult {
13    pub message_id: String,
14    pub actions: Vec<RuleAction>,
15    pub matched_rules: Vec<RuleId>,
16}
17
18/// Result of a dry-run evaluation.
19#[derive(Debug, Clone, Serialize)]
20pub struct DryRunResult {
21    pub rule_id: RuleId,
22    pub rule_name: String,
23    pub matches: Vec<DryRunMatch>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct DryRunMatch {
28    pub message_id: String,
29    pub from: String,
30    pub subject: String,
31    pub actions: Vec<RuleAction>,
32}
33
34impl RuleEngine {
35    pub fn new(mut rules: Vec<Rule>) -> Self {
36        // Sort by priority (lower = first)
37        rules.sort_by_key(|r| r.priority);
38        Self { rules }
39    }
40
41    pub fn rules(&self) -> &[Rule] {
42        &self.rules
43    }
44
45    /// Evaluate all enabled rules against a message.
46    /// Returns accumulated actions. Actions are NOT applied yet —
47    /// the caller is responsible for executing them.
48    pub fn evaluate(&self, msg: &dyn MessageView, message_id: &str) -> EvaluationResult {
49        let mut actions = Vec::new();
50        let mut matched_rules = Vec::new();
51
52        for rule in &self.rules {
53            if !rule.enabled {
54                continue;
55            }
56            if rule.conditions.evaluate(msg) {
57                tracing::debug!(
58                    rule_name = %rule.name,
59                    message_id = %message_id,
60                    "Rule matched"
61                );
62                actions.extend(rule.actions.clone());
63                matched_rules.push(rule.id.clone());
64            }
65        }
66
67        EvaluationResult {
68            message_id: message_id.to_string(),
69            actions,
70            matched_rules,
71        }
72    }
73
74    /// Evaluate all enabled rules against a batch of messages.
75    pub fn evaluate_batch(
76        &self,
77        messages: &[(&dyn MessageView, &str)],
78    ) -> Vec<EvaluationResult> {
79        messages
80            .iter()
81            .map(|(msg, id)| self.evaluate(*msg, id))
82            .filter(|r| !r.actions.is_empty())
83            .collect()
84    }
85
86    /// Dry-run: evaluate a specific rule against messages without applying actions.
87    pub fn dry_run(
88        &self,
89        rule_id: &RuleId,
90        messages: &[(&dyn MessageView, &str, &str, &str)], // (msg, id, from, subject)
91    ) -> Option<DryRunResult> {
92        let rule = self.rules.iter().find(|r| &r.id == rule_id)?;
93
94        let matches: Vec<DryRunMatch> = messages
95            .iter()
96            .filter(|(msg, _, _, _)| rule.conditions.evaluate(*msg))
97            .map(|(_, id, from, subject)| DryRunMatch {
98                message_id: id.to_string(),
99                from: from.to_string(),
100                subject: subject.to_string(),
101                actions: rule.actions.clone(),
102            })
103            .collect();
104
105        Some(DryRunResult {
106            rule_id: rule.id.clone(),
107            rule_name: rule.name.clone(),
108            matches,
109        })
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::action::RuleAction;
117    use crate::condition::*;
118    use crate::tests::*;
119    use crate::{Rule, RuleId};
120    use chrono::Utc;
121
122    fn archive_newsletters_rule() -> Rule {
123        Rule {
124            id: RuleId("r_archive".into()),
125            name: "Archive newsletters".into(),
126            enabled: true,
127            priority: 10,
128            conditions: Conditions::Field(FieldCondition::HasLabel {
129                label: "newsletters".into(),
130            }),
131            actions: vec![RuleAction::Archive],
132            created_at: Utc::now(),
133            updated_at: Utc::now(),
134        }
135    }
136
137    fn mark_read_unsub_rule() -> Rule {
138        Rule {
139            id: RuleId("r_markread".into()),
140            name: "Mark read if unsubscribe available".into(),
141            enabled: true,
142            priority: 20,
143            conditions: Conditions::Field(FieldCondition::HasUnsubscribe),
144            actions: vec![RuleAction::MarkRead],
145            created_at: Utc::now(),
146            updated_at: Utc::now(),
147        }
148    }
149
150    #[test]
151    fn engine_accumulates_actions_from_multiple_matching_rules() {
152        let engine = RuleEngine::new(vec![archive_newsletters_rule(), mark_read_unsub_rule()]);
153        let msg = newsletter_msg();
154        let result = engine.evaluate(&msg, "msg_1");
155
156        assert_eq!(result.actions.len(), 2);
157        assert_eq!(result.matched_rules.len(), 2);
158    }
159
160    #[test]
161    fn engine_returns_empty_when_no_rules_match() {
162        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
163        let msg = work_email_msg();
164        let result = engine.evaluate(&msg, "msg_1");
165
166        assert!(result.actions.is_empty());
167        assert!(result.matched_rules.is_empty());
168    }
169
170    #[test]
171    fn disabled_rules_are_skipped() {
172        let mut rule = archive_newsletters_rule();
173        rule.enabled = false;
174        let engine = RuleEngine::new(vec![rule]);
175        let msg = newsletter_msg();
176        let result = engine.evaluate(&msg, "msg_1");
177
178        assert!(result.actions.is_empty());
179    }
180
181    #[test]
182    fn priority_determines_action_order() {
183        let mut low = mark_read_unsub_rule();
184        low.priority = 100;
185        low.actions = vec![RuleAction::MarkRead];
186
187        let mut high = archive_newsletters_rule();
188        high.priority = 1;
189        high.conditions = Conditions::Field(FieldCondition::HasUnsubscribe);
190        high.actions = vec![RuleAction::Archive];
191
192        // Insert in wrong order — engine should sort by priority
193        let engine = RuleEngine::new(vec![low, high]);
194        let msg = newsletter_msg();
195        let result = engine.evaluate(&msg, "msg_1");
196
197        // High priority (Archive) should come first
198        assert!(matches!(result.actions[0], RuleAction::Archive));
199        assert!(matches!(result.actions[1], RuleAction::MarkRead));
200    }
201
202    #[test]
203    fn batch_evaluate_filters_non_matching() {
204        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
205        let nl = newsletter_msg();
206        let work = work_email_msg();
207
208        let messages: Vec<(&dyn MessageView, &str)> =
209            vec![(&nl as &dyn MessageView, "msg_1"), (&work, "msg_2")];
210
211        let results = engine.evaluate_batch(&messages);
212
213        // Only the newsletter should match
214        assert_eq!(results.len(), 1);
215        assert_eq!(results[0].message_id, "msg_1");
216    }
217
218    #[test]
219    fn batch_evaluate_returns_empty_for_no_matches() {
220        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
221        let work = work_email_msg();
222
223        let messages: Vec<(&dyn MessageView, &str)> = vec![(&work as &dyn MessageView, "msg_1")];
224
225        let results = engine.evaluate_batch(&messages);
226        assert!(results.is_empty());
227    }
228
229    #[test]
230    fn dry_run_shows_what_would_match() {
231        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
232        let nl = newsletter_msg();
233        let work = work_email_msg();
234
235        let messages: Vec<(&dyn MessageView, &str, &str, &str)> = vec![
236            (
237                &nl as &dyn MessageView,
238                "msg_1",
239                "newsletter@substack.com",
240                "This Week in Rust",
241            ),
242            (
243                &work,
244                "msg_2",
245                "boss@company.com",
246                "Q1 Review",
247            ),
248        ];
249
250        let result = engine
251            .dry_run(&RuleId("r_archive".into()), &messages)
252            .unwrap();
253
254        assert_eq!(result.rule_name, "Archive newsletters");
255        assert_eq!(result.matches.len(), 1);
256        assert_eq!(result.matches[0].message_id, "msg_1");
257        assert_eq!(result.matches[0].from, "newsletter@substack.com");
258    }
259
260    #[test]
261    fn dry_run_returns_none_for_unknown_rule() {
262        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
263        let result = engine.dry_run(&RuleId("nonexistent".into()), &[]);
264        assert!(result.is_none());
265    }
266
267    #[test]
268    fn engine_preserves_message_id_in_result() {
269        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
270        let msg = newsletter_msg();
271        let result = engine.evaluate(&msg, "specific_msg_id_123");
272        assert_eq!(result.message_id, "specific_msg_id_123");
273    }
274
275    #[test]
276    fn multiple_actions_per_rule() {
277        let rule = Rule {
278            id: RuleId("r_multi".into()),
279            name: "Multi-action rule".into(),
280            enabled: true,
281            priority: 1,
282            conditions: Conditions::Field(FieldCondition::HasUnsubscribe),
283            actions: vec![
284                RuleAction::Archive,
285                RuleAction::MarkRead,
286                RuleAction::AddLabel {
287                    label: "auto-archived".into(),
288                },
289            ],
290            created_at: Utc::now(),
291            updated_at: Utc::now(),
292        };
293        let engine = RuleEngine::new(vec![rule]);
294        let msg = newsletter_msg();
295        let result = engine.evaluate(&msg, "msg_1");
296
297        assert_eq!(result.actions.len(), 3);
298    }
299}