use std::fmt;
use comfy_table::Color;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertResponse {
pub id: String,
#[serde(default)]
pub agent_id: Option<String>,
pub severity: String,
#[serde(default)]
pub category: String,
pub message: String,
#[serde(default = "default_status")]
pub status: String,
#[serde(alias = "timestamp")]
pub created_at: String,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub context: Option<serde_json::Value>,
}
fn default_status() -> String {
"unresolved".to_string()
}
#[derive(Debug, Serialize)]
pub struct ResolveAlertRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlertSeverity {
Critical,
Warning,
Info,
Unknown,
}
impl AlertSeverity {
pub fn parse(s: &str) -> Self {
match s.to_lowercase().as_str() {
"critical" => Self::Critical,
"warning" => Self::Warning,
"info" => Self::Info,
_ => Self::Unknown,
}
}
pub fn color(self) -> Color {
match self {
Self::Critical => Color::Red,
Self::Warning => Color::Yellow,
Self::Info => Color::White,
Self::Unknown => Color::Reset,
}
}
}
impl fmt::Display for AlertSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Critical => write!(f, "critical"),
Self::Warning => write!(f, "warning"),
Self::Info => write!(f, "info"),
Self::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlertStatusKind {
Unresolved,
Acknowledged,
Resolved,
Unknown,
}
impl AlertStatusKind {
pub fn parse(s: &str) -> Self {
match s.to_lowercase().as_str() {
"unresolved" => Self::Unresolved,
"acknowledged" => Self::Acknowledged,
"resolved" => Self::Resolved,
_ => Self::Unknown,
}
}
pub fn color(self) -> Color {
match self {
Self::Unresolved => Color::Red,
Self::Acknowledged => Color::Yellow,
Self::Resolved => Color::Green,
Self::Unknown => Color::Reset,
}
}
}
impl fmt::Display for AlertStatusKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unresolved => write!(f, "unresolved"),
Self::Acknowledged => write!(f, "acknowledged"),
Self::Resolved => write!(f, "resolved"),
Self::Unknown => write!(f, "unknown"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_from_str_case_insensitive() {
assert_eq!(AlertSeverity::parse("Critical"), AlertSeverity::Critical);
assert_eq!(AlertSeverity::parse("WARNING"), AlertSeverity::Warning);
assert_eq!(AlertSeverity::parse("info"), AlertSeverity::Info);
assert_eq!(AlertSeverity::parse("other"), AlertSeverity::Unknown);
}
#[test]
fn severity_colors() {
assert_eq!(AlertSeverity::Critical.color(), Color::Red);
assert_eq!(AlertSeverity::Warning.color(), Color::Yellow);
assert_eq!(AlertSeverity::Info.color(), Color::White);
assert_eq!(AlertSeverity::Unknown.color(), Color::Reset);
}
#[test]
fn status_from_str_case_insensitive() {
assert_eq!(AlertStatusKind::parse("unresolved"), AlertStatusKind::Unresolved);
assert_eq!(AlertStatusKind::parse("ACKNOWLEDGED"), AlertStatusKind::Acknowledged);
assert_eq!(AlertStatusKind::parse("Resolved"), AlertStatusKind::Resolved);
assert_eq!(AlertStatusKind::parse("other"), AlertStatusKind::Unknown);
}
#[test]
fn status_colors() {
assert_eq!(AlertStatusKind::Unresolved.color(), Color::Red);
assert_eq!(AlertStatusKind::Acknowledged.color(), Color::Yellow);
assert_eq!(AlertStatusKind::Resolved.color(), Color::Green);
assert_eq!(AlertStatusKind::Unknown.color(), Color::Reset);
}
#[test]
fn alert_response_deserializes_with_defaults() {
let json = r#"{
"id": "alert-001",
"severity": "warning",
"message": "Budget threshold exceeded",
"timestamp": "2026-04-30T10:00:00Z"
}"#;
let alert: AlertResponse = serde_json::from_str(json).unwrap();
assert_eq!(alert.id, "alert-001");
assert_eq!(alert.status, "unresolved");
assert_eq!(alert.created_at, "2026-04-30T10:00:00Z");
assert!(alert.agent_id.is_none());
assert!(alert.context.is_none());
}
#[test]
fn alert_response_round_trip() {
let alert = AlertResponse {
id: "alert-002".to_string(),
agent_id: Some("agent-abc".to_string()),
severity: "critical".to_string(),
category: "policy_violation".to_string(),
message: "Blocked tool call".to_string(),
status: "resolved".to_string(),
created_at: "2026-04-30T10:00:00Z".to_string(),
updated_at: Some("2026-04-30T11:00:00Z".to_string()),
context: Some(serde_json::json!({"tool": "shell_exec"})),
};
let json = serde_json::to_string(&alert).unwrap();
let parsed: AlertResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, "alert-002");
assert_eq!(parsed.status, "resolved");
assert_eq!(parsed.context.unwrap()["tool"], "shell_exec");
}
#[test]
fn resolve_request_skips_none_reason() {
let req = ResolveAlertRequest { reason: None };
let json = serde_json::to_string(&req).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn resolve_request_includes_reason() {
let req = ResolveAlertRequest {
reason: Some("False positive".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("False positive"));
}
#[test]
fn severity_display() {
assert_eq!(format!("{}", AlertSeverity::Critical), "critical");
assert_eq!(format!("{}", AlertSeverity::Warning), "warning");
assert_eq!(format!("{}", AlertSeverity::Info), "info");
}
#[test]
fn status_display() {
assert_eq!(format!("{}", AlertStatusKind::Unresolved), "unresolved");
assert_eq!(format!("{}", AlertStatusKind::Acknowledged), "acknowledged");
assert_eq!(format!("{}", AlertStatusKind::Resolved), "resolved");
}
}