Skip to main content

mxr_rules/
engine.rs

1use crate::condition::MessageView;
2use crate::{Rule, RuleAction, RuleId};
3use serde::Serialize;
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(&self, messages: &[(&dyn MessageView, &str)]) -> Vec<EvaluationResult> {
76        messages
77            .iter()
78            .map(|(msg, id)| self.evaluate(*msg, id))
79            .filter(|r| !r.actions.is_empty())
80            .collect()
81    }
82
83    /// Dry-run: evaluate a specific rule against messages without applying actions.
84    pub fn dry_run(
85        &self,
86        rule_id: &RuleId,
87        messages: &[(&dyn MessageView, &str, &str, &str)], // (msg, id, from, subject)
88    ) -> Option<DryRunResult> {
89        let rule = self.rules.iter().find(|r| &r.id == rule_id)?;
90
91        let matches: Vec<DryRunMatch> = messages
92            .iter()
93            .filter(|(msg, _, _, _)| rule.conditions.evaluate(*msg))
94            .map(|(_, id, from, subject)| DryRunMatch {
95                message_id: id.to_string(),
96                from: from.to_string(),
97                subject: subject.to_string(),
98                actions: rule.actions.clone(),
99            })
100            .collect();
101
102        Some(DryRunResult {
103            rule_id: rule.id.clone(),
104            rule_name: rule.name.clone(),
105            matches,
106        })
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::action::RuleAction;
114    use crate::condition::*;
115    use crate::tests::*;
116    use crate::{Rule, RuleId};
117    use chrono::Utc;
118
119    fn archive_newsletters_rule() -> Rule {
120        Rule {
121            id: RuleId("r_archive".into()),
122            name: "Archive newsletters".into(),
123            enabled: true,
124            priority: 10,
125            conditions: Conditions::Field(FieldCondition::HasLabel {
126                label: "newsletters".into(),
127            }),
128            actions: vec![RuleAction::Archive],
129            created_at: Utc::now(),
130            updated_at: Utc::now(),
131        }
132    }
133
134    fn mark_read_unsub_rule() -> Rule {
135        Rule {
136            id: RuleId("r_markread".into()),
137            name: "Mark read if unsubscribe available".into(),
138            enabled: true,
139            priority: 20,
140            conditions: Conditions::Field(FieldCondition::HasUnsubscribe),
141            actions: vec![RuleAction::MarkRead],
142            created_at: Utc::now(),
143            updated_at: Utc::now(),
144        }
145    }
146
147    #[test]
148    fn engine_accumulates_actions_from_multiple_matching_rules() {
149        let engine = RuleEngine::new(vec![archive_newsletters_rule(), mark_read_unsub_rule()]);
150        let msg = newsletter_msg();
151        let result = engine.evaluate(&msg, "msg_1");
152
153        assert_eq!(result.actions.len(), 2);
154        assert_eq!(result.matched_rules.len(), 2);
155    }
156
157    #[test]
158    fn engine_returns_empty_when_no_rules_match() {
159        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
160        let msg = work_email_msg();
161        let result = engine.evaluate(&msg, "msg_1");
162
163        assert!(result.actions.is_empty());
164        assert!(result.matched_rules.is_empty());
165    }
166
167    #[test]
168    fn disabled_rules_are_skipped() {
169        let mut rule = archive_newsletters_rule();
170        rule.enabled = false;
171        let engine = RuleEngine::new(vec![rule]);
172        let msg = newsletter_msg();
173        let result = engine.evaluate(&msg, "msg_1");
174
175        assert!(result.actions.is_empty());
176    }
177
178    #[test]
179    fn priority_determines_action_order() {
180        let mut low = mark_read_unsub_rule();
181        low.priority = 100;
182        low.actions = vec![RuleAction::MarkRead];
183
184        let mut high = archive_newsletters_rule();
185        high.priority = 1;
186        high.conditions = Conditions::Field(FieldCondition::HasUnsubscribe);
187        high.actions = vec![RuleAction::Archive];
188
189        // Insert in wrong order — engine should sort by priority
190        let engine = RuleEngine::new(vec![low, high]);
191        let msg = newsletter_msg();
192        let result = engine.evaluate(&msg, "msg_1");
193
194        // High priority (Archive) should come first
195        assert!(matches!(result.actions[0], RuleAction::Archive));
196        assert!(matches!(result.actions[1], RuleAction::MarkRead));
197    }
198
199    #[test]
200    fn batch_evaluate_filters_non_matching() {
201        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
202        let nl = newsletter_msg();
203        let work = work_email_msg();
204
205        let messages: Vec<(&dyn MessageView, &str)> =
206            vec![(&nl as &dyn MessageView, "msg_1"), (&work, "msg_2")];
207
208        let results = engine.evaluate_batch(&messages);
209
210        // Only the newsletter should match
211        assert_eq!(results.len(), 1);
212        assert_eq!(results[0].message_id, "msg_1");
213    }
214
215    #[test]
216    fn batch_evaluate_returns_empty_for_no_matches() {
217        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
218        let work = work_email_msg();
219
220        let messages: Vec<(&dyn MessageView, &str)> = vec![(&work as &dyn MessageView, "msg_1")];
221
222        let results = engine.evaluate_batch(&messages);
223        assert!(results.is_empty());
224    }
225
226    #[test]
227    fn dry_run_shows_what_would_match() {
228        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
229        let nl = newsletter_msg();
230        let work = work_email_msg();
231
232        let messages: Vec<(&dyn MessageView, &str, &str, &str)> = vec![
233            (
234                &nl as &dyn MessageView,
235                "msg_1",
236                "newsletter@substack.com",
237                "This Week in Rust",
238            ),
239            (&work, "msg_2", "boss@company.com", "Q1 Review"),
240        ];
241
242        let result = engine
243            .dry_run(&RuleId("r_archive".into()), &messages)
244            .unwrap();
245
246        assert_eq!(result.rule_name, "Archive newsletters");
247        assert_eq!(result.matches.len(), 1);
248        assert_eq!(result.matches[0].message_id, "msg_1");
249        assert_eq!(result.matches[0].from, "newsletter@substack.com");
250    }
251
252    #[test]
253    fn dry_run_returns_none_for_unknown_rule() {
254        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
255        let result = engine.dry_run(&RuleId("nonexistent".into()), &[]);
256        assert!(result.is_none());
257    }
258
259    #[test]
260    fn engine_preserves_message_id_in_result() {
261        let engine = RuleEngine::new(vec![archive_newsletters_rule()]);
262        let msg = newsletter_msg();
263        let result = engine.evaluate(&msg, "specific_msg_id_123");
264        assert_eq!(result.message_id, "specific_msg_id_123");
265    }
266
267    #[test]
268    fn multiple_actions_per_rule() {
269        let rule = Rule {
270            id: RuleId("r_multi".into()),
271            name: "Multi-action rule".into(),
272            enabled: true,
273            priority: 1,
274            conditions: Conditions::Field(FieldCondition::HasUnsubscribe),
275            actions: vec![
276                RuleAction::Archive,
277                RuleAction::MarkRead,
278                RuleAction::AddLabel {
279                    label: "auto-archived".into(),
280                },
281            ],
282            created_at: Utc::now(),
283            updated_at: Utc::now(),
284        };
285        let engine = RuleEngine::new(vec![rule]);
286        let msg = newsletter_msg();
287        let result = engine.evaluate(&msg, "msg_1");
288
289        assert_eq!(result.actions.len(), 3);
290    }
291}