Skip to main content

bb_cli/
output.rs

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