1use serde::Serialize;
2use crate::condition::MessageView;
3use crate::{Rule, RuleAction, RuleId};
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(
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 pub fn dry_run(
88 &self,
89 rule_id: &RuleId,
90 messages: &[(&dyn MessageView, &str, &str, &str)], ) -> 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 let engine = RuleEngine::new(vec![low, high]);
194 let msg = newsletter_msg();
195 let result = engine.evaluate(&msg, "msg_1");
196
197 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 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}