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