Skip to main content

epicenter_cli/
util.rs

1//! Small formatting/parsing helpers shared by the other modules.
2
3use std::io::IsTerminal;
4use std::path::PathBuf;
5use std::sync::OnceLock;
6use std::time::{Duration, SystemTime};
7
8use anyhow::Result;
9
10// ── terminal colors ─────────────────────────────────────────────────────────
11//
12// Palette: one brand green (#92D293) for headers/accents, dim gray for labels,
13// amber strictly for warnings, red only for failures — calm by default, loud
14// only when something is wrong. Colors auto-disable when stdout is not a
15// terminal, when `NO_COLOR` is set, and force-enable with `CLICOLOR_FORCE`.
16
17const GREEN: &str = "38;2;146;210;147"; // #92D293 — headers, success, accents
18const WARN: &str = "38;2;230;180;85"; // #E6B455 — warnings only
19const FAIL: &str = "38;2;224;108;117"; // #E06C75 — failures only
20const INFO: &str = "38;2;126;166;224"; // #7EA6E0 — neutral tips
21const DIM: &str = "2"; // labels, legends, secondary detail
22
23fn colors_enabled() -> bool {
24    static ENABLED: OnceLock<bool> = OnceLock::new();
25    *ENABLED.get_or_init(|| {
26        if std::env::var_os("NO_COLOR").is_some() {
27            return false;
28        }
29        if let Some(force) = std::env::var_os("CLICOLOR_FORCE") {
30            if force != "0" && !force.is_empty() {
31                return true;
32            }
33        }
34        std::io::stdout().is_terminal()
35    })
36}
37
38fn paint(enabled: bool, code: &str, text: &str) -> String {
39    if enabled {
40        format!("\x1b[{code}m{text}\x1b[0m")
41    } else {
42        text.to_string()
43    }
44}
45
46/// Brand-green text (`#92D293`) — section titles and success accents.
47pub fn green(text: &str) -> String {
48    paint(colors_enabled(), GREEN, text)
49}
50
51/// Bold brand green — top-level headings.
52pub fn heading(text: &str) -> String {
53    paint(colors_enabled(), &format!("1;{GREEN}"), text)
54}
55
56/// Amber — warnings.
57pub fn warn(text: &str) -> String {
58    paint(colors_enabled(), WARN, text)
59}
60
61/// Red — failures.
62pub fn fail(text: &str) -> String {
63    paint(colors_enabled(), FAIL, text)
64}
65
66/// Calm blue — neutral info.
67pub fn info(text: &str) -> String {
68    paint(colors_enabled(), INFO, text)
69}
70
71/// Dimmed — labels, legends, secondary detail.
72pub fn dim(text: &str) -> String {
73    paint(colors_enabled(), DIM, text)
74}
75
76// ── status glyphs ───────────────────────────────────────────────────────────
77//
78// Geometric, single-width, emoji-free: ✓ pass · ▲ warning · ✕ failure · ◆ info.
79
80/// `✓`
81pub const GLYPH_PASS: &str = "✓";
82/// `▲`
83pub const GLYPH_WARN: &str = "▲";
84/// `✕`
85pub const GLYPH_FAIL: &str = "✕";
86/// `◆`
87pub const GLYPH_INFO: &str = "◆";
88
89/// `1234567` -> `"1.2 MiB"` style, compact.
90pub fn human_bytes(bytes: u64) -> String {
91    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
92    let mut value = bytes as f64;
93    let mut unit = 0;
94    while value >= 1024.0 && unit < UNITS.len() - 1 {
95        value /= 1024.0;
96        unit += 1;
97    }
98    if unit == 0 {
99        format!("{bytes} B")
100    } else {
101        format!("{value:.1} {}", UNITS[unit])
102    }
103}
104
105/// Parse `"30d"`, `"2w"`, `"12h"`, `"90m"`, `"45s"` or plain seconds `"45"`.
106pub fn parse_duration(text: &str) -> Result<Duration> {
107    let text = text.trim();
108    let split = text
109        .find(|c: char| c.is_ascii_alphabetic())
110        .unwrap_or(text.len());
111    let (num, unit) = text.split_at(split);
112    // Build the message explicitly: the raw integer-parse error ("cannot parse
113    // integer from empty string") is noise for a CLI user.
114    let n: u64 = num.parse().map_err(|_| {
115        anyhow::anyhow!("invalid duration {text:?} (expected e.g. 30d, 2w, 12h)")
116    })?;
117    let secs = match unit.to_ascii_lowercase().as_str() {
118        "" | "s" => n,
119        "m" => n * 60,
120        "h" => n * 3_600,
121        "d" => n * 86_400,
122        "w" => n * 604_800,
123        other => anyhow::bail!("unknown time unit {other:?} in {text:?} (use s, m, h, d or w)"),
124    };
125    Ok(Duration::from_secs(secs))
126}
127
128/// Age of a timestamp, human-readable ("3.4 days").
129pub fn human_age(at: SystemTime) -> String {
130    match SystemTime::now().duration_since(at) {
131        Ok(age) => human_duration(age),
132        Err(_) => "in the future".to_string(),
133    }
134}
135
136/// One failure rendered in the tool's visual language: a red `✕` line, the
137/// message's own continuation lines indented beneath it, then any `anyhow`
138/// causes as dim rows. Plain glyphs when colors are off.
139pub fn error_block(err: &anyhow::Error) -> String {
140    let mut chain = err.chain();
141    let headline = chain
142        .next()
143        .map(|cause| cause.to_string())
144        .unwrap_or_else(|| "unknown error".to_string());
145    let mut lines = headline.lines();
146    let mut out = format!("{} {}", fail(GLYPH_FAIL), lines.next().unwrap_or(""));
147    for line in lines {
148        out.push_str("\n  ");
149        out.push_str(line);
150    }
151    for cause in chain {
152        out.push_str(&format!("\n  {} {cause}", dim("caused by:")));
153    }
154    out
155}
156
157/// Compact duration for humans ("3.4 s", "1.2 min", "2.1 h").
158pub fn human_duration(duration: Duration) -> String {
159    let secs = duration.as_secs_f64();
160    if secs >= 86_400.0 {
161        format!("{:.1} days", secs / 86_400.0)
162    } else if secs >= 3_600.0 {
163        format!("{:.1} h", secs / 3_600.0)
164    } else if secs >= 60.0 {
165        format!("{:.1} min", secs / 60.0)
166    } else {
167        format!("{} s", duration.as_secs())
168    }
169}
170
171/// Candidate executables for `program`. Cargo installs `kache` as
172/// `kache.exe` on Windows, so the suffix must be considered there.
173fn exe_candidates(program: &str) -> Vec<String> {
174    let mut names = vec![program.to_string()];
175    if cfg!(windows) {
176        names.push(format!("{program}.exe"));
177    }
178    names
179}
180
181/// Is `program` reachable through `PATH`? (bare `which`, no extra crate)
182pub fn on_path(program: &str) -> bool {
183    let names = exe_candidates(program);
184    let Some(path) = std::env::var_os("PATH") else {
185        return false;
186    };
187    std::env::split_paths(&path)
188        .any(|dir| names.iter().any(|name| dir.join(name).is_file()))
189}
190
191/// Absolute path of `program` if discoverable — first on `PATH`, then as a
192/// fallback in Cargo's own bin dir (`$CARGO_HOME/bin`, e.g.
193/// `C:\Users\<user>\.cargo\bin`), which rustup tools live in but isn't on
194/// PATH in every shell. Returns `None` when not found anywhere.
195pub fn which_path(program: &str) -> Option<PathBuf> {
196    let names = exe_candidates(program);
197    if let Some(path) = std::env::var_os("PATH") {
198        for dir in std::env::split_paths(&path) {
199            for name in &names {
200                let candidate = dir.join(name);
201                if candidate.is_file() {
202                    return Some(candidate);
203                }
204            }
205        }
206    }
207    let cargo_bin = crate::paths::cargo_home().ok()?.join("bin");
208    names
209        .iter()
210        .map(|name| cargo_bin.join(name))
211        .find(|candidate| candidate.is_file())
212}
213
214/// Is `program` installed at all (PATH or cargo bin dir)?
215pub fn which(program: &str) -> bool {
216    which_path(program).is_some()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use anyhow::Context;
223
224    #[test]
225    fn parses_time_units() {
226        assert_eq!(parse_duration("30d").unwrap(), Duration::from_secs(30 * 86_400));
227        assert_eq!(parse_duration("2w").unwrap(), Duration::from_secs(2 * 604_800));
228        assert_eq!(parse_duration("12h").unwrap(), Duration::from_secs(12 * 3_600));
229        assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
230        assert!(parse_duration("abc").is_err());
231        assert!(parse_duration("5x").is_err());
232    }
233
234    #[test]
235    fn human_sizes() {
236        assert_eq!(human_bytes(0), "0 B");
237        assert_eq!(human_bytes(1023), "1023 B");
238        assert_eq!(human_bytes(1024), "1.0 KiB");
239        assert_eq!(human_bytes(3 * 1_048_576 + 200_000), "3.2 MiB");
240    }
241
242    #[test]
243    fn exe_candidates_is_platform_aware() {
244        let names = exe_candidates("kache");
245        assert!(names.contains(&"kache".to_string()));
246        #[cfg(windows)]
247        assert!(names.contains(&"kache.exe".to_string()));
248        #[cfg(not(windows))]
249        assert_eq!(names.len(), 1);
250    }
251
252    #[test]
253    fn paint_is_pure() {
254        assert_eq!(paint(false, "38;2;146;210;147", "hi"), "hi");
255        assert_eq!(paint(true, "1", "hi"), "\x1b[1mhi\x1b[0m");
256    }
257
258    #[test]
259    fn error_blocks_render_a_glyph_indent_and_causes() {
260        let plain = anyhow::anyhow!("nothing selected — pass one selector:\n--stale   older toolchains");
261        let block = error_block(&plain);
262        assert_eq!(block, "✕ nothing selected — pass one selector:\n  --stale   older toolchains");
263
264        let chained = Err::<(), _>(anyhow::anyhow!("inner detail")).context("outer headline");
265        let block = error_block(&chained.unwrap_err());
266        assert_eq!(block, "✕ outer headline\n  caused by: inner detail");
267    }
268}