harmont_cli/output/
format.rs1#![allow(
8 clippy::print_stdout,
9 reason = "output/format.rs is a first-class print module alongside output/status.rs"
10)]
11
12use chrono::{DateTime, Utc};
13use owo_colors::{OwoColorize, Style};
14use std::fmt::Display;
15
16#[must_use]
26pub fn rel_time(epoch: i64) -> String {
27 if epoch == 0 {
28 return "—".to_string();
29 }
30 let now = Utc::now().timestamp();
31 let delta = now - epoch;
32 let abs = delta.abs();
33
34 if abs < 5 {
35 return "just now".to_string();
36 }
37
38 let (n, unit) = if abs < 60 {
39 (abs, "s")
40 } else if abs < 3600 {
41 (abs / 60, "m")
42 } else if abs < 86400 {
43 (abs / 3600, "h")
44 } else if abs < 7 * 86400 {
45 (abs / 86400, "d")
46 } else {
47 return DateTime::<Utc>::from_timestamp(epoch, 0)
49 .map_or_else(|| "—".to_string(), |dt| dt.format("%b %e").to_string());
50 };
51
52 if delta >= 0 {
53 format!("{n}{unit} ago")
54 } else {
55 format!("in {n}{unit}")
56 }
57}
58
59#[must_use]
63pub fn duration_human(secs: i64) -> String {
64 if secs <= 0 {
65 return "—".to_string();
66 }
67 if secs < 60 {
68 return format!("{secs}s");
69 }
70 if secs < 3600 {
71 let m = secs / 60;
72 let s = secs % 60;
73 return format!("{m}m{s:02}s");
74 }
75 let h = secs / 3600;
76 let m = (secs % 3600) / 60;
77 format!("{h}h{m:02}m")
78}
79
80#[must_use]
86pub fn elapsed_between(start: i64, end: i64) -> String {
87 if start == 0 {
88 return "—".to_string();
89 }
90 let actual_end = if end == 0 {
91 Utc::now().timestamp()
92 } else {
93 end
94 };
95 duration_human(actual_end - start)
96}
97
98fn status_style(status: &str) -> (Style, &'static str) {
100 match status {
101 "passed" => (Style::new().green().bold(), "✓"),
102 "failed" => (Style::new().red().bold(), "✗"),
103 "running" => (Style::new().yellow().bold(), "◐"),
104 "queued" | "scheduled" => (Style::new().blue(), "◷"),
105 "blocked" | "waiting" => (Style::new().magenta(), "⏸"),
106 "canceled" | "canceling" => (Style::new().bright_black(), "⊘"),
107 "skipped" | "not_run" => (Style::new().bright_black(), "⤼"),
108 _ => (Style::new().white(), "•"),
109 }
110}
111
112#[must_use]
115pub fn status_pill(status: &str) -> String {
116 let (style, icon) = status_style(status);
117 let body = format!("{icon} {status}");
118 body.style(style).to_string()
119}
120
121fn supports_hyperlinks() -> bool {
126 if std::env::var_os("NO_COLOR").is_some() {
127 return false;
128 }
129 match std::env::var("TERM_PROGRAM") {
130 Ok(v) if v.is_empty() || v == "dumb" => false,
131 Ok(_) => true,
132 Err(_) => false,
133 }
134}
135
136#[must_use]
138pub fn hyperlink(url: &str, label: &str) -> String {
139 hyperlink_with(url, label, supports_hyperlinks())
140}
141
142#[must_use]
147pub fn hyperlink_with(url: &str, label: &str, enabled: bool) -> String {
148 if enabled {
149 format!("\x1b]8;;{url}\x07{label}\x1b]8;;\x07")
150 } else if label == url {
151 label.to_string()
152 } else {
153 format!("{label} ({})", url.dimmed())
154 }
155}
156
157pub fn header(title: &str) {
159 let rule_len = title.chars().count() + 4;
160 let rule: String = "─".repeat(rule_len);
161 println!();
162 println!(" {}", title.bold());
163 println!(" {}", rule.bright_black());
164}
165
166pub fn kv(label: &str, value: impl Display) {
168 let label_with_colon = format!("{label}:");
169 let padded = format!("{label_with_colon:<10}");
172 println!(" {} {value}", padded.bright_black());
173}
174
175pub fn empty_state(title: &str, hint: &str) {
177 println!();
178 println!(" {}", title.bold());
179 println!(" {}", hint.bright_black());
180 println!();
181}
182
183pub fn banner(command: &str, subtitle: &str) {
185 println!(
186 "{} {} {} {}",
187 "▌".cyan().bold(),
188 "hm".bold(),
189 command.cyan(),
190 format!("· {subtitle}").bright_black()
191 );
192 println!();
193}
194
195pub fn step(verb: &str, result: impl Display) {
197 println!(
198 " {} {} {}",
199 "✓".green().bold(),
200 verb.bright_black(),
201 result
202 );
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use chrono::Utc;
209
210 #[test]
211 fn rel_time_zero_is_dash() {
212 assert_eq!(rel_time(0), "—");
213 }
214
215 #[test]
216 fn rel_time_just_now() {
217 let now = Utc::now().timestamp();
218 assert_eq!(rel_time(now), "just now");
219 }
220
221 #[test]
222 fn rel_time_seconds_ago() {
223 let now = Utc::now().timestamp();
224 assert_eq!(rel_time(now - 12), "12s ago");
225 }
226
227 #[test]
228 fn rel_time_minutes_ago() {
229 let now = Utc::now().timestamp();
230 assert_eq!(rel_time(now - 180), "3m ago");
231 }
232
233 #[test]
234 fn rel_time_hours_ago() {
235 let now = Utc::now().timestamp();
236 assert_eq!(rel_time(now - 2 * 3600), "2h ago");
237 }
238
239 #[test]
240 fn rel_time_days_ago() {
241 let now = Utc::now().timestamp();
242 assert_eq!(rel_time(now - 5 * 86400), "5d ago");
243 }
244
245 #[test]
246 fn rel_time_future_seconds() {
247 let now = Utc::now().timestamp();
248 assert_eq!(rel_time(now + 12), "in 12s");
249 }
250
251 #[test]
252 fn duration_human_zero() {
253 assert_eq!(duration_human(0), "—");
254 }
255
256 #[test]
257 fn duration_human_seconds() {
258 assert_eq!(duration_human(42), "42s");
259 }
260
261 #[test]
262 fn duration_human_minutes() {
263 assert_eq!(duration_human(192), "3m12s");
264 }
265
266 #[test]
267 fn duration_human_hours() {
268 assert_eq!(duration_human(3900), "1h05m");
269 }
270
271 #[test]
272 fn elapsed_between_returns_dash_when_unstarted() {
273 assert_eq!(elapsed_between(0, 0), "—");
274 }
275
276 #[test]
277 fn elapsed_between_uses_now_when_unfinished() {
278 let now = Utc::now().timestamp();
279 let s = elapsed_between(now - 10, 0);
280 assert!(s.ends_with('s'), "got: {s}");
282 }
283
284 #[test]
285 fn hyperlink_fallback_when_disabled() {
286 let s = hyperlink_with("https://x.test", "click", false);
287 assert!(s.contains("click"));
288 assert!(s.contains("https://x.test"));
289 assert!(!s.contains("\x1b]8;;"));
290 }
291
292 #[test]
293 fn hyperlink_emits_osc8_when_enabled() {
294 let s = hyperlink_with("https://x.test", "click", true);
295 assert!(s.contains("\x1b]8;;https://x.test\x07click\x1b]8;;\x07"));
296 }
297
298 #[test]
299 fn status_pill_known_status_contains_label() {
300 assert!(status_pill("passed").contains("passed"));
301 assert!(status_pill("failed").contains("failed"));
302 assert!(status_pill("running").contains("running"));
303 }
304
305 #[test]
306 fn hyperlink_fallback_collapses_when_label_is_url() {
307 let s = hyperlink_with("https://x.test", "https://x.test", false);
308 assert_eq!(s, "https://x.test");
309 }
310}