use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotificationEvent {
Completed,
Failed,
Blocked,
}
impl NotificationEvent {
pub fn as_str(&self) -> &'static str {
match self {
Self::Completed => "completed",
Self::Failed => "failed",
Self::Blocked => "blocked",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"completed" => Some(Self::Completed),
"failed" => Some(Self::Failed),
"blocked" => Some(Self::Blocked),
_ => None,
}
}
}
impl std::fmt::Display for NotificationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct WebhookPayload {
pub event: String,
pub run_id: String,
pub timestamp: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub message: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub error: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub schema: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub file: String,
#[serde(skip_serializing_if = "is_zero")]
pub blocked_count: usize,
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct NotificationConfig {
pub url: String,
pub events: Vec<NotificationEvent>,
}
impl NotificationConfig {
pub fn new(url: impl Into<String>, notification_on: &[String]) -> Self {
let events = notification_on
.iter()
.filter_map(|s| NotificationEvent::from_str(s))
.collect();
Self {
url: url.into(),
events,
}
}
pub fn wants_event(&self, event: &NotificationEvent) -> bool {
!self.url.is_empty() && self.events.contains(event)
}
pub fn completed_payload(&self, run_id: &str) -> WebhookPayload {
WebhookPayload {
event: NotificationEvent::Completed.as_str().to_string(),
run_id: run_id.to_string(),
timestamp: rfc3339_now(),
message: "Migration run completed successfully.".to_string(),
..WebhookPayload::default()
}
}
pub fn failed_payload(&self, run_id: &str, error: &str, file: &str) -> WebhookPayload {
WebhookPayload {
event: NotificationEvent::Failed.as_str().to_string(),
run_id: run_id.to_string(),
timestamp: rfc3339_now(),
message: "Migration run failed.".to_string(),
error: error.to_string(),
file: file.to_string(),
..WebhookPayload::default()
}
}
pub fn blocked_payload(&self, run_id: &str, count: usize) -> WebhookPayload {
WebhookPayload {
event: NotificationEvent::Blocked.as_str().to_string(),
run_id: run_id.to_string(),
timestamp: rfc3339_now(),
message: format!("{count} blocked operation(s) require manual intervention."),
blocked_count: count,
..WebhookPayload::default()
}
}
}
fn rfc3339_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let (y, mo, d, h, mi, s) = epoch_to_utc(secs);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
fn epoch_to_utc(secs: u64) -> (u64, u64, u64, u64, u64, u64) {
let s = secs % 60;
let mins = secs / 60;
let mi = mins % 60;
let hours = mins / 60;
let h = hours % 24;
let days = hours / 24;
let mut y = 1970u64;
let mut remaining = days;
loop {
let days_in_year = if is_leap(y) { 366 } else { 365 };
if remaining < days_in_year {
break;
}
remaining -= days_in_year;
y += 1;
}
let month_days: &[u64] = if is_leap(y) {
&[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
&[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut mo = 1u64;
for &md in month_days {
if remaining < md {
break;
}
remaining -= md;
mo += 1;
}
(y, mo, remaining + 1, h, mi, s)
}
fn is_leap(year: u64) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_event_round_trip() {
for (s, expected) in [
("completed", NotificationEvent::Completed),
("failed", NotificationEvent::Failed),
("blocked", NotificationEvent::Blocked),
] {
assert_eq!(NotificationEvent::from_str(s).unwrap(), expected);
assert_eq!(expected.as_str(), s);
}
}
#[test]
fn notification_config_wants_event() {
let cfg = NotificationConfig::new(
"https://hooks.example.com/xyz",
&["completed".to_string(), "failed".to_string()],
);
assert!(cfg.wants_event(&NotificationEvent::Completed));
assert!(cfg.wants_event(&NotificationEvent::Failed));
assert!(!cfg.wants_event(&NotificationEvent::Blocked));
}
#[test]
fn notification_config_empty_url_returns_false() {
let cfg = NotificationConfig::new("", &["completed".to_string()]);
assert!(!cfg.wants_event(&NotificationEvent::Completed));
}
#[test]
fn completed_payload_has_correct_event() {
let cfg = NotificationConfig::new("https://example.com", &[]);
let p = cfg.completed_payload("run-001");
assert_eq!(p.event, "completed");
assert_eq!(p.run_id, "run-001");
}
#[test]
fn blocked_payload_has_count() {
let cfg = NotificationConfig::new("https://example.com", &[]);
let p = cfg.blocked_payload("run-002", 3);
assert_eq!(p.event, "blocked");
assert_eq!(p.blocked_count, 3);
}
}