1use crate::condition::MessageView;
2use crate::{Rule, RuleAction, RuleId};
3use serde::Serialize;
4
5pub struct RuleEngine {
7 rules: Vec<Rule>,
8}
9
10#[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#[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 rules.sort_by_key(|r| r.priority);
38 Self { rules }
39 }
40
41 pub fn rules(&self) -> &[Rule] {
42 &self.rules
43 }
44
45 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 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 pub fn dry_run(
85 &self,
86 rule_id: &RuleId,
87 messages: &[(&dyn MessageView, &str, &str, &str)], ) -> 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 let engine = RuleEngine::new(vec![low, high]);
191 let msg = newsletter_msg();
192 let result = engine.evaluate(&msg, "msg_1");
193
194 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 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}