#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DispatchChannel {
Email,
Webhook,
Slack,
Sms,
InApp,
}
impl DispatchChannel {
#[must_use]
pub const fn is_external(self) -> bool {
!matches!(self, Self::InApp)
}
#[must_use]
pub const fn typical_latency_ms(self) -> u32 {
match self {
Self::InApp => 5,
Self::Webhook => 200,
Self::Slack => 300,
Self::Email => 5_000,
Self::Sms => 2_000,
}
}
}
#[derive(Debug, Clone)]
pub struct NotificationTemplate {
pub subject: String,
pub body_template: String,
}
impl NotificationTemplate {
#[must_use]
pub fn new(subject: impl Into<String>, body_template: impl Into<String>) -> Self {
Self {
subject: subject.into(),
body_template: body_template.into(),
}
}
#[must_use]
pub fn render(&self, vars: &[(String, String)]) -> String {
let mut result = self.body_template.clone();
for (key, value) in vars {
let placeholder = format!("{{{{{key}}}}}");
result = result.replace(&placeholder, value);
}
result
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum NotificationSeverity {
Info,
Warning,
Error,
Critical,
}
impl NotificationSeverity {
#[must_use]
pub const fn level(self) -> u8 {
match self {
Self::Info => 0,
Self::Warning => 1,
Self::Error => 2,
Self::Critical => 3,
}
}
}
#[derive(Debug, Clone)]
pub struct NotificationRule {
pub trigger: String,
pub channels: Vec<DispatchChannel>,
pub template: NotificationTemplate,
pub min_severity: NotificationSeverity,
}
impl NotificationRule {
#[must_use]
pub fn new(
trigger: impl Into<String>,
channels: Vec<DispatchChannel>,
template: NotificationTemplate,
min_severity: NotificationSeverity,
) -> Self {
Self {
trigger: trigger.into(),
channels,
template,
min_severity,
}
}
#[must_use]
pub fn matches(&self, trigger: &str, severity: NotificationSeverity) -> bool {
self.trigger == trigger && severity >= self.min_severity
}
}
#[derive(Debug, Default)]
pub struct NotificationDispatcher {
pub rules: Vec<NotificationRule>,
pub sent: Vec<(u64, String)>,
}
impl NotificationDispatcher {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_rule(&mut self, r: NotificationRule) {
self.rules.push(r);
}
pub fn dispatch(
&mut self,
trigger: &str,
severity: NotificationSeverity,
vars: &[(String, String)],
now_ms: u64,
) -> usize {
let mut count = 0;
let dispatches: Vec<(String, usize)> = self
.rules
.iter()
.filter(|r| r.matches(trigger, severity))
.map(|r| {
let body = r.template.render(vars);
(body, r.channels.len())
})
.collect();
for (body, ch_count) in dispatches {
for _ in 0..ch_count {
self.sent.push((now_ms, body.clone()));
count += 1;
}
}
count
}
#[must_use]
pub fn total_sent(&self) -> usize {
self.sent.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_in_app_is_not_external() {
assert!(!DispatchChannel::InApp.is_external());
}
#[test]
fn test_external_channels() {
assert!(DispatchChannel::Email.is_external());
assert!(DispatchChannel::Webhook.is_external());
assert!(DispatchChannel::Slack.is_external());
assert!(DispatchChannel::Sms.is_external());
}
#[test]
fn test_typical_latency_ordering() {
assert!(
DispatchChannel::InApp.typical_latency_ms()
< DispatchChannel::Webhook.typical_latency_ms()
);
assert!(
DispatchChannel::Sms.typical_latency_ms() < DispatchChannel::Email.typical_latency_ms()
);
}
#[test]
fn test_render_replaces_placeholder() {
let t = NotificationTemplate::new("Subject", "Hello {{name}}!");
let rendered = t.render(&[("name".to_string(), "World".to_string())]);
assert_eq!(rendered, "Hello World!");
}
#[test]
fn test_render_multiple_placeholders() {
let t = NotificationTemplate::new("S", "{{a}} and {{b}}");
let rendered = t.render(&[
("a".to_string(), "foo".to_string()),
("b".to_string(), "bar".to_string()),
]);
assert_eq!(rendered, "foo and bar");
}
#[test]
fn test_render_unknown_placeholder_unchanged() {
let t = NotificationTemplate::new("S", "Hello {{unknown}}");
let rendered = t.render(&[("name".to_string(), "X".to_string())]);
assert_eq!(rendered, "Hello {{unknown}}");
}
#[test]
fn test_render_no_vars() {
let t = NotificationTemplate::new("S", "plain text");
assert_eq!(t.render(&[]), "plain text");
}
#[test]
fn test_severity_levels() {
assert_eq!(NotificationSeverity::Info.level(), 0);
assert_eq!(NotificationSeverity::Warning.level(), 1);
assert_eq!(NotificationSeverity::Error.level(), 2);
assert_eq!(NotificationSeverity::Critical.level(), 3);
}
#[test]
fn test_severity_ordering() {
assert!(NotificationSeverity::Info < NotificationSeverity::Warning);
assert!(NotificationSeverity::Warning < NotificationSeverity::Error);
assert!(NotificationSeverity::Error < NotificationSeverity::Critical);
}
#[test]
fn test_rule_matches_exact() {
let t = NotificationTemplate::new("S", "B");
let r = NotificationRule::new(
"job_done",
vec![DispatchChannel::InApp],
t,
NotificationSeverity::Info,
);
assert!(r.matches("job_done", NotificationSeverity::Info));
}
#[test]
fn test_rule_matches_higher_severity() {
let t = NotificationTemplate::new("S", "B");
let r = NotificationRule::new(
"job_done",
vec![DispatchChannel::InApp],
t,
NotificationSeverity::Info,
);
assert!(r.matches("job_done", NotificationSeverity::Critical));
}
#[test]
fn test_rule_does_not_match_lower_severity() {
let t = NotificationTemplate::new("S", "B");
let r = NotificationRule::new(
"job_done",
vec![DispatchChannel::Email],
t,
NotificationSeverity::Error,
);
assert!(!r.matches("job_done", NotificationSeverity::Warning));
}
#[test]
fn test_rule_does_not_match_different_trigger() {
let t = NotificationTemplate::new("S", "B");
let r = NotificationRule::new(
"job_done",
vec![DispatchChannel::Email],
t,
NotificationSeverity::Info,
);
assert!(!r.matches("job_failed", NotificationSeverity::Critical));
}
fn make_dispatcher() -> NotificationDispatcher {
let mut d = NotificationDispatcher::new();
let t = NotificationTemplate::new("Alert", "Job {{job}} finished.");
d.add_rule(NotificationRule::new(
"job_complete",
vec![DispatchChannel::Email, DispatchChannel::Slack],
t,
NotificationSeverity::Info,
));
d
}
#[test]
fn test_dispatch_returns_channel_count() {
let mut d = make_dispatcher();
let vars = vec![("job".to_string(), "encode_001".to_string())];
let sent = d.dispatch("job_complete", NotificationSeverity::Info, &vars, 1000);
assert_eq!(sent, 2); }
#[test]
fn test_dispatch_no_match_returns_zero() {
let mut d = make_dispatcher();
let sent = d.dispatch("unknown_event", NotificationSeverity::Critical, &[], 1000);
assert_eq!(sent, 0);
}
#[test]
fn test_total_sent_accumulates() {
let mut d = make_dispatcher();
d.dispatch("job_complete", NotificationSeverity::Info, &[], 1000);
d.dispatch("job_complete", NotificationSeverity::Warning, &[], 2000);
assert_eq!(d.total_sent(), 4); }
#[test]
fn test_dispatch_renders_template() {
let mut d = make_dispatcher();
let vars = vec![("job".to_string(), "my-job".to_string())];
d.dispatch("job_complete", NotificationSeverity::Info, &vars, 5000);
assert!(d.sent[0].1.contains("my-job"));
}
}