1use crate::api::models::BuildState;
2use crate::error::Result;
3use chrono::{DateTime, Utc};
4use comfy_table::presets::UTF8_BORDERS_ONLY;
5use comfy_table::{Cell, ContentArrangement, Table};
6use owo_colors::OwoColorize;
7use serde::Serialize;
8use std::io::IsTerminal;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Format {
12 Human,
13 Json,
14}
15
16impl Format {
17 pub fn from_json_flag(json: bool) -> Self {
18 if json {
19 Format::Json
20 } else {
21 Format::Human
22 }
23 }
24
25 pub fn is_json(self) -> bool {
26 self == Format::Json
27 }
28}
29
30pub fn color_enabled() -> bool {
31 color_from(
32 std::io::stdout().is_terminal(),
33 std::env::var_os("NO_COLOR").is_some(),
34 )
35}
36
37fn color_from(is_tty: bool, no_color: bool) -> bool {
38 is_tty && !no_color
39}
40
41pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
42 println!("{}", serde_json::to_string_pretty(value)?);
43 Ok(())
44}
45
46pub fn table(headers: &[&str], rows: Vec<Vec<String>>) -> String {
47 let mut table = Table::new();
48 table
49 .load_preset(UTF8_BORDERS_ONLY)
50 .set_content_arrangement(ContentArrangement::Dynamic)
51 .set_header(headers.iter().map(Cell::new));
52 for row in rows {
53 table.add_row(row);
54 }
55 table.to_string()
56}
57
58pub fn print_table(headers: &[&str], rows: Vec<Vec<String>>) {
59 if rows.is_empty() {
60 info("nothing to show");
61 return;
62 }
63 println!("{}", table(headers, rows));
64}
65
66fn success_line(msg: &str, color: bool) -> String {
67 if color {
68 format!("{} {}", "✓".green(), msg)
69 } else {
70 format!("✓ {msg}")
71 }
72}
73
74fn info_line(msg: &str, color: bool) -> String {
75 if color {
76 msg.dimmed().to_string()
77 } else {
78 msg.to_string()
79 }
80}
81
82fn warn_line(msg: &str, color: bool) -> String {
83 if color {
84 format!("{} {}", "!".yellow(), msg)
85 } else {
86 format!("! {msg}")
87 }
88}
89
90fn heading_line(msg: &str, color: bool) -> String {
91 if color {
92 msg.bold().underline().to_string()
93 } else {
94 msg.to_string()
95 }
96}
97
98pub fn success(msg: &str) {
99 println!("{}", success_line(msg, color_enabled()));
100}
101
102pub fn info(msg: &str) {
103 println!("{}", info_line(msg, color_enabled()));
104}
105
106pub fn warn(msg: &str) {
107 eprintln!("{}", warn_line(msg, color_enabled()));
108}
109
110pub fn heading(msg: &str) {
111 println!("{}", heading_line(msg, color_enabled()));
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum Tone {
117 Bad,
118 Warn,
119 Good,
120 Dim,
121}
122
123fn colored_cell_with(text: &str, tone: Tone, color: bool) -> String {
124 if !color {
125 return text.to_string();
126 }
127 match tone {
128 Tone::Bad => text.red().to_string(),
129 Tone::Warn => text.yellow().to_string(),
130 Tone::Good => text.green().to_string(),
131 Tone::Dim => text.dimmed().to_string(),
132 }
133}
134
135pub fn colored_cell(text: &str, tone: Tone) -> String {
136 colored_cell_with(text, tone, color_enabled())
137}
138
139pub fn tone_for(state: BuildState) -> Tone {
141 match state {
142 BuildState::Failed => Tone::Bad,
143 BuildState::Stopped | BuildState::InProgress => Tone::Warn,
144 BuildState::Successful => Tone::Good,
145 BuildState::None => Tone::Dim,
146 }
147}
148
149pub fn spinner(msg: &str) -> indicatif::ProgressBar {
150 if !std::io::stderr().is_terminal() {
151 return indicatif::ProgressBar::hidden();
152 }
153 let pb = indicatif::ProgressBar::new_spinner();
154 pb.enable_steady_tick(std::time::Duration::from_millis(90));
155 pb.set_message(msg.to_string());
156 pb
157}
158
159pub fn relative_time(iso: &str) -> String {
162 let Ok(parsed) = DateTime::parse_from_rfc3339(iso) else {
163 return iso.to_string();
164 };
165 let parsed = parsed.with_timezone(&Utc);
166 let now = Utc::now();
167
168 if parsed > now {
169 return parsed.format("%b %d, %Y").to_string();
170 }
171
172 let delta = now - parsed;
173 let days = delta.num_days();
174 if days > 7 {
175 return parsed.format("%b %d, %Y").to_string();
176 }
177 if days >= 1 {
178 return format!("{days} day{} ago", plural(days));
179 }
180 let hours = delta.num_hours();
181 if hours >= 1 {
182 return format!("{hours} hour{} ago", plural(hours));
183 }
184 let minutes = delta.num_minutes();
185 format!("{minutes} minute{} ago", plural(minutes))
186}
187
188fn plural(n: i64) -> &'static str {
189 if n == 1 {
190 ""
191 } else {
192 "s"
193 }
194}
195
196#[cfg(test)]
197#[allow(clippy::unwrap_used)]
198mod tests {
199 use super::*;
200 use chrono::{Duration, Utc};
201
202 #[test]
203 fn relative_time_renders_minutes() {
204 let ts = (Utc::now() - Duration::minutes(5)).to_rfc3339();
205 assert_eq!(relative_time(&ts), "5 minutes ago");
206 }
207
208 #[test]
209 fn relative_time_renders_singular_hour() {
210 let ts = (Utc::now() - Duration::minutes(61)).to_rfc3339();
211 assert_eq!(relative_time(&ts), "1 hour ago");
212 }
213
214 #[test]
215 fn relative_time_renders_days() {
216 let ts = (Utc::now() - Duration::days(3)).to_rfc3339();
217 assert_eq!(relative_time(&ts), "3 days ago");
218 }
219
220 #[test]
221 fn relative_time_falls_back_to_absolute_beyond_a_week() {
222 let ts = (Utc::now() - Duration::days(30)).to_rfc3339();
223 let shown = relative_time(&ts);
224 assert!(
225 !shown.contains("ago"),
226 "expected absolute date, got {shown}"
227 );
228 }
229
230 #[test]
231 fn future_timestamps_render_absolute() {
232 let ts = (Utc::now() + Duration::days(2)).to_rfc3339();
233 let shown = relative_time(&ts);
234 assert!(
235 !shown.contains("ago"),
236 "future must not be relative, got {shown}"
237 );
238 }
239
240 #[test]
241 fn unparseable_timestamp_is_passed_through() {
242 assert_eq!(relative_time("not-a-date"), "not-a-date");
243 }
244
245 #[test]
246 fn table_contains_headers_and_cells() {
247 let out = table(&["ID", "TITLE"], vec![vec!["7".into(), "fix thing".into()]]);
248 assert!(out.contains("ID"));
249 assert!(out.contains("fix thing"));
250 }
251
252 #[test]
253 fn format_from_flag() {
254 assert!(matches!(Format::from_json_flag(true), Format::Json));
255 assert!(matches!(Format::from_json_flag(false), Format::Human));
256 }
257
258 #[test]
259 fn tone_for_maps_every_build_state() {
260 assert_eq!(tone_for(BuildState::Failed), Tone::Bad);
261 assert_eq!(tone_for(BuildState::Stopped), Tone::Warn);
262 assert_eq!(tone_for(BuildState::InProgress), Tone::Warn);
263 assert_eq!(tone_for(BuildState::Successful), Tone::Good);
264 assert_eq!(tone_for(BuildState::None), Tone::Dim);
265 }
266
267 #[test]
268 fn color_from_tty_without_no_color_is_true() {
269 assert!(color_from(true, false));
270 }
271
272 #[test]
273 fn color_from_tty_with_no_color_is_false() {
274 assert!(!color_from(true, true));
275 }
276
277 #[test]
278 fn color_from_non_tty_without_no_color_is_false() {
279 assert!(!color_from(false, false));
280 }
281
282 #[test]
283 fn color_from_non_tty_with_no_color_is_false() {
284 assert!(!color_from(false, true));
285 }
286
287 #[test]
288 fn success_line_has_no_escape_when_color_disabled() {
289 let line = success_line("done", false);
290 assert!(!line.contains('\x1b'));
291 assert!(line.contains("done"));
292 }
293
294 #[test]
295 fn success_line_has_escape_when_color_enabled() {
296 let line = success_line("done", true);
297 assert!(line.contains('\x1b'));
298 assert!(line.contains("done"));
299 }
300
301 #[test]
302 fn info_line_has_no_escape_when_color_disabled() {
303 let line = info_line("note", false);
304 assert!(!line.contains('\x1b'));
305 assert!(line.contains("note"));
306 }
307
308 #[test]
309 fn info_line_has_escape_when_color_enabled() {
310 let line = info_line("note", true);
311 assert!(line.contains('\x1b'));
312 assert!(line.contains("note"));
313 }
314
315 #[test]
316 fn warn_line_has_no_escape_when_color_disabled() {
317 let line = warn_line("careful", false);
318 assert!(!line.contains('\x1b'));
319 assert!(line.contains("careful"));
320 }
321
322 #[test]
323 fn warn_line_has_escape_when_color_enabled() {
324 let line = warn_line("careful", true);
325 assert!(line.contains('\x1b'));
326 assert!(line.contains("careful"));
327 }
328
329 #[test]
330 fn heading_line_has_no_escape_when_color_disabled() {
331 let line = heading_line("Title", false);
332 assert!(!line.contains('\x1b'));
333 assert!(line.contains("Title"));
334 }
335
336 #[test]
337 fn heading_line_has_escape_when_color_enabled() {
338 let line = heading_line("Title", true);
339 assert!(line.contains('\x1b'));
340 assert!(line.contains("Title"));
341 }
342
343 #[test]
344 fn colored_cell_is_plain_without_color() {
345 assert_eq!(colored_cell_with("FAILED", Tone::Bad, false), "FAILED");
346 assert_eq!(colored_cell_with("-", Tone::Dim, false), "-");
347 }
348
349 #[test]
350 fn colored_cell_wraps_when_color_is_on() {
351 let painted = colored_cell_with("FAILED", Tone::Bad, true);
352 assert!(painted.contains("FAILED"));
353 assert_ne!(painted, "FAILED", "expected an ansi escape around the text");
354 }
355}