1use std::io::IsTerminal;
4use std::path::PathBuf;
5use std::sync::OnceLock;
6use std::time::{Duration, SystemTime};
7
8use anyhow::Result;
9
10const GREEN: &str = "38;2;146;210;147"; const WARN: &str = "38;2;230;180;85"; const FAIL: &str = "38;2;224;108;117"; const INFO: &str = "38;2;126;166;224"; const DIM: &str = "2"; fn 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
46pub fn green(text: &str) -> String {
48 paint(colors_enabled(), GREEN, text)
49}
50
51pub fn heading(text: &str) -> String {
53 paint(colors_enabled(), &format!("1;{GREEN}"), text)
54}
55
56pub fn warn(text: &str) -> String {
58 paint(colors_enabled(), WARN, text)
59}
60
61pub fn fail(text: &str) -> String {
63 paint(colors_enabled(), FAIL, text)
64}
65
66pub fn info(text: &str) -> String {
68 paint(colors_enabled(), INFO, text)
69}
70
71pub fn dim(text: &str) -> String {
73 paint(colors_enabled(), DIM, text)
74}
75
76pub const GLYPH_PASS: &str = "✓";
82pub const GLYPH_WARN: &str = "▲";
84pub const GLYPH_FAIL: &str = "✕";
86pub const GLYPH_INFO: &str = "◆";
88
89pub 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
105pub 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 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
128pub 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
136pub 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
157pub 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
171fn 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
181pub 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
191pub 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
214pub 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}