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
123pub 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}