Skip to main content

oximedia_workflow/
notification_sink.rs

1//! Notification sink integrations for workflow events.
2//!
3//! This module provides a [`NotificationSink`] trait and three concrete
4//! implementations:
5//!
6//! - [`SlackSink`]: sends messages to a Slack webhook URL with configurable
7//!   channel, username, and icon.
8//! - [`EmailSink`]: produces RFC 5321-formatted SMTP messages (transport-agnostic;
9//!   does not initiate TCP connections — callers relay the [`EmailMessage`]).
10//! - [`PagerDutySink`]: builds PagerDuty Events API v2 JSON payloads with
11//!   severity mapping and deduplication keys.
12//!
13//! [`NotificationRouter`] dispatches a single [`SinkEvent`] to multiple sinks
14//! concurrently and collects per-sink outcomes.
15//!
16//! # Design
17//!
18//! All sinks are **transport-agnostic**: they build and return payloads /
19//! messages rather than performing actual network I/O.  This keeps the module
20//! free of async HTTP clients and makes it trivially testable.  Callers provide
21//! their own transport (reqwest, hyper, `tokio::net::TcpStream`, etc.).
22//!
23//! # Example
24//!
25//! ```rust
26//! use oximedia_workflow::notification_sink::{
27//!     NotificationRouter, SlackSink, EmailSink, PagerDutySink,
28//!     SmtpConfig, PagerDutyConfig, SinkEvent, PagerDutySeverity,
29//! };
30//!
31//! let slack = SlackSink::new("https://hooks.slack.com/T000/B000/xxx")
32//!     .with_channel("#ops")
33//!     .with_username("OxiMedia Bot");
34//!
35//! let email = EmailSink::new(SmtpConfig {
36//!     from: "ops@example.com".to_string(),
37//!     to: vec!["alerts@example.com".to_string()],
38//!     subject_prefix: "[OxiMedia]".to_string(),
39//! });
40//!
41//! let pager = PagerDutySink::new(PagerDutyConfig {
42//!     routing_key: "abc123".to_string(),
43//!     default_severity: PagerDutySeverity::Error,
44//!     service_name: "OxiMedia Pipeline".to_string(),
45//! });
46//!
47//! let mut router = NotificationRouter::new();
48//! router.add_sink(Box::new(slack));
49//! router.add_sink(Box::new(email));
50//! router.add_sink(Box::new(pager));
51//!
52//! let event = SinkEvent::workflow_failed("wf-001", "transcode-pipeline", "disk full");
53//! let outcomes = router.dispatch(&event);
54//! assert_eq!(outcomes.outcomes.len(), 3);
55//! ```
56
57use std::collections::HashMap;
58
59// ---------------------------------------------------------------------------
60// SinkEvent
61// ---------------------------------------------------------------------------
62
63/// Severity level for a sink event.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
65pub enum EventSeverity {
66    /// Informational.
67    Info,
68    /// Non-critical warning.
69    Warning,
70    /// Recoverable error.
71    Error,
72    /// Unrecoverable / page-worthy.
73    Critical,
74}
75
76impl EventSeverity {
77    /// Short lowercase label.
78    #[must_use]
79    pub const fn label(self) -> &'static str {
80        match self {
81            Self::Info => "info",
82            Self::Warning => "warning",
83            Self::Error => "error",
84            Self::Critical => "critical",
85        }
86    }
87
88    /// Numeric level (0 = info, 3 = critical).
89    #[must_use]
90    pub const fn level(self) -> u8 {
91        match self {
92            Self::Info => 0,
93            Self::Warning => 1,
94            Self::Error => 2,
95            Self::Critical => 3,
96        }
97    }
98}
99
100impl std::fmt::Display for EventSeverity {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(self.label())
103    }
104}
105
106/// An event emitted by the workflow engine that should be forwarded to sinks.
107#[derive(Debug, Clone)]
108pub struct SinkEvent {
109    /// Short machine-readable event type (e.g. `"workflow.failed"`).
110    pub event_type: String,
111    /// Human-readable title for the notification.
112    pub title: String,
113    /// Detailed description / error message.
114    pub body: String,
115    /// Severity level used for routing and PagerDuty mapping.
116    pub severity: EventSeverity,
117    /// Arbitrary key-value metadata (workflow_id, task_name, etc.).
118    pub metadata: HashMap<String, String>,
119    /// Unix epoch in milliseconds.
120    pub timestamp_ms: u64,
121    /// Optional deduplication key (used by PagerDuty and idempotent sinks).
122    pub dedup_key: Option<String>,
123}
124
125impl SinkEvent {
126    /// Create a fully specified event.
127    #[must_use]
128    pub fn new(
129        event_type: impl Into<String>,
130        title: impl Into<String>,
131        body: impl Into<String>,
132        severity: EventSeverity,
133    ) -> Self {
134        Self {
135            event_type: event_type.into(),
136            title: title.into(),
137            body: body.into(),
138            severity,
139            metadata: HashMap::new(),
140            timestamp_ms: current_timestamp_ms(),
141            dedup_key: None,
142        }
143    }
144
145    /// Convenience constructor for a workflow-completed event.
146    #[must_use]
147    pub fn workflow_completed(workflow_id: &str, workflow_name: &str) -> Self {
148        let mut ev = Self::new(
149            "workflow.completed",
150            format!("Workflow completed: {workflow_name}"),
151            format!("Workflow '{workflow_name}' (id={workflow_id}) completed successfully."),
152            EventSeverity::Info,
153        );
154        ev.metadata
155            .insert("workflow_id".to_string(), workflow_id.to_string());
156        ev.metadata
157            .insert("workflow_name".to_string(), workflow_name.to_string());
158        ev
159    }
160
161    /// Convenience constructor for a workflow-failed event.
162    #[must_use]
163    pub fn workflow_failed(workflow_id: &str, workflow_name: &str, reason: &str) -> Self {
164        let mut ev = Self::new(
165            "workflow.failed",
166            format!("Workflow FAILED: {workflow_name}"),
167            format!("Workflow '{workflow_name}' (id={workflow_id}) failed: {reason}"),
168            EventSeverity::Error,
169        );
170        ev.metadata
171            .insert("workflow_id".to_string(), workflow_id.to_string());
172        ev.metadata
173            .insert("workflow_name".to_string(), workflow_name.to_string());
174        ev.metadata.insert("reason".to_string(), reason.to_string());
175        ev.dedup_key = Some(format!("wf-failed-{workflow_id}"));
176        ev
177    }
178
179    /// Convenience constructor for a task-failed event.
180    #[must_use]
181    pub fn task_failed(workflow_id: &str, task_name: &str, reason: &str) -> Self {
182        let mut ev = Self::new(
183            "task.failed",
184            format!("Task FAILED: {task_name}"),
185            format!("Task '{task_name}' in workflow {workflow_id} failed: {reason}"),
186            EventSeverity::Warning,
187        );
188        ev.metadata
189            .insert("workflow_id".to_string(), workflow_id.to_string());
190        ev.metadata
191            .insert("task_name".to_string(), task_name.to_string());
192        ev
193    }
194
195    /// Builder: add a metadata key-value pair.
196    #[must_use]
197    pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
198        self.metadata.insert(key.into(), value.into());
199        self
200    }
201
202    /// Builder: set a deduplication key.
203    #[must_use]
204    pub fn with_dedup_key(mut self, key: impl Into<String>) -> Self {
205        self.dedup_key = Some(key.into());
206        self
207    }
208}
209
210fn current_timestamp_ms() -> u64 {
211    std::time::SystemTime::now()
212        .duration_since(std::time::UNIX_EPOCH)
213        .unwrap_or_default()
214        .as_millis() as u64
215}
216
217// ---------------------------------------------------------------------------
218// NotificationSink trait
219// ---------------------------------------------------------------------------
220
221/// Outcome of a single sink dispatch attempt.
222#[derive(Debug, Clone)]
223pub struct SinkOutcome {
224    /// Name of the sink (for logging and debugging).
225    pub sink_name: String,
226    /// Whether the sink accepted and processed the event.
227    pub success: bool,
228    /// Human-readable summary of what the sink produced or the error message.
229    pub message: String,
230}
231
232impl SinkOutcome {
233    /// Create a successful outcome.
234    #[must_use]
235    pub fn ok(sink_name: impl Into<String>, message: impl Into<String>) -> Self {
236        Self {
237            sink_name: sink_name.into(),
238            success: true,
239            message: message.into(),
240        }
241    }
242
243    /// Create a failed outcome.
244    #[must_use]
245    pub fn err(sink_name: impl Into<String>, message: impl Into<String>) -> Self {
246        Self {
247            sink_name: sink_name.into(),
248            success: false,
249            message: message.into(),
250        }
251    }
252}
253
254/// A destination that can receive workflow notification events.
255///
256/// Implementations are transport-agnostic: they build payloads and return
257/// [`SinkOutcome`]s describing what would be sent.
258pub trait NotificationSink: Send + Sync {
259    /// Short, stable name for this sink (e.g. `"slack"`, `"email"`).
260    fn name(&self) -> &str;
261
262    /// Dispatch `event` to this sink and return the outcome.
263    ///
264    /// Implementations should never panic; all errors must be captured in the
265    /// returned [`SinkOutcome`].
266    fn dispatch(&self, event: &SinkEvent) -> SinkOutcome;
267
268    /// Return `true` when this sink should receive events of the given severity.
269    ///
270    /// The default implementation accepts all severities.
271    fn accepts_severity(&self, _severity: EventSeverity) -> bool {
272        true
273    }
274}
275
276// ---------------------------------------------------------------------------
277// SlackSink
278// ---------------------------------------------------------------------------
279
280/// A Slack Block Kit / incoming-webhook message ready to POST.
281#[derive(Debug, Clone)]
282pub struct SlackMessage {
283    /// Slack channel override (e.g. `"#ops"`).
284    pub channel: Option<String>,
285    /// Bot username shown in Slack.
286    pub username: Option<String>,
287    /// Emoji icon (e.g. `":rotating_light:"`).
288    pub icon_emoji: Option<String>,
289    /// Serialized JSON body of the Slack API payload.
290    pub payload_json: String,
291}
292
293/// Slack incoming-webhook sink.
294///
295/// Builds a JSON payload suitable for Slack's incoming-webhook API.
296/// Does **not** perform HTTP POST — callers forward [`SlackMessage::payload_json`].
297#[derive(Debug, Clone)]
298pub struct SlackSink {
299    /// Incoming-webhook URL (e.g. `"https://hooks.slack.com/T…/B…/xxx"`).
300    pub webhook_url: String,
301    /// Optional channel override.
302    pub channel: Option<String>,
303    /// Optional bot username.
304    pub username: Option<String>,
305    /// Optional emoji icon.
306    pub icon_emoji: Option<String>,
307    /// Minimum severity to forward (default: Info).
308    pub min_severity: EventSeverity,
309}
310
311impl SlackSink {
312    /// Create a new Slack sink for the given webhook URL.
313    #[must_use]
314    pub fn new(webhook_url: impl Into<String>) -> Self {
315        Self {
316            webhook_url: webhook_url.into(),
317            channel: None,
318            username: None,
319            icon_emoji: None,
320            min_severity: EventSeverity::Info,
321        }
322    }
323
324    /// Set the target channel.
325    #[must_use]
326    pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
327        self.channel = Some(channel.into());
328        self
329    }
330
331    /// Set the bot username.
332    #[must_use]
333    pub fn with_username(mut self, username: impl Into<String>) -> Self {
334        self.username = Some(username.into());
335        self
336    }
337
338    /// Set the emoji icon.
339    #[must_use]
340    pub fn with_icon(mut self, emoji: impl Into<String>) -> Self {
341        self.icon_emoji = Some(emoji.into());
342        self
343    }
344
345    /// Set the minimum severity for this sink.
346    #[must_use]
347    pub fn with_min_severity(mut self, min: EventSeverity) -> Self {
348        self.min_severity = min;
349        self
350    }
351
352    /// Build the Slack message for `event` without sending it.
353    #[must_use]
354    pub fn build_message(&self, event: &SinkEvent) -> SlackMessage {
355        let color = match event.severity {
356            EventSeverity::Info => "#36a64f",
357            EventSeverity::Warning => "#ffcc00",
358            EventSeverity::Error => "#ff4444",
359            EventSeverity::Critical => "#cc0000",
360        };
361
362        let attachment = serde_json::json!({
363            "color": color,
364            "title": event.title,
365            "text": event.body,
366            "footer": format!("severity={} type={}", event.severity.label(), event.event_type),
367            "ts": event.timestamp_ms / 1000,
368        });
369
370        let mut payload = serde_json::json!({
371            "attachments": [attachment],
372        });
373
374        if let Some(ch) = &self.channel {
375            payload["channel"] = serde_json::json!(ch);
376        }
377        if let Some(un) = &self.username {
378            payload["username"] = serde_json::json!(un);
379        }
380        if let Some(ic) = &self.icon_emoji {
381            payload["icon_emoji"] = serde_json::json!(ic);
382        }
383
384        SlackMessage {
385            channel: self.channel.clone(),
386            username: self.username.clone(),
387            icon_emoji: self.icon_emoji.clone(),
388            payload_json: payload.to_string(),
389        }
390    }
391}
392
393impl NotificationSink for SlackSink {
394    fn name(&self) -> &str {
395        "slack"
396    }
397
398    fn dispatch(&self, event: &SinkEvent) -> SinkOutcome {
399        if !self.accepts_severity(event.severity) {
400            return SinkOutcome::ok(
401                "slack",
402                format!(
403                    "skipped: severity {} below minimum {}",
404                    event.severity.label(),
405                    self.min_severity.label()
406                ),
407            );
408        }
409        let msg = self.build_message(event);
410        SinkOutcome::ok(
411            "slack",
412            format!(
413                "POST {} payload_len={}",
414                self.webhook_url,
415                msg.payload_json.len()
416            ),
417        )
418    }
419
420    fn accepts_severity(&self, severity: EventSeverity) -> bool {
421        severity >= self.min_severity
422    }
423}
424
425// ---------------------------------------------------------------------------
426// EmailSink
427// ---------------------------------------------------------------------------
428
429/// SMTP configuration for the email sink.
430#[derive(Debug, Clone)]
431pub struct SmtpConfig {
432    /// Sender address.
433    pub from: String,
434    /// List of recipient addresses.
435    pub to: Vec<String>,
436    /// Subject line prefix (e.g. `"[OxiMedia]"`).
437    pub subject_prefix: String,
438}
439
440/// An RFC 5321-formatted email message ready to relay.
441#[derive(Debug, Clone)]
442pub struct EmailMessage {
443    /// MAIL FROM envelope address.
444    pub from: String,
445    /// RCPT TO addresses.
446    pub to: Vec<String>,
447    /// Subject line.
448    pub subject: String,
449    /// Plain-text body.
450    pub body: String,
451}
452
453/// Email notification sink.
454///
455/// Produces [`EmailMessage`] values formatted for SMTP delivery.
456/// Transport (connection, TLS, AUTH) is left to the caller.
457#[derive(Debug, Clone)]
458pub struct EmailSink {
459    /// SMTP configuration.
460    pub config: SmtpConfig,
461    /// Minimum severity to forward (default: Warning).
462    pub min_severity: EventSeverity,
463}
464
465impl EmailSink {
466    /// Create a new email sink.
467    #[must_use]
468    pub fn new(config: SmtpConfig) -> Self {
469        Self {
470            config,
471            min_severity: EventSeverity::Warning,
472        }
473    }
474
475    /// Set the minimum severity.
476    #[must_use]
477    pub fn with_min_severity(mut self, min: EventSeverity) -> Self {
478        self.min_severity = min;
479        self
480    }
481
482    /// Build the email message for `event` without sending it.
483    #[must_use]
484    pub fn build_message(&self, event: &SinkEvent) -> EmailMessage {
485        let subject = format!(
486            "{} [{}] {}",
487            self.config.subject_prefix,
488            event.severity.label().to_uppercase(),
489            event.title
490        );
491
492        let mut body_lines = vec![
493            event.body.clone(),
494            String::new(),
495            format!("Event type : {}", event.event_type),
496            format!("Severity   : {}", event.severity.label()),
497            format!("Timestamp  : {}ms", event.timestamp_ms),
498        ];
499
500        if !event.metadata.is_empty() {
501            body_lines.push(String::new());
502            body_lines.push("Metadata:".to_string());
503            let mut sorted: Vec<(&String, &String)> = event.metadata.iter().collect();
504            sorted.sort_by_key(|(k, _)| k.as_str());
505            for (k, v) in sorted {
506                body_lines.push(format!("  {k}: {v}"));
507            }
508        }
509
510        EmailMessage {
511            from: self.config.from.clone(),
512            to: self.config.to.clone(),
513            subject,
514            body: body_lines.join("\n"),
515        }
516    }
517}
518
519impl NotificationSink for EmailSink {
520    fn name(&self) -> &str {
521        "email"
522    }
523
524    fn dispatch(&self, event: &SinkEvent) -> SinkOutcome {
525        if !self.accepts_severity(event.severity) {
526            return SinkOutcome::ok(
527                "email",
528                format!(
529                    "skipped: severity {} below minimum {}",
530                    event.severity.label(),
531                    self.min_severity.label()
532                ),
533            );
534        }
535        let msg = self.build_message(event);
536        SinkOutcome::ok(
537            "email",
538            format!(
539                "SMTP from={} to={} subject={:?}",
540                msg.from,
541                msg.to.join(","),
542                msg.subject
543            ),
544        )
545    }
546
547    fn accepts_severity(&self, severity: EventSeverity) -> bool {
548        severity >= self.min_severity
549    }
550}
551
552// ---------------------------------------------------------------------------
553// PagerDutySink
554// ---------------------------------------------------------------------------
555
556/// PagerDuty Events API v2 severity levels.
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
558pub enum PagerDutySeverity {
559    /// PD `info`.
560    Info,
561    /// PD `warning`.
562    Warning,
563    /// PD `error`.
564    Error,
565    /// PD `critical`.
566    Critical,
567}
568
569impl PagerDutySeverity {
570    /// The lowercase string used in PD API payloads.
571    #[must_use]
572    pub const fn as_str(self) -> &'static str {
573        match self {
574            Self::Info => "info",
575            Self::Warning => "warning",
576            Self::Error => "error",
577            Self::Critical => "critical",
578        }
579    }
580
581    /// Map an [`EventSeverity`] to the closest PagerDuty severity.
582    #[must_use]
583    pub fn from_event_severity(s: EventSeverity) -> Self {
584        match s {
585            EventSeverity::Info => Self::Info,
586            EventSeverity::Warning => Self::Warning,
587            EventSeverity::Error => Self::Error,
588            EventSeverity::Critical => Self::Critical,
589        }
590    }
591}
592
593/// Configuration for the PagerDuty Events API v2 sink.
594#[derive(Debug, Clone)]
595pub struct PagerDutyConfig {
596    /// PagerDuty Integration Key (aka routing key).
597    pub routing_key: String,
598    /// Default severity when the event does not carry one.
599    pub default_severity: PagerDutySeverity,
600    /// Human-readable service / component name included in the payload.
601    pub service_name: String,
602}
603
604/// A PagerDuty Events API v2 payload ready to POST to
605/// `https://events.pagerduty.com/v2/enqueue`.
606#[derive(Debug, Clone)]
607pub struct PagerDutyPayload {
608    /// JSON body string.
609    pub body_json: String,
610    /// The deduplication key embedded in the payload.
611    pub dedup_key: String,
612}
613
614/// PagerDuty notification sink.
615///
616/// Builds Events API v2 payloads.  Does **not** perform HTTP POST — callers
617/// must send [`PagerDutyPayload::body_json`] to the PD enqueue endpoint.
618///
619/// Only events with severity >= [`EventSeverity::Warning`] are forwarded by
620/// default; override with [`PagerDutySink::with_min_severity`].
621#[derive(Debug, Clone)]
622pub struct PagerDutySink {
623    /// PagerDuty configuration.
624    pub config: PagerDutyConfig,
625    /// Minimum severity to forward (default: Warning).
626    pub min_severity: EventSeverity,
627}
628
629impl PagerDutySink {
630    /// Create a new PagerDuty sink.
631    #[must_use]
632    pub fn new(config: PagerDutyConfig) -> Self {
633        Self {
634            config,
635            min_severity: EventSeverity::Warning,
636        }
637    }
638
639    /// Set the minimum severity.
640    #[must_use]
641    pub fn with_min_severity(mut self, min: EventSeverity) -> Self {
642        self.min_severity = min;
643        self
644    }
645
646    /// Build the PD Events API v2 payload for `event`.
647    #[must_use]
648    pub fn build_payload(&self, event: &SinkEvent) -> PagerDutyPayload {
649        let pd_severity = PagerDutySeverity::from_event_severity(event.severity);
650        let dedup_key = event
651            .dedup_key
652            .clone()
653            .unwrap_or_else(|| format!("oximedia-{}", event.event_type));
654
655        let mut custom_details = serde_json::Map::new();
656        for (k, v) in &event.metadata {
657            custom_details.insert(k.clone(), serde_json::json!(v));
658        }
659        custom_details.insert(
660            "event_type".to_string(),
661            serde_json::json!(event.event_type),
662        );
663
664        let body = serde_json::json!({
665            "routing_key": self.config.routing_key,
666            "event_action": "trigger",
667            "dedup_key": dedup_key,
668            "payload": {
669                "summary": event.title,
670                "severity": pd_severity.as_str(),
671                "source": self.config.service_name,
672                "timestamp": event.timestamp_ms,
673                "custom_details": custom_details,
674            },
675            "client": "OxiMedia Workflow",
676            "client_url": "https://github.com/cool-japan/oximedia",
677        });
678
679        PagerDutyPayload {
680            body_json: body.to_string(),
681            dedup_key,
682        }
683    }
684}
685
686impl NotificationSink for PagerDutySink {
687    fn name(&self) -> &str {
688        "pagerduty"
689    }
690
691    fn dispatch(&self, event: &SinkEvent) -> SinkOutcome {
692        if !self.accepts_severity(event.severity) {
693            return SinkOutcome::ok(
694                "pagerduty",
695                format!(
696                    "skipped: severity {} below minimum {}",
697                    event.severity.label(),
698                    self.min_severity.label()
699                ),
700            );
701        }
702        let payload = self.build_payload(event);
703        SinkOutcome::ok(
704            "pagerduty",
705            format!(
706                "POST events.pagerduty.com/v2/enqueue dedup_key={} payload_len={}",
707                payload.dedup_key,
708                payload.body_json.len()
709            ),
710        )
711    }
712
713    fn accepts_severity(&self, severity: EventSeverity) -> bool {
714        severity >= self.min_severity
715    }
716}
717
718// ---------------------------------------------------------------------------
719// NotificationRouter
720// ---------------------------------------------------------------------------
721
722/// Summary of a router dispatch round.
723#[derive(Debug, Clone, Default)]
724pub struct RouterSummary {
725    /// Per-sink outcomes.
726    pub outcomes: Vec<SinkOutcome>,
727    /// Number of sinks that succeeded.
728    pub success_count: usize,
729    /// Number of sinks that failed.
730    pub failure_count: usize,
731    /// Number of sinks that skipped the event (severity filter).
732    pub skip_count: usize,
733}
734
735impl RouterSummary {
736    /// Returns `true` when all dispatches succeeded (none failed).
737    #[must_use]
738    pub fn all_succeeded(&self) -> bool {
739        self.failure_count == 0
740    }
741}
742
743/// Dispatches a single [`SinkEvent`] to multiple [`NotificationSink`]s.
744///
745/// Sinks are called sequentially (no async / thread overhead).  For async
746/// fan-out combine with [`crate::fan_pattern::FanExecutor`].
747#[derive(Default)]
748pub struct NotificationRouter {
749    sinks: Vec<Box<dyn NotificationSink>>,
750}
751
752impl std::fmt::Debug for NotificationRouter {
753    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754        let names: Vec<&str> = self.sinks.iter().map(|s| s.name()).collect();
755        f.debug_struct("NotificationRouter")
756            .field("sinks", &names)
757            .finish()
758    }
759}
760
761impl NotificationRouter {
762    /// Create an empty router.
763    #[must_use]
764    pub fn new() -> Self {
765        Self::default()
766    }
767
768    /// Add a sink.
769    pub fn add_sink(&mut self, sink: Box<dyn NotificationSink>) {
770        self.sinks.push(sink);
771    }
772
773    /// Number of registered sinks.
774    #[must_use]
775    pub fn sink_count(&self) -> usize {
776        self.sinks.len()
777    }
778
779    /// Dispatch `event` to all registered sinks and return a [`RouterSummary`].
780    pub fn dispatch(&self, event: &SinkEvent) -> RouterSummary {
781        let mut summary = RouterSummary::default();
782
783        for sink in &self.sinks {
784            let outcome = sink.dispatch(event);
785            if outcome.success {
786                if outcome.message.starts_with("skipped:") {
787                    summary.skip_count += 1;
788                } else {
789                    summary.success_count += 1;
790                }
791            } else {
792                summary.failure_count += 1;
793            }
794            summary.outcomes.push(outcome);
795        }
796
797        summary
798    }
799
800    /// Names of all registered sinks.
801    #[must_use]
802    pub fn sink_names(&self) -> Vec<&str> {
803        self.sinks.iter().map(|s| s.name()).collect()
804    }
805}
806
807// ---------------------------------------------------------------------------
808// Tests
809// ---------------------------------------------------------------------------
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    // -----------------------------------------------------------------------
816    // EventSeverity
817    // -----------------------------------------------------------------------
818
819    #[test]
820    fn severity_ordering() {
821        assert!(EventSeverity::Info < EventSeverity::Warning);
822        assert!(EventSeverity::Warning < EventSeverity::Error);
823        assert!(EventSeverity::Error < EventSeverity::Critical);
824    }
825
826    #[test]
827    fn severity_labels() {
828        assert_eq!(EventSeverity::Info.label(), "info");
829        assert_eq!(EventSeverity::Warning.label(), "warning");
830        assert_eq!(EventSeverity::Error.label(), "error");
831        assert_eq!(EventSeverity::Critical.label(), "critical");
832    }
833
834    // -----------------------------------------------------------------------
835    // SinkEvent constructors
836    // -----------------------------------------------------------------------
837
838    #[test]
839    fn sink_event_workflow_completed() {
840        let ev = SinkEvent::workflow_completed("wf-001", "transcode");
841        assert_eq!(ev.event_type, "workflow.completed");
842        assert_eq!(ev.severity, EventSeverity::Info);
843        assert_eq!(
844            ev.metadata.get("workflow_id").map(String::as_str),
845            Some("wf-001")
846        );
847    }
848
849    #[test]
850    fn sink_event_workflow_failed_has_dedup_key() {
851        let ev = SinkEvent::workflow_failed("wf-002", "ingest", "disk full");
852        assert!(ev.dedup_key.is_some());
853        assert!(ev.dedup_key.as_deref().unwrap().contains("wf-002"));
854        assert_eq!(ev.severity, EventSeverity::Error);
855    }
856
857    #[test]
858    fn sink_event_task_failed() {
859        let ev = SinkEvent::task_failed("wf-003", "qc-check", "bitrate too high");
860        assert_eq!(ev.event_type, "task.failed");
861        assert_eq!(ev.severity, EventSeverity::Warning);
862        assert_eq!(
863            ev.metadata.get("task_name").map(String::as_str),
864            Some("qc-check")
865        );
866    }
867
868    // -----------------------------------------------------------------------
869    // SlackSink
870    // -----------------------------------------------------------------------
871
872    #[test]
873    fn slack_sink_builds_valid_json_payload() {
874        let sink = SlackSink::new("https://hooks.slack.com/T/B/xxx")
875            .with_channel("#ops")
876            .with_username("Bot");
877        let ev = SinkEvent::workflow_failed("wf-1", "pipe", "oom");
878        let msg = sink.build_message(&ev);
879        let parsed: serde_json::Value =
880            serde_json::from_str(&msg.payload_json).expect("valid JSON");
881        assert!(parsed["attachments"].is_array());
882        assert_eq!(parsed["channel"], "#ops");
883        assert_eq!(parsed["username"], "Bot");
884    }
885
886    #[test]
887    fn slack_sink_skips_below_min_severity() {
888        let sink = SlackSink::new("https://x").with_min_severity(EventSeverity::Error);
889        let ev = SinkEvent::workflow_completed("wf-1", "pipe");
890        let outcome = sink.dispatch(&ev);
891        assert!(outcome.success);
892        assert!(outcome.message.starts_with("skipped:"));
893    }
894
895    #[test]
896    fn slack_sink_dispatches_above_min_severity() {
897        let sink = SlackSink::new("https://hooks.slack.com/x");
898        let ev = SinkEvent::workflow_failed("wf-1", "pipe", "err");
899        let outcome = sink.dispatch(&ev);
900        assert!(outcome.success);
901        assert!(outcome.message.contains("POST"));
902    }
903
904    // -----------------------------------------------------------------------
905    // EmailSink
906    // -----------------------------------------------------------------------
907
908    #[test]
909    fn email_sink_builds_correct_subject() {
910        let sink = EmailSink::new(SmtpConfig {
911            from: "ops@example.com".to_string(),
912            to: vec!["alerts@example.com".to_string()],
913            subject_prefix: "[OxiMedia]".to_string(),
914        });
915        let ev = SinkEvent::workflow_failed("wf-5", "encode", "crash");
916        let msg = sink.build_message(&ev);
917        assert!(msg.subject.starts_with("[OxiMedia]"));
918        assert!(msg.subject.contains("ERROR"));
919    }
920
921    #[test]
922    fn email_sink_body_contains_metadata() {
923        let sink = EmailSink::new(SmtpConfig {
924            from: "a@b.com".to_string(),
925            to: vec!["c@d.com".to_string()],
926            subject_prefix: "[X]".to_string(),
927        });
928        let ev = SinkEvent::workflow_failed("wf-6", "pipe", "oom").with_meta("region", "eu-west-1");
929        let msg = sink.build_message(&ev);
930        assert!(msg.body.contains("region"));
931        assert!(msg.body.contains("eu-west-1"));
932    }
933
934    #[test]
935    fn email_sink_skips_info_by_default() {
936        let sink = EmailSink::new(SmtpConfig {
937            from: "a@b.com".to_string(),
938            to: vec![],
939            subject_prefix: "X".to_string(),
940        });
941        let ev = SinkEvent::workflow_completed("wf-7", "pipe");
942        let outcome = sink.dispatch(&ev);
943        assert!(outcome.success);
944        assert!(outcome.message.starts_with("skipped:"));
945    }
946
947    // -----------------------------------------------------------------------
948    // PagerDutySink
949    // -----------------------------------------------------------------------
950
951    #[test]
952    fn pagerduty_sink_payload_is_valid_json() {
953        let sink = PagerDutySink::new(PagerDutyConfig {
954            routing_key: "abc123".to_string(),
955            default_severity: PagerDutySeverity::Error,
956            service_name: "OxiMedia".to_string(),
957        });
958        let ev = SinkEvent::workflow_failed("wf-8", "pipe", "disk full");
959        let payload = sink.build_payload(&ev);
960        let parsed: serde_json::Value =
961            serde_json::from_str(&payload.body_json).expect("valid JSON");
962        assert_eq!(parsed["routing_key"], "abc123");
963        assert_eq!(parsed["event_action"], "trigger");
964        assert_eq!(parsed["payload"]["severity"], "error");
965        assert_eq!(parsed["payload"]["source"], "OxiMedia");
966    }
967
968    #[test]
969    fn pagerduty_sink_uses_event_dedup_key() {
970        let sink = PagerDutySink::new(PagerDutyConfig {
971            routing_key: "r".to_string(),
972            default_severity: PagerDutySeverity::Warning,
973            service_name: "S".to_string(),
974        });
975        let ev = SinkEvent::workflow_failed("wf-9", "p", "e");
976        let payload = sink.build_payload(&ev);
977        assert!(payload.dedup_key.contains("wf-9"));
978    }
979
980    #[test]
981    fn pagerduty_severity_mapping() {
982        assert_eq!(
983            PagerDutySeverity::from_event_severity(EventSeverity::Critical).as_str(),
984            "critical"
985        );
986        assert_eq!(
987            PagerDutySeverity::from_event_severity(EventSeverity::Info).as_str(),
988            "info"
989        );
990    }
991
992    // -----------------------------------------------------------------------
993    // NotificationRouter
994    // -----------------------------------------------------------------------
995
996    #[test]
997    fn router_dispatches_to_all_sinks() {
998        let mut router = NotificationRouter::new();
999        router.add_sink(Box::new(
1000            SlackSink::new("https://hooks.slack.com/x").with_min_severity(EventSeverity::Info),
1001        ));
1002        router.add_sink(Box::new(
1003            EmailSink::new(SmtpConfig {
1004                from: "a@b.com".to_string(),
1005                to: vec!["c@d.com".to_string()],
1006                subject_prefix: "[X]".to_string(),
1007            })
1008            .with_min_severity(EventSeverity::Info),
1009        ));
1010        router.add_sink(Box::new(
1011            PagerDutySink::new(PagerDutyConfig {
1012                routing_key: "r".to_string(),
1013                default_severity: PagerDutySeverity::Error,
1014                service_name: "S".to_string(),
1015            })
1016            .with_min_severity(EventSeverity::Info),
1017        ));
1018
1019        let ev = SinkEvent::workflow_failed("wf-x", "pipe", "crash");
1020        let summary = router.dispatch(&ev);
1021        assert_eq!(summary.outcomes.len(), 3);
1022        assert_eq!(summary.success_count, 3);
1023        assert_eq!(summary.failure_count, 0);
1024        assert!(summary.all_succeeded());
1025    }
1026
1027    #[test]
1028    fn router_counts_skips_correctly() {
1029        let mut router = NotificationRouter::new();
1030        // Email default min = Warning; Info event should be skipped.
1031        router.add_sink(Box::new(EmailSink::new(SmtpConfig {
1032            from: "a@b.com".to_string(),
1033            to: vec![],
1034            subject_prefix: "[X]".to_string(),
1035        })));
1036
1037        let ev = SinkEvent::workflow_completed("wf-z", "pipe");
1038        let summary = router.dispatch(&ev);
1039        assert_eq!(summary.skip_count, 1);
1040        assert_eq!(summary.success_count, 0);
1041    }
1042
1043    #[test]
1044    fn router_sink_names() {
1045        let mut router = NotificationRouter::new();
1046        router.add_sink(Box::new(SlackSink::new("u")));
1047        router.add_sink(Box::new(PagerDutySink::new(PagerDutyConfig {
1048            routing_key: "r".to_string(),
1049            default_severity: PagerDutySeverity::Error,
1050            service_name: "s".to_string(),
1051        })));
1052        let names = router.sink_names();
1053        assert_eq!(names, vec!["slack", "pagerduty"]);
1054    }
1055}