Skip to main content

oximedia_workflow/
notification_system.rs

1// Copyright 2025 OxiMedia Contributors
2// Licensed under the Apache License, Version 2.0
3
4//! Notification system for workflow events.
5//!
6//! Provides a rule-based dispatcher that matches events and severities to
7//! channels, renders templates with variable substitution, and records every
8//! dispatch for auditing.
9
10/// Dispatch channel classification.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum DispatchChannel {
13    /// Electronic mail.
14    Email,
15    /// HTTP(S) webhook endpoint.
16    Webhook,
17    /// Slack workspace message.
18    Slack,
19    /// SMS short message.
20    Sms,
21    /// In-application notification.
22    InApp,
23}
24
25impl DispatchChannel {
26    /// Returns `true` for channels that involve external network calls.
27    #[must_use]
28    pub const fn is_external(self) -> bool {
29        !matches!(self, Self::InApp)
30    }
31
32    /// Typical round-trip latency expectation in milliseconds.
33    #[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/// A message template with `{{key}}` placeholder syntax.
46#[derive(Debug, Clone)]
47pub struct NotificationTemplate {
48    /// Short subject line.
49    pub subject: String,
50    /// Body text; may contain `{{key}}` placeholders.
51    pub body_template: String,
52}
53
54impl NotificationTemplate {
55    /// Create a new template.
56    #[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    /// Render the body by substituting every `{{key}}` with its value from `vars`.
65    ///
66    /// Unrecognised placeholders are left unchanged.
67    #[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/// Severity level for a notification event.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
80pub enum NotificationSeverity {
81    /// Informational message.
82    Info,
83    /// Non-critical warning.
84    Warning,
85    /// Recoverable error.
86    Error,
87    /// Unrecoverable / urgent error.
88    Critical,
89}
90
91impl NotificationSeverity {
92    /// Numeric level: higher is more severe (Info=0, Critical=3).
93    #[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/// A rule that maps a trigger + minimum severity to one or more channels.
105#[derive(Debug, Clone)]
106pub struct NotificationRule {
107    /// Event name that activates this rule.
108    pub trigger: String,
109    /// Channels to dispatch on when matched.
110    pub channels: Vec<DispatchChannel>,
111    /// Template to render for this rule.
112    pub template: NotificationTemplate,
113    /// Minimum severity level required to dispatch.
114    pub min_severity: NotificationSeverity,
115}
116
117impl NotificationRule {
118    /// Create a new rule.
119    #[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    /// Returns `true` when `trigger` matches and `severity >= min_severity`.
135    #[must_use]
136    pub fn matches(&self, trigger: &str, severity: NotificationSeverity) -> bool {
137        self.trigger == trigger && severity >= self.min_severity
138    }
139}
140
141/// Accumulates rules and records dispatched notifications.
142#[derive(Debug, Default)]
143pub struct NotificationDispatcher {
144    /// Registered rules.
145    pub rules: Vec<NotificationRule>,
146    /// Audit log: `(timestamp_ms, rendered_message)`.
147    pub sent: Vec<(u64, String)>,
148}
149
150impl NotificationDispatcher {
151    /// Create an empty dispatcher.
152    #[must_use]
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Register a rule.
158    pub fn add_rule(&mut self, r: NotificationRule) {
159        self.rules.push(r);
160    }
161
162    /// Evaluate all rules for `trigger` + `severity`.
163    ///
164    /// For every matching rule every channel is "dispatched" (recorded in the
165    /// audit log). Returns the total number of channel dispatches performed.
166    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        // Collect matching (rendered_body, channel_count) pairs first to
176        // avoid borrow issues.
177        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    /// Total number of channel dispatches recorded since creation.
198    #[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    // --- DispatchChannel ---
209
210    #[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    // --- NotificationTemplate ---
235
236    #[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    // --- NotificationSeverity ---
267
268    #[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    // --- NotificationRule ---
284
285    #[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    // --- NotificationDispatcher ---
334
335    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); // Email + Slack
353    }
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); // 2 channels × 2 dispatches
368    }
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}