Skip to main content

bb_cli/
output.rs

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/// The meaning a cell carries, so callers pick intent rather than a colour.
115#[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
139/// The single source of truth for how a build state maps to a colour intent.
140pub 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
159/// Human-friendly timestamp. Past values within a week are relative; anything
160/// older, or in the future, renders as an absolute date.
161pub 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}