1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum DispatchChannel {
13 Email,
15 Webhook,
17 Slack,
19 Sms,
21 InApp,
23}
24
25impl DispatchChannel {
26 #[must_use]
28 pub const fn is_external(self) -> bool {
29 !matches!(self, Self::InApp)
30 }
31
32 #[must_use]
34 pub const fn typical_latency_ms(self) -> u32 {
35 match self {
36 Self::InApp => 5,
37 Self::Webhook => 200,
38 Self::Slack => 300,
39 Self::Email => 5_000,
40 Self::Sms => 2_000,
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct NotificationTemplate {
48 pub subject: String,
50 pub body_template: String,
52}
53
54impl NotificationTemplate {
55 #[must_use]
57 pub fn new(subject: impl Into<String>, body_template: impl Into<String>) -> Self {
58 Self {
59 subject: subject.into(),
60 body_template: body_template.into(),
61 }
62 }
63
64 #[must_use]
68 pub fn render(&self, vars: &[(String, String)]) -> String {
69 let mut result = self.body_template.clone();
70 for (key, value) in vars {
71 let placeholder = format!("{{{{{key}}}}}");
72 result = result.replace(&placeholder, value);
73 }
74 result
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
80pub enum NotificationSeverity {
81 Info,
83 Warning,
85 Error,
87 Critical,
89}
90
91impl NotificationSeverity {
92 #[must_use]
94 pub const fn level(self) -> u8 {
95 match self {
96 Self::Info => 0,
97 Self::Warning => 1,
98 Self::Error => 2,
99 Self::Critical => 3,
100 }
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct NotificationRule {
107 pub trigger: String,
109 pub channels: Vec<DispatchChannel>,
111 pub template: NotificationTemplate,
113 pub min_severity: NotificationSeverity,
115}
116
117impl NotificationRule {
118 #[must_use]
120 pub fn new(
121 trigger: impl Into<String>,
122 channels: Vec<DispatchChannel>,
123 template: NotificationTemplate,
124 min_severity: NotificationSeverity,
125 ) -> Self {
126 Self {
127 trigger: trigger.into(),
128 channels,
129 template,
130 min_severity,
131 }
132 }
133
134 #[must_use]
136 pub fn matches(&self, trigger: &str, severity: NotificationSeverity) -> bool {
137 self.trigger == trigger && severity >= self.min_severity
138 }
139}
140
141#[derive(Debug, Default)]
143pub struct NotificationDispatcher {
144 pub rules: Vec<NotificationRule>,
146 pub sent: Vec<(u64, String)>,
148}
149
150impl NotificationDispatcher {
151 #[must_use]
153 pub fn new() -> Self {
154 Self::default()
155 }
156
157 pub fn add_rule(&mut self, r: NotificationRule) {
159 self.rules.push(r);
160 }
161
162 pub fn dispatch(
167 &mut self,
168 trigger: &str,
169 severity: NotificationSeverity,
170 vars: &[(String, String)],
171 now_ms: u64,
172 ) -> usize {
173 let mut count = 0;
174
175 let dispatches: Vec<(String, usize)> = self
178 .rules
179 .iter()
180 .filter(|r| r.matches(trigger, severity))
181 .map(|r| {
182 let body = r.template.render(vars);
183 (body, r.channels.len())
184 })
185 .collect();
186
187 for (body, ch_count) in dispatches {
188 for _ in 0..ch_count {
189 self.sent.push((now_ms, body.clone()));
190 count += 1;
191 }
192 }
193
194 count
195 }
196
197 #[must_use]
199 pub fn total_sent(&self) -> usize {
200 self.sent.len()
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
211 fn test_in_app_is_not_external() {
212 assert!(!DispatchChannel::InApp.is_external());
213 }
214
215 #[test]
216 fn test_external_channels() {
217 assert!(DispatchChannel::Email.is_external());
218 assert!(DispatchChannel::Webhook.is_external());
219 assert!(DispatchChannel::Slack.is_external());
220 assert!(DispatchChannel::Sms.is_external());
221 }
222
223 #[test]
224 fn test_typical_latency_ordering() {
225 assert!(
226 DispatchChannel::InApp.typical_latency_ms()
227 < DispatchChannel::Webhook.typical_latency_ms()
228 );
229 assert!(
230 DispatchChannel::Sms.typical_latency_ms() < DispatchChannel::Email.typical_latency_ms()
231 );
232 }
233
234 #[test]
237 fn test_render_replaces_placeholder() {
238 let t = NotificationTemplate::new("Subject", "Hello {{name}}!");
239 let rendered = t.render(&[("name".to_string(), "World".to_string())]);
240 assert_eq!(rendered, "Hello World!");
241 }
242
243 #[test]
244 fn test_render_multiple_placeholders() {
245 let t = NotificationTemplate::new("S", "{{a}} and {{b}}");
246 let rendered = t.render(&[
247 ("a".to_string(), "foo".to_string()),
248 ("b".to_string(), "bar".to_string()),
249 ]);
250 assert_eq!(rendered, "foo and bar");
251 }
252
253 #[test]
254 fn test_render_unknown_placeholder_unchanged() {
255 let t = NotificationTemplate::new("S", "Hello {{unknown}}");
256 let rendered = t.render(&[("name".to_string(), "X".to_string())]);
257 assert_eq!(rendered, "Hello {{unknown}}");
258 }
259
260 #[test]
261 fn test_render_no_vars() {
262 let t = NotificationTemplate::new("S", "plain text");
263 assert_eq!(t.render(&[]), "plain text");
264 }
265
266 #[test]
269 fn test_severity_levels() {
270 assert_eq!(NotificationSeverity::Info.level(), 0);
271 assert_eq!(NotificationSeverity::Warning.level(), 1);
272 assert_eq!(NotificationSeverity::Error.level(), 2);
273 assert_eq!(NotificationSeverity::Critical.level(), 3);
274 }
275
276 #[test]
277 fn test_severity_ordering() {
278 assert!(NotificationSeverity::Info < NotificationSeverity::Warning);
279 assert!(NotificationSeverity::Warning < NotificationSeverity::Error);
280 assert!(NotificationSeverity::Error < NotificationSeverity::Critical);
281 }
282
283 #[test]
286 fn test_rule_matches_exact() {
287 let t = NotificationTemplate::new("S", "B");
288 let r = NotificationRule::new(
289 "job_done",
290 vec![DispatchChannel::InApp],
291 t,
292 NotificationSeverity::Info,
293 );
294 assert!(r.matches("job_done", NotificationSeverity::Info));
295 }
296
297 #[test]
298 fn test_rule_matches_higher_severity() {
299 let t = NotificationTemplate::new("S", "B");
300 let r = NotificationRule::new(
301 "job_done",
302 vec![DispatchChannel::InApp],
303 t,
304 NotificationSeverity::Info,
305 );
306 assert!(r.matches("job_done", NotificationSeverity::Critical));
307 }
308
309 #[test]
310 fn test_rule_does_not_match_lower_severity() {
311 let t = NotificationTemplate::new("S", "B");
312 let r = NotificationRule::new(
313 "job_done",
314 vec![DispatchChannel::Email],
315 t,
316 NotificationSeverity::Error,
317 );
318 assert!(!r.matches("job_done", NotificationSeverity::Warning));
319 }
320
321 #[test]
322 fn test_rule_does_not_match_different_trigger() {
323 let t = NotificationTemplate::new("S", "B");
324 let r = NotificationRule::new(
325 "job_done",
326 vec![DispatchChannel::Email],
327 t,
328 NotificationSeverity::Info,
329 );
330 assert!(!r.matches("job_failed", NotificationSeverity::Critical));
331 }
332
333 fn make_dispatcher() -> NotificationDispatcher {
336 let mut d = NotificationDispatcher::new();
337 let t = NotificationTemplate::new("Alert", "Job {{job}} finished.");
338 d.add_rule(NotificationRule::new(
339 "job_complete",
340 vec![DispatchChannel::Email, DispatchChannel::Slack],
341 t,
342 NotificationSeverity::Info,
343 ));
344 d
345 }
346
347 #[test]
348 fn test_dispatch_returns_channel_count() {
349 let mut d = make_dispatcher();
350 let vars = vec![("job".to_string(), "encode_001".to_string())];
351 let sent = d.dispatch("job_complete", NotificationSeverity::Info, &vars, 1000);
352 assert_eq!(sent, 2); }
354
355 #[test]
356 fn test_dispatch_no_match_returns_zero() {
357 let mut d = make_dispatcher();
358 let sent = d.dispatch("unknown_event", NotificationSeverity::Critical, &[], 1000);
359 assert_eq!(sent, 0);
360 }
361
362 #[test]
363 fn test_total_sent_accumulates() {
364 let mut d = make_dispatcher();
365 d.dispatch("job_complete", NotificationSeverity::Info, &[], 1000);
366 d.dispatch("job_complete", NotificationSeverity::Warning, &[], 2000);
367 assert_eq!(d.total_sent(), 4); }
369
370 #[test]
371 fn test_dispatch_renders_template() {
372 let mut d = make_dispatcher();
373 let vars = vec![("job".to_string(), "my-job".to_string())];
374 d.dispatch("job_complete", NotificationSeverity::Info, &vars, 5000);
375 assert!(d.sent[0].1.contains("my-job"));
376 }
377}