use std::process::ExitCode;
use chrono::Utc;
use clap::{Args, ValueEnum};
use comfy_table::{Cell, Color, Table};
use crate::config::ResolvedContext;
use crate::output::OutputFormat;
use crate::sanitize::sanitize_terminal;
use super::client;
use super::models::{compute_timeout_color, format_countdown, ApprovalResponse, TimeoutColor};
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ApprovalStatusFilter {
Pending,
Approved,
Rejected,
}
impl ApprovalStatusFilter {
pub fn as_query_value(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Rejected => "rejected",
}
}
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long, value_enum)]
pub output: Option<OutputFormat>,
#[arg(long, value_enum)]
pub status: Option<ApprovalStatusFilter>,
#[arg(long)]
pub agent: Option<String>,
}
fn approval_row_text_cells(item: &ApprovalResponse) -> Vec<String> {
vec![
sanitize_terminal(&item.id),
sanitize_terminal(&item.agent_id),
sanitize_terminal(&item.action),
sanitize_terminal(&item.reason),
sanitize_terminal(&item.created_at),
]
}
pub fn render_approvals_table(items: &[ApprovalResponse], now_epoch: i64) {
let mut table = Table::new();
table.set_header(vec!["ID", "AGENT", "ACTION", "CONDITION", "SUBMITTED_AT", "TIMEOUT_IN"]);
for item in items {
let submitted_epoch = chrono::DateTime::parse_from_rfc3339(&item.created_at)
.map(|dt| dt.timestamp())
.unwrap_or(0);
let timeout_secs: i64 = 300;
let remaining = (submitted_epoch + timeout_secs) - now_epoch;
let color = match compute_timeout_color(remaining) {
TimeoutColor::Red => Color::Red,
TimeoutColor::Yellow => Color::Yellow,
TimeoutColor::Green => Color::Green,
};
let mut cells: Vec<Cell> = approval_row_text_cells(item).into_iter().map(Cell::new).collect();
cells.push(Cell::new(format_countdown(remaining)).fg(color));
table.add_row(cells);
}
println!("{table}");
}
pub fn run_list(args: ListArgs, ctx: &ResolvedContext, global_output: OutputFormat) -> ExitCode {
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
let status = args.status.map(|s| s.as_query_value());
let result = rt.block_on(client::list_approvals(ctx, status, args.agent.as_deref()));
match result {
Ok(paginated) => {
let format = args.output.unwrap_or(global_output);
match format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&paginated.items).unwrap_or_default());
}
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(&paginated.items).unwrap_or_default());
}
OutputFormat::Table => {
let now = Utc::now().timestamp();
render_approvals_table(&paginated.items, now);
println!("\n{} pending approval(s)", paginated.total);
}
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn approval_row_text_cells_strips_server_supplied_escapes() {
let item = ApprovalResponse {
id: "id\x1b[2Kfake".to_string(),
agent_id: "bot\x1b]52;c;ZXZpbA==\x07".to_string(),
action: "delete\x1b[31m_all".to_string(),
reason: "ok\ninjected".to_string(),
status: "pending".to_string(),
created_at: "2026-04-30T10:00:00Z".to_string(),
};
let cells = approval_row_text_cells(&item);
assert!(cells.iter().all(|c| !c.contains('\x1b')), "no ESC in {cells:?}");
assert!(cells.iter().all(|c| !c.contains('\n')), "no newline in {cells:?}");
assert_eq!(cells[0], "idfake");
assert_eq!(cells[1], "bot");
assert_eq!(cells[2], "delete_all");
assert_eq!(cells[3], "okinjected");
assert_eq!(cells[4], "2026-04-30T10:00:00Z");
}
}