Skip to main content

harmont_cli/output/
format.rs

1//! Visual and DX helpers for the CLI: relative times, status pills,
2//! hyperlinks, banners, headers, key/value rows and empty states.
3//!
4//! These helpers are the foundation for the CLI's "beautiful, modern, fun"
5//! design language. Every command should funnel its visual output through
6//! this module rather than reaching for `owo_colors` directly.
7#![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/// Render an epoch timestamp as a human-friendly relative time.
17///
18/// - `0` → `—`
19/// - `|delta| < 5s` → `just now`
20/// - `< 60s` → `12s ago` / `in 12s`
21/// - `< 1h`  → `3m ago` / `in 3m`
22/// - `< 1d`  → `2h ago` / `in 2h`
23/// - `< 1w`  → `5d ago` / `in 5d`
24/// - otherwise → `%b %e` (e.g. `Jan 12`)
25#[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        // Fall back to absolute date formatting.
48        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/// Render a duration in seconds as `42s` / `3m12s` / `1h05m`.
60///
61/// Values `<= 0` render as `—`.
62#[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/// Elapsed duration between two epoch timestamps.
81///
82/// - `start == 0` → `—`
83/// - `end == 0` → use now as the end
84/// - otherwise `duration_human(end - start)`
85#[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
98/// Return the (style, icon) pair for a given status string.
99fn 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/// Render a status pill: `"<icon> <status>"`, both styled in the
113/// status color.
114#[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
121/// Detect whether OSC 8 hyperlinks should be emitted by default.
122///
123/// Returns false if `NO_COLOR` is set, or if `TERM_PROGRAM` is empty or
124/// `"dumb"`; otherwise true.
125fn 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/// Render a hyperlink, auto-detecting terminal support.
137#[must_use]
138pub fn hyperlink(url: &str, label: &str) -> String {
139    hyperlink_with(url, label, supports_hyperlinks())
140}
141
142/// Render a hyperlink with explicit support toggle.
143///
144/// When `enabled`, emits an OSC 8 escape sequence. Otherwise falls back
145/// to `<label> (<url-dimmed>)`.
146#[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
157/// Print a section header: blank line, bold title, a unicode rule.
158pub 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
166/// Print a key/value row, aligned at column 12.
167pub fn kv(label: &str, value: impl Display) {
168    let label_with_colon = format!("{label}:");
169    // Right-pad to width 10 so values line up at column 12 from start
170    // of line (2 spaces + 10-wide label field = 12).
171    let padded = format!("{label_with_colon:<10}");
172    println!("  {} {value}", padded.bright_black());
173}
174
175/// Print an empty-state message: blank line, bold title, dim hint, blank line.
176pub fn empty_state(title: &str, hint: &str) {
177    println!();
178    println!("  {}", title.bold());
179    println!("  {}", hint.bright_black());
180    println!();
181}
182
183/// Print a command banner: `▌ hm <command> · <subtitle>`.
184pub 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
195/// Print a single step line: `  ✓ <verb-dim> <result>`.
196pub 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        // ~10s elapsed; allow both "10s" and "11s" for clock drift.
281        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}