use std::process::ExitCode;
use clap::Args;
use comfy_table::{Cell, Table};
use super::models::{AlertResponse, AlertSeverity, AlertStatusKind};
use crate::client;
use crate::commands::agent::PaginatedResponse;
use crate::config::ResolvedContext;
use crate::output::OutputFormat;
#[derive(Args)]
pub struct ListArgs {
#[arg(long)]
pub agent: Option<String>,
#[arg(long)]
pub severity: Option<String>,
#[arg(long, default_value = "unresolved")]
pub status: Option<String>,
}
async fn fetch_alerts(ctx: &ResolvedContext) -> Result<Vec<AlertResponse>, crate::error::CliError> {
let resp: PaginatedResponse<AlertResponse> = client::get_json(ctx, "/api/v1/alerts").await?;
Ok(resp.items)
}
pub fn apply_filters(alerts: Vec<AlertResponse>, args: &ListArgs) -> Vec<AlertResponse> {
alerts
.into_iter()
.filter(|a| {
if let Some(ref agent) = args.agent {
match &a.agent_id {
Some(id) if id.eq_ignore_ascii_case(agent) => {}
_ => return false,
}
}
if let Some(ref sev) = args.severity {
if !a.severity.eq_ignore_ascii_case(sev) {
return false;
}
}
if let Some(ref status) = args.status {
if !a.status.eq_ignore_ascii_case(status) {
return false;
}
}
true
})
.collect()
}
pub fn render_table(alerts: &[AlertResponse]) {
let mut table = Table::new();
table.set_header(vec![
"ID",
"AGENT",
"SEVERITY",
"TYPE",
"MESSAGE",
"STATUS",
"CREATED_AT",
]);
for alert in alerts {
let agent = alert.agent_id.as_deref().unwrap_or("-");
let sev = AlertSeverity::parse(&alert.severity);
let status = AlertStatusKind::parse(&alert.status);
table.add_row(vec![
Cell::new(&alert.id),
Cell::new(agent),
Cell::new(&alert.severity).fg(sev.color()),
Cell::new(&alert.category),
Cell::new(&alert.message),
Cell::new(&alert.status).fg(status.color()),
Cell::new(&alert.created_at),
]);
}
println!("{table}");
}
pub fn run(args: ListArgs, ctx: &ResolvedContext, output: OutputFormat) -> ExitCode {
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
let alerts = match rt.block_on(fetch_alerts(ctx)) {
Ok(a) => apply_filters(a, &args),
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
if alerts.is_empty() {
println!("No alerts found.");
} else {
match output {
OutputFormat::Table => render_table(&alerts),
OutputFormat::Json => match serde_json::to_string_pretty(&alerts) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("error serializing JSON: {e}"),
},
OutputFormat::Yaml => match serde_yaml::to_string(&alerts) {
Ok(yaml) => print!("{yaml}"),
Err(e) => eprintln!("error serializing YAML: {e}"),
},
}
}
ExitCode::SUCCESS
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_alerts() -> Vec<AlertResponse> {
vec![
AlertResponse {
id: "alert-001".to_string(),
agent_id: Some("agent-abc".to_string()),
severity: "critical".to_string(),
category: "budget".to_string(),
message: "Budget exceeded".to_string(),
status: "unresolved".to_string(),
created_at: "2026-04-30T10:00:00Z".to_string(),
updated_at: None,
context: None,
},
AlertResponse {
id: "alert-002".to_string(),
agent_id: Some("agent-def".to_string()),
severity: "warning".to_string(),
category: "anomaly".to_string(),
message: "Unusual activity".to_string(),
status: "acknowledged".to_string(),
created_at: "2026-04-30T09:00:00Z".to_string(),
updated_at: None,
context: None,
},
AlertResponse {
id: "alert-003".to_string(),
agent_id: None,
severity: "info".to_string(),
category: "policy_violation".to_string(),
message: "Policy updated".to_string(),
status: "resolved".to_string(),
created_at: "2026-04-30T08:00:00Z".to_string(),
updated_at: None,
context: None,
},
]
}
#[test]
fn filter_by_agent() {
let alerts = sample_alerts();
let args = ListArgs {
agent: Some("agent-abc".to_string()),
severity: None,
status: None,
};
let filtered = apply_filters(alerts, &args);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "alert-001");
}
#[test]
fn filter_by_severity() {
let alerts = sample_alerts();
let args = ListArgs {
agent: None,
severity: Some("warning".to_string()),
status: None,
};
let filtered = apply_filters(alerts, &args);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "alert-002");
}
#[test]
fn filter_by_status_default_unresolved() {
let alerts = sample_alerts();
let args = ListArgs {
agent: None,
severity: None,
status: Some("unresolved".to_string()),
};
let filtered = apply_filters(alerts, &args);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "alert-001");
}
#[test]
fn filter_no_status_returns_all() {
let alerts = sample_alerts();
let args = ListArgs {
agent: None,
severity: None,
status: None,
};
let filtered = apply_filters(alerts, &args);
assert_eq!(filtered.len(), 3);
}
#[test]
fn filter_agent_without_agent_id_excluded() {
let alerts = sample_alerts();
let args = ListArgs {
agent: Some("agent-xyz".to_string()),
severity: None,
status: None,
};
let filtered = apply_filters(alerts, &args);
assert!(filtered.is_empty());
}
#[test]
fn render_table_does_not_panic() {
let alerts = sample_alerts();
render_table(&alerts);
}
#[test]
fn render_table_empty_does_not_panic() {
render_table(&[]);
}
#[test]
fn json_output_is_valid() {
let alerts = sample_alerts();
let json = serde_json::to_string_pretty(&alerts).unwrap();
let parsed: Vec<AlertResponse> = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.len(), 3);
}
}