Skip to main content

cli_ui/
progress.rs

1//! Download progress tracking.
2//!
3//! [`Progress`] tracks concurrent downloads and prints each result as it
4//! completes. URLs are truncated to fit the terminal width automatically.
5//!
6//! # Example
7//! ```rust,no_run
8//! use cli_ui::Progress;
9//!
10//! let pb = Progress::new(13);
11//! pb.ok("style", "remote", "https://example.com/style.css", "./css/style.css", 6041);
12//! pb.fail("script", "https://example.com/app.js", "connection refused");
13//! pb.finish();
14//! ```
15
16use crate::styles::*;
17use crate::term;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use std::sync::Arc;
20
21/// Tracks download progress and prints styled result lines.
22pub struct Progress {
23    total: usize,
24    current: Arc<AtomicUsize>,
25}
26
27impl Progress {
28    /// Create a new progress tracker for `total` items.
29    pub fn new(total: usize) -> Self {
30        Self {
31            total,
32            current: Arc::new(AtomicUsize::new(0)),
33        }
34    }
35
36    /// Print a successful download line.
37    ///
38    /// ```text
39    ///   ✓  [style ][remote]  https://…  →  ./css/file.css  [1.37 KB]
40    /// ```
41    pub fn ok(&self, kind: &str, source: &str, url: &str, local: &str, bytes: usize) {
42        let n = self.current.fetch_add(1, Ordering::Relaxed) + 1;
43        let total = self.total;
44        let size = format_bytes(bytes);
45        let term_w = term::width();
46
47        // counter prefix: (5/13)
48        let counter = paint(DIM, &format!("({n}/{total})"));
49        let kind_s = paint(DIM, &format!("[{:<6}]", kind));
50        let src_s = paint(DIM, &format!("[{:<6}]", source));
51        let arrow = paint(DIM, "→");
52        let local_s = paint(CYAN, local);
53        let size_s = paint(DIM, &format!("[{size}]"));
54
55        // truncate URL to fit terminal
56        let prefix_len = 3 + 8 + 8 + 4 + local.len() + size.len() + 12;
57        let url_budget = term_w.saturating_sub(prefix_len).max(20);
58        let url_display = truncate_url(url, url_budget);
59        let url_s = paint(DIM, &url_display);
60
61        eprintln!(
62            "   {}  {}  {}  {}  {}  {}  {}",
63            counter, kind_s, src_s, url_s, arrow, local_s, size_s
64        );
65    }
66
67    /// Print a failed download line, aligned with `ok()` lines.
68    ///
69    /// ```text
70    ///   (4/5)  [file  ][error ]  https://…  (connection timeout)
71    /// ```
72    pub fn fail(&self, kind: &str, url: &str, error: &str) {
73        let n = self.current.fetch_add(1, Ordering::Relaxed) + 1;
74        let total = self.total;
75        let counter = paint(DIM, &format!("({n}/{total})"));
76        let term_w = term::width();
77        let prefix_len = 3 + 8 + 8 + 4 + error.len() + 6;
78        let url_budget = term_w.saturating_sub(prefix_len).max(20);
79        let url_s = paint(DIM, &truncate_url(url, url_budget));
80        eprintln!(
81            "   {}  {}  {}  {}  {}",
82            counter,
83            paint(ERR, &format!("[{:<6}]", kind)),
84            paint(ERR, "[error ]"),
85            url_s,
86            paint(ERR, &format!("({error})")),
87        );
88    }
89
90    /// Print a blank line after the download block.
91    pub fn finish(&self) {
92        eprintln!();
93    }
94}
95
96/// Print a CSS sub-step line.
97///
98/// ```text
99///      └─  https://fonts.gstatic.com/…  →  ./fonts/file.ttf  [317 KB]
100/// ```
101pub fn substep(url: &str, local: &str) {
102    eprintln!(
103        "      {}  {}  {}  {}",
104        paint(DIM, BRANCH),
105        paint(DIM, url),
106        paint(DIM, "→"),
107        paint(CYAN, local),
108    );
109}
110
111/// Format a byte count as a human-readable string.
112///
113/// # Examples
114/// ```
115/// use cli_ui::progress::format_bytes;
116/// assert_eq!(format_bytes(512),       "512 B");
117/// assert_eq!(format_bytes(1500),      "1.46 KB");
118/// assert_eq!(format_bytes(1_500_000), "1.43 MB");
119/// ```
120pub fn format_bytes(bytes: usize) -> String {
121    if bytes >= 1_048_576 {
122        format!("{:.2} MB", bytes as f64 / 1_048_576.0)
123    } else if bytes >= 1_024 {
124        format!("{:.2} KB", bytes as f64 / 1_024.0)
125    } else {
126        format!("{bytes} B")
127    }
128}
129
130/// Truncate a URL to `max_len` characters, keeping the end visible.
131///
132/// `"https://fonts.gstatic.com/s/inter/v20/UcCO…ttf"` → `"…/UcCO…ttf"`
133fn truncate_url(url: &str, max_len: usize) -> String {
134    if url.len() <= max_len {
135        return url.to_string();
136    }
137    let keep = max_len.saturating_sub(1);
138    format!("…{}", &url[url.len() - keep..])
139}