1use colored::Colorize;
2use indicatif::{ProgressBar, ProgressStyle};
3use std::future::Future;
4use std::time::Duration;
5use supports_color::Stream;
6
7const SPINNER_SETS: &[&[&str]] = &[
8 &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
9 &["◐", "◓", "◑", "◒"],
10 &["▖", "▘", "▝", "▗"],
11 &["∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙●∙"],
12];
13
14pub struct Loader {
15 pb: ProgressBar,
16 label: String,
17}
18
19impl Loader {
20 pub fn start(label: &str) -> Self {
21 let spinner_frames = select_spinner_set(label);
22 let pb = ProgressBar::new_spinner();
23 let style = ProgressStyle::with_template("{spinner:.cyan} {msg}")
24 .unwrap_or_else(|_| ProgressStyle::default_spinner())
25 .tick_strings(spinner_frames);
26 pb.set_style(style);
27 pb.set_message(format!("{}", label.bright_cyan()));
28 pb.enable_steady_tick(Duration::from_millis(95));
29 Self {
30 pb,
31 label: label.to_string(),
32 }
33 }
34
35 pub fn success(&self) {
36 self.pb
37 .finish_with_message(format!("{} {}", "OK".bright_green().bold(), self.label));
38 }
39
40 pub fn success_with(&self, message: &str) {
41 self.pb
42 .finish_with_message(format!("{} {}", "OK".bright_green().bold(), message));
43 }
44
45 pub fn fail(&self, details: &str) {
46 self.pb.finish_with_message(format!(
47 "{} {} {}",
48 "ERR".bright_red().bold(),
49 self.label,
50 format!("({})", details).bright_black()
51 ));
52 }
53
54 pub fn update(&self, message: &str) {
55 self.pb.set_message(format!("{}", message.bright_cyan()));
56 }
57
58 pub fn log(&self, message: &str) {
59 self.pb.println(message);
60 }
61
62 pub fn suspend<T, F>(&self, op: F) -> T
63 where
64 F: FnOnce() -> T,
65 {
66 self.pb.suspend(op)
67 }
68
69 pub fn finish_for_handoff(&self) {
71 self.pb.finish_and_clear();
72 }
73}
74
75pub struct TaskBar {
78 pb: ProgressBar,
79 label: String,
80}
81
82impl TaskBar {
83 pub fn start(label: &str, total: u64) -> Self {
84 let pb = ProgressBar::new(total.max(1));
85 let style = ProgressStyle::with_template(
86 "{spinner:.cyan} {msg} [{bar:32.cyan/blue}] {pos}/{len} ({percent}%)",
87 )
88 .unwrap_or_else(|_| ProgressStyle::default_bar())
89 .progress_chars("█▓░")
90 .tick_strings(select_spinner_set(label));
91 pb.set_style(style);
92 pb.set_message(format!("{}", label.bright_cyan()));
93 pb.enable_steady_tick(Duration::from_millis(95));
94 if total == 0 {
95 pb.set_position(0);
96 }
97 Self {
98 pb,
99 label: label.to_string(),
100 }
101 }
102
103 pub fn set_position(&self, completed: u64) {
104 self.pb.set_position(completed);
105 }
106
107 pub fn set_message(&self, message: &str) {
108 self.pb.set_message(format!("{}", message.bright_cyan()));
109 }
110
111 pub fn tick(&self, completed: u64, message: &str) {
112 self.set_message(message);
113 self.set_position(completed);
114 }
115
116 pub fn success_with(&self, message: &str) {
117 self.pb
118 .finish_with_message(format!("{} {}", "OK".bright_green().bold(), message));
119 }
120
121 pub fn fail(&self, details: &str) {
122 self.pb.finish_with_message(format!(
123 "{} {} {}",
124 "ERR".bright_red().bold(),
125 self.label,
126 format!("({})", details).bright_black()
127 ));
128 }
129}
130
131pub async fn with_loader<T, E, F>(label: &str, op: F) -> Result<T, E>
132where
133 E: std::fmt::Display,
134 F: Future<Output = Result<T, E>>,
135{
136 let loader = Loader::start(label);
137 let result = op.await;
138 match &result {
139 Ok(_) => loader.success(),
140 Err(err) => loader.fail(&err.to_string()),
141 }
142 result
143}
144
145pub fn print_cli_header(command: &str, debug: bool) {
146 let mode = if debug {
147 "DEBUG".bright_yellow().bold().to_string()
148 } else {
149 "NORMAL".bright_blue().bold().to_string()
150 };
151 println!(
152 "{} {} {} {}",
153 "xbp".bright_magenta().bold(),
154 "→".bright_black(),
155 command.bright_white().bold(),
156 format!("[{}]", mode).bright_black()
157 );
158}
159
160pub fn configure_color_output() {
161 colored::control::set_override(should_emit_ansi());
162}
163
164pub fn should_emit_ansi() -> bool {
165 let disable_via_clicolor = std::env::var("CLICOLOR")
167 .map(|value| value == "0")
168 .unwrap_or(false);
169 if std::env::var_os("NO_COLOR").is_some() || disable_via_clicolor {
170 return false;
171 }
172 if std::env::var_os("FORCE_COLOR").is_some() || std::env::var_os("CLICOLOR_FORCE").is_some() {
173 return true;
174 }
175
176 if legacy_cmd_without_color_hints() {
177 return false;
178 }
179
180 let stdout_color = supports_color::on(Stream::Stdout).is_some();
181 let stderr_color = supports_color::on(Stream::Stderr).is_some();
182 stdout_color || stderr_color
183}
184
185pub fn section(title: &str) {
186 println!(
187 "\n{} {}",
188 "◆".bright_blue().bold(),
189 title.bright_blue().bold()
190 );
191}
192
193pub fn divider(width: usize) {
194 println!("{}", "─".repeat(width).bright_black());
195}
196
197pub fn status_line(label: &str, status: &str, ok: bool) {
198 let icon = if ok {
199 "✓".bright_green().bold()
200 } else {
201 "✗".bright_red().bold()
202 };
203 let status = if ok {
204 status.bright_green().to_string()
205 } else {
206 status.bright_red().to_string()
207 };
208 println!(" {} {} {}", icon, label.bright_white(), status);
209}
210
211pub fn tip(message: &str) {
212 println!("{} {}", "Hint:".bright_yellow().bold(), message);
213}
214
215fn select_spinner_set(label: &str) -> &'static [&'static str] {
216 let hash = label
217 .bytes()
218 .fold(0_u64, |acc, b| acc.wrapping_mul(16777619) ^ u64::from(b));
219 let idx = (hash as usize) % SPINNER_SETS.len();
220 SPINNER_SETS[idx]
221}
222
223#[cfg(windows)]
224fn legacy_cmd_without_color_hints() -> bool {
225 let Some(comspec) = std::env::var("ComSpec").ok() else {
226 return false;
227 };
228
229 if !comspec.to_ascii_lowercase().ends_with("cmd.exe") {
230 return false;
231 }
232
233 let has_color_hints = std::env::var_os("WT_SESSION").is_some()
234 || std::env::var_os("ANSICON").is_some()
235 || std::env::var("TERM_PROGRAM").is_ok()
236 || std::env::var("TERM").is_ok_and(|value| value != "dumb")
237 || std::env::var("ConEmuANSI")
238 .map(|value| value.eq_ignore_ascii_case("ON"))
239 .unwrap_or(false);
240
241 !has_color_hints
242}
243
244#[cfg(not(windows))]
245fn legacy_cmd_without_color_hints() -> bool {
246 false
247}
248
249#[cfg(all(test, windows))]
250mod tests {
251 use super::legacy_cmd_without_color_hints;
252 use once_cell::sync::Lazy;
253 use std::sync::Mutex;
254
255 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
256
257 #[test]
258 fn cmd_without_color_hints_disables_ansi() {
259 let _guard = ENV_LOCK.lock().expect("env lock");
260
261 let keys = [
262 "ComSpec",
263 "WT_SESSION",
264 "ANSICON",
265 "TERM_PROGRAM",
266 "TERM",
267 "ConEmuANSI",
268 ];
269 let snapshot: Vec<(String, Option<String>)> = keys
270 .iter()
271 .map(|key| ((*key).to_string(), std::env::var(key).ok()))
272 .collect();
273
274 std::env::set_var("ComSpec", r"C:\Windows\System32\cmd.exe");
275 for key in [
276 "WT_SESSION",
277 "ANSICON",
278 "TERM_PROGRAM",
279 "TERM",
280 "ConEmuANSI",
281 ] {
282 std::env::remove_var(key);
283 }
284
285 assert!(legacy_cmd_without_color_hints());
286
287 std::env::set_var("WT_SESSION", "1");
288 assert!(!legacy_cmd_without_color_hints());
289
290 for (key, value) in snapshot {
291 match value {
292 Some(value) => std::env::set_var(key, value),
293 None => std::env::remove_var(key),
294 }
295 }
296 }
297}