Skip to main content

xbp_cli/cli/
ui.rs

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    /// Finish the spinner so a determinate bar can take over the next line.
71    pub fn finish_for_handoff(&self) {
72        self.pb.finish_and_clear();
73    }
74}
75
76/// Left-to-right determinate progress bar (tqdm-style) for multi-item work
77/// like secrets push / pull.
78pub 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
136/// Format a duration for TTY progress lines (`1m48s`, `42.1s`, `3h02m`).
137pub 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
156/// Determinate multi-unit bar with unit lines, elapsed, and optional ETA from history.
157pub 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    // Respect common color control environment variables first.
300    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}