1use 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
11const 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 {
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
47pub fn green(text: &str) -> String {
49 paint(colors_enabled(), GREEN, text)
50}
51
52pub fn heading(text: &str) -> String {
54 paint(colors_enabled(), &format!("1;{GREEN}"), text)
55}
56
57pub fn warn(text: &str) -> String {
59 paint(colors_enabled(), WARN, text)
60}
61
62pub fn fail(text: &str) -> String {
64 paint(colors_enabled(), FAIL, text)
65}
66
67pub fn info(text: &str) -> String {
69 paint(colors_enabled(), INFO, text)
70}
71
72pub fn dim(text: &str) -> String {
74 paint(colors_enabled(), DIM, text)
75}
76
77pub const GLYPH_PASS: &str = "✓";
83pub const GLYPH_WARN: &str = "▲";
85pub const GLYPH_FAIL: &str = "✕";
87pub const GLYPH_INFO: &str = "◆";
89
90pub 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
106pub 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 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
129pub 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
137pub 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
158pub 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
172fn 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
182pub 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
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
219pub 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}