use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Severity {
Info,
Warning,
Critical,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Info => write!(f, "info"),
Severity::Warning => write!(f, "warning"),
Severity::Critical => write!(f, "critical"),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum AlertState {
#[default]
Inactive,
Pending { since: Instant },
Firing { since: Instant, notifications_sent: u32 },
Resolved { resolved_at: Instant },
}
impl AlertState {
pub fn is_active(&self) -> bool {
matches!(self, AlertState::Pending { .. } | AlertState::Firing { .. })
}
pub fn is_firing(&self) -> bool {
matches!(self, AlertState::Firing { .. })
}
pub fn duration(&self) -> Duration {
match self {
AlertState::Inactive => Duration::ZERO,
AlertState::Pending { since } => since.elapsed(),
AlertState::Firing { since, .. } => since.elapsed(),
AlertState::Resolved { resolved_at } => resolved_at.elapsed(),
}
}
pub fn icon(&self) -> &'static str {
match self {
AlertState::Inactive => "○",
AlertState::Pending { .. } => "◐",
AlertState::Firing { .. } => "●",
AlertState::Resolved { .. } => "◯",
}
}
}
#[derive(Debug, Clone)]
pub struct ActiveAlert {
pub name: String,
pub state: AlertState,
pub severity: Severity,
pub annotations: AlertAnnotations,
pub value: f64,
pub labels: std::collections::HashMap<String, String>,
}
impl ActiveAlert {
pub fn new(
name: impl Into<String>,
severity: Severity,
value: f64,
labels: std::collections::HashMap<String, String>,
) -> Self {
Self {
name: name.into(),
state: AlertState::Pending { since: Instant::now() },
severity,
annotations: AlertAnnotations::default(),
value,
labels,
}
}
pub fn fire(&mut self) {
if let AlertState::Pending { since } = self.state {
self.state = AlertState::Firing { since, notifications_sent: 0 };
}
}
pub fn resolve(&mut self) {
self.state = AlertState::Resolved { resolved_at: Instant::now() };
}
pub fn duration(&self) -> Duration {
self.state.duration()
}
pub fn state_icon(&self) -> &'static str {
self.state.icon()
}
}
#[derive(Debug, Clone, Default)]
pub struct AlertAnnotations {
pub summary: String,
pub description: String,
pub runbook_url: Option<String>,
}
impl AlertAnnotations {
pub fn with_summary(summary: impl Into<String>) -> Self {
Self { summary: summary.into(), ..Default::default() }
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
pub fn runbook(mut self, url: impl Into<String>) -> Self {
self.runbook_url = Some(url.into());
self
}
pub fn expand_summary(
&self,
value: f64,
labels: &std::collections::HashMap<String, String>,
) -> String {
let mut result = self.summary.clone();
result = result.replace("{{$value}}", &format!("{:.4}", value));
for (key, val) in labels {
result = result.replace(&format!("{{{{$labels.{}}}}}", key), val);
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alert_state_transitions() {
let mut alert = ActiveAlert::new("test", Severity::Warning, 100.0, Default::default());
assert!(matches!(alert.state, AlertState::Pending { .. }));
assert!(alert.state.is_active());
assert!(!alert.state.is_firing());
alert.fire();
assert!(matches!(alert.state, AlertState::Firing { .. }));
assert!(alert.state.is_active());
assert!(alert.state.is_firing());
alert.resolve();
assert!(matches!(alert.state, AlertState::Resolved { .. }));
assert!(!alert.state.is_active());
}
#[test]
fn test_alert_duration() {
let alert = ActiveAlert::new("test", Severity::Warning, 100.0, Default::default());
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(alert.duration() >= std::time::Duration::from_millis(10));
}
#[test]
fn test_annotation_expansion() {
let mut labels = std::collections::HashMap::new();
labels.insert("instance".to_string(), "server1".to_string());
let ann = AlertAnnotations::with_summary("Value is {{$value}} on {{$labels.instance}}");
let expanded = ann.expand_summary(42.5, &labels);
assert_eq!(expanded, "Value is 42.5000 on server1");
}
#[test]
fn test_severity_display() {
assert_eq!(Severity::Info.to_string(), "info");
assert_eq!(Severity::Warning.to_string(), "warning");
assert_eq!(Severity::Critical.to_string(), "critical");
}
#[test]
fn test_state_icons() {
assert_eq!(AlertState::Inactive.icon(), "○");
assert_eq!(AlertState::Pending { since: Instant::now() }.icon(), "◐");
assert_eq!(AlertState::Firing { since: Instant::now(), notifications_sent: 0 }.icon(), "●");
assert_eq!(AlertState::Resolved { resolved_at: Instant::now() }.icon(), "◯");
}
#[test]
fn test_alert_state_default() {
let state: AlertState = Default::default();
assert!(matches!(state, AlertState::Inactive));
assert!(!state.is_active());
assert!(!state.is_firing());
}
#[test]
fn test_alert_state_duration_all_variants() {
assert_eq!(AlertState::Inactive.duration(), Duration::ZERO);
let pending = AlertState::Pending { since: Instant::now() };
std::thread::sleep(Duration::from_millis(5));
assert!(pending.duration() >= Duration::from_millis(5));
let firing = AlertState::Firing { since: Instant::now(), notifications_sent: 5 };
std::thread::sleep(Duration::from_millis(5));
assert!(firing.duration() >= Duration::from_millis(5));
let resolved = AlertState::Resolved { resolved_at: Instant::now() };
std::thread::sleep(Duration::from_millis(5));
assert!(resolved.duration() >= Duration::from_millis(5));
}
#[test]
fn test_annotations_builder() {
let ann = AlertAnnotations::with_summary("High CPU usage")
.description("CPU usage exceeds 90%")
.runbook("https://runbook.example.com/cpu");
assert_eq!(ann.summary, "High CPU usage");
assert_eq!(ann.description, "CPU usage exceeds 90%");
assert_eq!(ann.runbook_url, Some("https://runbook.example.com/cpu".to_string()));
}
#[test]
fn test_annotations_default() {
let ann = AlertAnnotations::default();
assert!(ann.summary.is_empty());
assert!(ann.description.is_empty());
assert!(ann.runbook_url.is_none());
}
#[test]
fn test_active_alert_state_icon() {
let mut alert = ActiveAlert::new("test", Severity::Critical, 99.0, Default::default());
assert_eq!(alert.state_icon(), "◐");
alert.fire();
assert_eq!(alert.state_icon(), "●");
alert.resolve();
assert_eq!(alert.state_icon(), "◯"); }
#[test]
fn test_fire_from_non_pending_state() {
let mut alert = ActiveAlert::new("test", Severity::Warning, 50.0, Default::default());
alert.fire();
alert.fire(); assert!(matches!(alert.state, AlertState::Firing { .. }));
}
#[test]
fn test_severity_eq_hash() {
use std::collections::HashSet;
let mut set: HashSet<Severity> = HashSet::new();
set.insert(Severity::Info);
set.insert(Severity::Warning);
set.insert(Severity::Critical);
assert!(set.contains(&Severity::Info));
assert!(set.contains(&Severity::Warning));
assert!(set.contains(&Severity::Critical));
assert_eq!(set.len(), 3);
}
#[test]
fn test_annotation_expand_summary_multiple_labels() {
let mut labels = std::collections::HashMap::new();
labels.insert("host".to_string(), "web-01".to_string());
labels.insert("dc".to_string(), "us-east".to_string());
labels.insert("env".to_string(), "prod".to_string());
let ann = AlertAnnotations::with_summary(
"Alert on {{$labels.host}} in {{$labels.dc}} ({{$labels.env}}): {{$value}}",
);
let expanded = ann.expand_summary(123.456, &labels);
assert_eq!(expanded, "Alert on web-01 in us-east (prod): 123.4560");
}
#[test]
fn test_active_alert_clone() {
let mut labels = std::collections::HashMap::new();
labels.insert("team".to_string(), "infra".to_string());
let alert = ActiveAlert::new("test_alert", Severity::Critical, 95.0, labels);
let cloned = alert.clone();
assert_eq!(cloned.name, "test_alert");
assert_eq!(cloned.severity, Severity::Critical);
assert!((cloned.value - 95.0).abs() < f64::EPSILON);
assert_eq!(cloned.labels.get("team"), Some(&"infra".to_string()));
}
#[test]
fn test_alert_state_clone() {
let state = AlertState::Firing { since: Instant::now(), notifications_sent: 3 };
let cloned = state.clone();
assert!(matches!(cloned, AlertState::Firing { notifications_sent: 3, .. }));
}
#[test]
fn test_severity_copy() {
let s1 = Severity::Warning;
let s2 = s1; assert_eq!(s1, s2);
}
}