1use colored::Colorize;
2use indicatif::{ProgressBar, ProgressStyle};
3use std::future::Future;
4use std::io::IsTerminal;
5use std::time::Duration;
6use supports_color::Stream;
7
8const SPINNER_SETS: &[&[&str]] = &[
9 &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
10 &["◐", "◓", "◑", "◒"],
11 &["▖", "▘", "▝", "▗"],
12 &["∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙●∙"],
13];
14
15pub struct Loader {
16 pb: ProgressBar,
17 label: String,
18}
19
20impl Loader {
21 pub fn start(label: &str) -> Self {
22 let spinner_frames = select_spinner_set(label);
23 let pb = ProgressBar::new_spinner();
24 let style = ProgressStyle::with_template("{spinner:.cyan} {msg}")
25 .unwrap_or_else(|_| ProgressStyle::default_spinner())
26 .tick_strings(spinner_frames);
27 pb.set_style(style);
28 pb.set_message(format!("{}", label.bright_cyan()));
29 pb.enable_steady_tick(Duration::from_millis(95));
30 Self {
31 pb,
32 label: label.to_string(),
33 }
34 }
35
36 pub fn success(&self) {
37 self.pb
38 .finish_with_message(format!("{} {}", "OK".bright_green().bold(), self.label));
39 }
40
41 pub fn success_with(&self, message: &str) {
42 self.pb
43 .finish_with_message(format!("{} {}", "OK".bright_green().bold(), message));
44 }
45
46 pub fn fail(&self, details: &str) {
47 self.pb.finish_with_message(format!(
48 "{} {} {}",
49 "ERR".bright_red().bold(),
50 self.label,
51 format!("({})", details).bright_black()
52 ));
53 }
54
55 pub fn update(&self, message: &str) {
56 self.pb.set_message(format!("{}", message.bright_cyan()));
57 }
58
59 pub fn log(&self, message: &str) {
60 self.pb.println(message);
61 }
62
63 pub fn suspend<T, F>(&self, op: F) -> T
64 where
65 F: FnOnce() -> T,
66 {
67 self.pb.suspend(op)
68 }
69
70 pub fn finish_for_handoff(&self) {
72 self.pb.finish_and_clear();
73 }
74}
75
76pub struct TaskBar {
79 pb: ProgressBar,
80 label: String,
81}
82
83impl TaskBar {
84 pub fn start(label: &str, total: u64) -> Self {
85 let pb = ProgressBar::new(total.max(1));
86 let style = ProgressStyle::with_template(
87 "{spinner:.cyan} {msg} [{bar:28.cyan/blue}] {pos}/{len} {percent:>3}% {elapsed_precise} · ETA {eta_precise}",
88 )
89 .unwrap_or_else(|_| ProgressStyle::default_bar())
90 .progress_chars("█▓░")
91 .tick_strings(select_spinner_set(label));
92 pb.set_style(style);
93 pb.set_message(format!("{}", label.bright_cyan()));
94 pb.enable_steady_tick(Duration::from_millis(95));
95 if total == 0 {
96 pb.set_position(0);
97 }
98 Self {
99 pb,
100 label: label.to_string(),
101 }
102 }
103
104 pub fn set_position(&self, completed: u64) {
105 self.pb.set_position(completed);
106 }
107
108 pub fn set_message(&self, message: &str) {
109 self.pb.set_message(format!("{}", message.bright_cyan()));
110 }
111
112 pub fn tick(&self, completed: u64, message: &str) {
113 self.set_message(message);
114 self.set_position(completed);
115 }
116
117 pub fn println(&self, message: &str) {
118 self.pb.println(message);
119 }
120
121 pub fn success_with(&self, message: &str) {
122 self.pb
123 .finish_with_message(format!("{} {}", "OK".bright_green().bold(), message));
124 }
125
126 pub fn fail(&self, details: &str) {
127 self.pb.finish_with_message(format!(
128 "{} {} {}",
129 "ERR".bright_red().bold(),
130 self.label,
131 format!("({})", details).bright_black()
132 ));
133 }
134}
135
136pub fn format_duration_short(d: Duration) -> String {
138 let secs = d.as_secs_f64();
139 if secs < 60.0 {
140 if secs < 10.0 {
141 format!("{secs:.1}s")
142 } else {
143 format!("{:.0}s", secs.round())
144 }
145 } else if secs < 3600.0 {
146 let m = (secs / 60.0).floor() as u64;
147 let s = (secs % 60.0).round() as u64;
148 format!("{m}m{s:02}s")
149 } else {
150 let h = (secs / 3600.0).floor() as u64;
151 let m = ((secs % 3600.0) / 60.0).floor() as u64;
152 format!("{h}h{m:02}m")
153 }
154}
155
156pub struct TimedTaskBar {
158 inner: Option<TaskBar>,
159 label: String,
160 total: u64,
161 completed: u64,
162 tty: bool,
163 phase_started: std::time::Instant,
164}
165
166impl TimedTaskBar {
167 pub fn start(label: &str, total: u64) -> Self {
168 let tty = std::io::stderr().is_terminal() || std::io::stdout().is_terminal();
169 let total = total.max(1);
170 let inner = if tty {
171 Some(TaskBar::start(label, total))
172 } else {
173 eprintln!("{} ({} unit(s))", label.bright_cyan(), total);
174 None
175 };
176 Self {
177 inner,
178 label: label.to_string(),
179 total,
180 completed: 0,
181 tty,
182 phase_started: std::time::Instant::now(),
183 }
184 }
185
186 pub fn begin_unit(&self, unit_label: &str, remaining_eta: Option<Duration>) {
187 let msg = match remaining_eta {
188 Some(eta) if eta.as_secs() > 0 => {
189 format!(
190 "{} · {} ~{}",
191 self.label,
192 unit_label,
193 format_duration_short(eta)
194 )
195 }
196 _ => format!("{} · {}", self.label, unit_label),
197 };
198 if let Some(bar) = &self.inner {
199 bar.set_message(&msg);
200 bar.set_position(self.completed);
201 } else {
202 eprintln!(
203 " {} {} [{}/{}]",
204 "→".bright_cyan(),
205 unit_label.bright_white(),
206 self.completed + 1,
207 self.total
208 );
209 }
210 }
211
212 pub fn finish_unit(&mut self, unit_label: &str, ok: bool, elapsed: Duration, detail: &str) {
213 self.completed = (self.completed + 1).min(self.total);
214 let time = format_duration_short(elapsed);
215 let line = if ok {
216 format!(
217 " {} {:<28} {} {}",
218 "✓".bright_green().bold(),
219 unit_label.bright_white(),
220 time.bright_black(),
221 detail.bright_black()
222 )
223 } else {
224 format!(
225 " {} {:<28} {} {}",
226 "✗".bright_red().bold(),
227 unit_label.bright_white(),
228 time.bright_black(),
229 detail.bright_red()
230 )
231 };
232 if let Some(bar) = &self.inner {
233 bar.println(&line);
234 bar.set_position(self.completed);
235 } else {
236 eprintln!("{line}");
237 }
238 let _ = self.tty;
239 }
240
241 pub fn success_with(&self, message: &str) {
242 let total = format_duration_short(self.phase_started.elapsed());
243 let msg = format!("{message} ({total})");
244 if let Some(bar) = &self.inner {
245 bar.success_with(&msg);
246 } else {
247 eprintln!("{} {}", "OK".bright_green().bold(), msg);
248 }
249 }
250
251 pub fn fail(&self, details: &str) {
252 if let Some(bar) = &self.inner {
253 bar.fail(details);
254 } else {
255 eprintln!(
256 "{} {} ({})",
257 "ERR".bright_red().bold(),
258 self.label,
259 details.bright_black()
260 );
261 }
262 }
263}
264
265pub async fn with_loader<T, E, F>(label: &str, op: F) -> Result<T, E>
266where
267 E: std::fmt::Display,
268 F: Future<Output = Result<T, E>>,
269{
270 let loader = Loader::start(label);
271 let result = op.await;
272 match &result {
273 Ok(_) => loader.success(),
274 Err(err) => loader.fail(&err.to_string()),
275 }
276 result
277}
278
279pub fn print_cli_header(command: &str, debug: bool) {
280 let mode = if debug {
281 "DEBUG".bright_yellow().bold().to_string()
282 } else {
283 "NORMAL".bright_blue().bold().to_string()
284 };
285 println!(
286 "{} {} {} {}",
287 "xbp".bright_magenta().bold(),
288 "→".bright_black(),
289 command.bright_white().bold(),
290 format!("[{}]", mode).bright_black()
291 );
292}
293
294pub fn configure_color_output() {
295 colored::control::set_override(should_emit_ansi());
296}
297
298pub fn should_emit_ansi() -> bool {
299 let disable_via_clicolor = std::env::var("CLICOLOR")
301 .map(|value| value == "0")
302 .unwrap_or(false);
303 if std::env::var_os("NO_COLOR").is_some() || disable_via_clicolor {
304 return false;
305 }
306 if std::env::var_os("FORCE_COLOR").is_some() || std::env::var_os("CLICOLOR_FORCE").is_some() {
307 return true;
308 }
309
310 if legacy_cmd_without_color_hints() {
311 return false;
312 }
313
314 let stdout_color = supports_color::on(Stream::Stdout).is_some();
315 let stderr_color = supports_color::on(Stream::Stderr).is_some();
316 stdout_color || stderr_color
317}
318
319pub fn section(title: &str) {
320 println!(
321 "\n{} {}",
322 "◆".bright_blue().bold(),
323 title.bright_blue().bold()
324 );
325}
326
327pub fn divider(width: usize) {
328 println!("{}", "─".repeat(width).bright_black());
329}
330
331pub fn status_line(label: &str, status: &str, ok: bool) {
332 let icon = if ok {
333 "✓".bright_green().bold()
334 } else {
335 "✗".bright_red().bold()
336 };
337 let status = if ok {
338 status.bright_green().to_string()
339 } else {
340 status.bright_red().to_string()
341 };
342 println!(" {} {} {}", icon, label.bright_white(), status);
343}
344
345pub fn tip(message: &str) {
346 println!("{} {}", "Hint:".bright_yellow().bold(), message);
347}
348
349fn select_spinner_set(label: &str) -> &'static [&'static str] {
350 let hash = label
351 .bytes()
352 .fold(0_u64, |acc, b| acc.wrapping_mul(16777619) ^ u64::from(b));
353 let idx = (hash as usize) % SPINNER_SETS.len();
354 SPINNER_SETS[idx]
355}
356
357#[cfg(windows)]
358fn legacy_cmd_without_color_hints() -> bool {
359 let Some(comspec) = std::env::var("ComSpec").ok() else {
360 return false;
361 };
362
363 if !comspec.to_ascii_lowercase().ends_with("cmd.exe") {
364 return false;
365 }
366
367 let has_color_hints = std::env::var_os("WT_SESSION").is_some()
368 || std::env::var_os("ANSICON").is_some()
369 || std::env::var("TERM_PROGRAM").is_ok()
370 || std::env::var("TERM").is_ok_and(|value| value != "dumb")
371 || std::env::var("ConEmuANSI")
372 .map(|value| value.eq_ignore_ascii_case("ON"))
373 .unwrap_or(false);
374
375 !has_color_hints
376}
377
378#[cfg(not(windows))]
379fn legacy_cmd_without_color_hints() -> bool {
380 false
381}
382
383#[cfg(all(test, windows))]
384mod tests {
385 use super::legacy_cmd_without_color_hints;
386 use once_cell::sync::Lazy;
387 use std::sync::Mutex;
388
389 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
390
391 #[test]
392 fn cmd_without_color_hints_disables_ansi() {
393 let _guard = ENV_LOCK.lock().expect("env lock");
394
395 let keys = [
396 "ComSpec",
397 "WT_SESSION",
398 "ANSICON",
399 "TERM_PROGRAM",
400 "TERM",
401 "ConEmuANSI",
402 ];
403 let snapshot: Vec<(String, Option<String>)> = keys
404 .iter()
405 .map(|key| ((*key).to_string(), std::env::var(key).ok()))
406 .collect();
407
408 std::env::set_var("ComSpec", r"C:\Windows\System32\cmd.exe");
409 for key in [
410 "WT_SESSION",
411 "ANSICON",
412 "TERM_PROGRAM",
413 "TERM",
414 "ConEmuANSI",
415 ] {
416 std::env::remove_var(key);
417 }
418
419 assert!(legacy_cmd_without_color_hints());
420
421 std::env::set_var("WT_SESSION", "1");
422 assert!(!legacy_cmd_without_color_hints());
423
424 for (key, value) in snapshot {
425 match value {
426 Some(value) => std::env::set_var(key, value),
427 None => std::env::remove_var(key),
428 }
429 }
430 }
431}