1use std::str::FromStr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub enum Status {
7 Running,
8 Success,
9 Stopped,
10 Pending,
11 Failed,
12 Unknown(String),
13}
14
15impl FromStr for Status {
16 type Err = std::convert::Infallible;
17
18 fn from_str(s: &str) -> Result<Self, Self::Err> {
19 Ok(match s.to_uppercase().as_str() {
20 "RUNNING" => Status::Running,
21 "SUCCESS" | "COMPLETED" | "FINISHED" => Status::Success,
22 "IDLE" | "STOPPED" | "TERMINATED" | "DELETED" | "SKIPPED" | "CANCELED" => {
23 Status::Stopped
24 }
25 "PENDING"
26 | "STARTING"
27 | "RESTARTING"
28 | "DELETING"
29 | "TERMINATING"
30 | "QUEUED"
31 | "WAITING_FOR_RETRY"
32 | "BLOCKED"
33 | "CREATED"
34 | "INITIALIZING"
35 | "RESETTING"
36 | "SETTING_UP_TABLES"
37 | "WAITING_FOR_RESOURCES"
38 | "STOPPING" => Status::Pending,
39 "FAILED" | "ERROR" | "TIMEDOUT" | "TIMED_OUT" | "INTERNAL_ERROR" => Status::Failed,
40 other => Status::Unknown(other.to_string()),
41 })
42 }
43}
44
45impl Status {
46 pub fn rank(&self) -> u8 {
49 match self {
50 Status::Running => 0,
51 Status::Pending => 1,
52 Status::Failed => 2,
53 Status::Success => 3,
54 Status::Stopped => 4,
55 Status::Unknown(_) => 5,
56 }
57 }
58
59 pub fn label(&self) -> &str {
60 match self {
61 Status::Running => "RUNNING",
62 Status::Success => "SUCCESS",
63 Status::Stopped => "IDLE",
64 Status::Pending => "PENDING",
65 Status::Failed => "FAILED",
66 Status::Unknown(s) => s.as_str(),
67 }
68 }
69}
70
71pub fn relative_time(epoch_ms: u64) -> String {
73 let now = std::time::SystemTime::now()
74 .duration_since(std::time::UNIX_EPOCH)
75 .map(|d| d.as_millis() as u64)
76 .unwrap_or(0);
77 let secs = now.saturating_sub(epoch_ms) / 1000;
78 match secs {
79 0..=59 => "just now".to_string(),
80 60..=3599 => format!("{}m ago", secs / 60),
81 3600..=86_399 => format!("{}h ago", secs / 3600),
82 _ => format!("{}d ago", secs / 86_400),
83 }
84}
85
86pub fn fmt_duration_ms(ms: u64) -> String {
88 let secs = ms / 1000;
89 if secs < 60 {
90 format!("{}s", secs)
91 } else if secs < 3600 {
92 format!("{}m {}s", secs / 60, secs % 60)
93 } else {
94 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
95 }
96}
97
98#[derive(Debug, Clone)]
99pub enum Shape {
100 List(Vec<ListItem>),
101 Table(TableData),
102 Badge(BadgeData),
103 Text(String),
104}
105
106#[derive(Debug, Clone, Default)]
107pub struct ListItem {
108 pub name: String,
109 pub status: Status,
110 pub detail: Option<String>,
111 pub id: Option<String>,
113 pub history: Vec<Status>,
115}
116
117impl Default for Status {
118 fn default() -> Self {
119 Status::Unknown(String::new())
120 }
121}
122
123pub fn item_matches(item: &ListItem, query: &str) -> bool {
126 if query.is_empty() {
127 return true;
128 }
129 let q = query.to_lowercase();
130 item.name.to_lowercase().contains(&q)
131 || item
132 .detail
133 .as_deref()
134 .is_some_and(|d| d.to_lowercase().contains(&q))
135 || item.status.label().to_lowercase().contains(&q)
136}
137
138#[derive(Debug, Clone)]
140pub struct DetailData {
141 pub summary: Vec<(String, String)>,
143 pub activity: Vec<(Status, String)>,
145 pub raw: String,
147}
148
149#[derive(Debug, Clone)]
150pub struct TableData {
151 pub headers: Vec<String>,
152 pub rows: Vec<Vec<String>>,
153}
154
155impl TableData {
156 pub fn to_csv(&self) -> String {
159 fn field(s: &str) -> String {
160 if s.contains([',', '"', '\n', '\r']) {
161 format!("\"{}\"", s.replace('"', "\"\""))
162 } else {
163 s.to_string()
164 }
165 }
166 let mut out = String::new();
167 out.push_str(
168 &self
169 .headers
170 .iter()
171 .map(|h| field(h))
172 .collect::<Vec<_>>()
173 .join(","),
174 );
175 out.push('\n');
176 for row in &self.rows {
177 out.push_str(&row.iter().map(|c| field(c)).collect::<Vec<_>>().join(","));
178 out.push('\n');
179 }
180 out
181 }
182}
183
184#[derive(Debug, Clone)]
185pub struct BadgeData {
186 pub label: String,
187 pub value: String,
188}