Skip to main content

rootle_manager/
progress.rs

1//! CLI output aesthetics for the provider manager (plans/0010):
2//! the uv/mise grammar — bold-cyan in-progress verbs that flip to
3//! bold-green on completion, a braille spinner during downloads, dim
4//! step summaries with bold counts and timings. Std only: raw ANSI,
5//! `IsTerminal` gating, `NO_COLOR` respected.
6//!
7//! `Ui::recorder()` swaps the writes for a capture buffer — tests
8//! assert the rendered step sequence without touching real stderr.
9
10use std::io::{IsTerminal, Write};
11use std::sync::Arc;
12use std::sync::Mutex;
13use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
14use std::time::{Duration, Instant};
15
16const DIM: &str = "\x1b[2m";
17const BOLD: &str = "\x1b[1m";
18const RED: &str = "\x1b[31m";
19const GREEN: &str = "\x1b[32m";
20const CYAN: &str = "\x1b[36m";
21const YELLOW: &str = "\x1b[33m";
22const RESET: &str = "\x1b[0m";
23
24const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
25
26pub struct ProgressOutput {
27    color: bool,
28    stderr_tty: bool,
29    /// Recorder mode (tests): lines land here instead of stderr.
30    log: Option<Arc<Mutex<Vec<String>>>>,
31}
32
33impl ProgressOutput {
34    pub fn new() -> ProgressOutput {
35        let stderr_tty = std::io::stderr().is_terminal();
36        let color = std::env::var("NO_COLOR").is_err() && stderr_tty;
37        ProgressOutput {
38            color,
39            stderr_tty,
40            log: None,
41        }
42    }
43
44    /// A capturing Ui for tests: renders nothing, records the plain
45    /// line each method would have written.
46    pub fn recorder() -> (ProgressOutput, Arc<Mutex<Vec<String>>>) {
47        let log = Arc::new(Mutex::new(Vec::new()));
48        (
49            ProgressOutput {
50                color: false,
51                stderr_tty: false,
52                log: Some(Arc::clone(&log)),
53            },
54            log,
55        )
56    }
57
58    fn paint(&self, code: &str, text: &str) -> String {
59        if self.color {
60            format!("{code}{text}{RESET}")
61        } else {
62            text.to_string()
63        }
64    }
65
66    /// One output line: recorded in recorder mode, written to stderr
67    /// otherwise (clearing the previous line first on a tty).
68    fn out(&self, plain: &str, colored: &str) {
69        if let Some(log) = &self.log {
70            log.lock()
71                .unwrap_or_else(|e| e.into_inner())
72                .push(plain.to_string());
73            return;
74        }
75        let mut err = std::io::stderr().lock();
76        if self.stderr_tty {
77            let _ = write!(err, "\r\x1b[2K");
78        }
79        let _ = writeln!(err, "{}", if self.color { colored } else { plain });
80        let _ = err.flush();
81    }
82
83    /// A bare heading line: `Updating rootle`.
84    pub fn heading(&self, text: &str) {
85        self.out(text, &self.paint(BOLD, text));
86    }
87
88    /// `⠋ Resolving rootledev/rootle-gitlab…` — the in-progress line,
89    /// replaced by the done line on the next step call.
90    pub fn step(&self, verb: &str, detail: &str) {
91        self.out(
92            &format!(" ● {verb} {detail}…"),
93            &format!(
94                " {} {} {detail}…",
95                self.paint(CYAN, "●"),
96                self.paint(&format!("{BOLD}{CYAN}"), verb),
97            ),
98        );
99    }
100
101    /// `✓ Resolved rootledev/rootle-gitlab` — the completed step.
102    /// Replaces the in-progress line (same position, same verb, now
103    /// green, past tense implied by the check).
104    pub fn done(&self, verb: &str, detail: &str) {
105        self.out(
106            &format!(" ✓ {verb} {detail}"),
107            &format!(
108                " {} {} {detail}",
109                self.paint(GREEN, "✓"),
110                self.paint(&format!("{BOLD}{GREEN}"), verb),
111            ),
112        );
113    }
114
115    /// A sweep row (0019 M1): ` ✓ gitlab  v0.2.0 → v0.2.1`. The glyph
116    /// colors itself — ✓ green, ✗ red, everything else dim.
117    pub fn update_row(&self, glyph: &str, name: &str, detail: &str) {
118        let color = match glyph {
119            "✓" => GREEN,
120            "✗" => RED,
121            "📌" => YELLOW,
122            _ => DIM,
123        };
124        self.out(
125            &format!(" {glyph} {name}  {detail}"),
126            &format!(
127                " {} {}  {}",
128                self.paint(color, glyph),
129                self.paint(BOLD, name),
130                self.paint(DIM, detail),
131            ),
132        );
133    }
134
135    /// The dim summary line: `Installed gitlab v0.1.0 in 2.3s`.
136    pub fn summary(&self, verb: &str, subject: &str, detail: &str, elapsed: Duration) {
137        let secs = elapsed.as_secs_f64();
138        let timing = if secs >= 1.0 {
139            format!(" in {secs:.1}s")
140        } else if secs * 1000.0 >= 100.0 {
141            format!(" in {:.0}ms", secs * 1000.0)
142        } else {
143            String::new() // instant — no timing
144        };
145        let mut parts: Vec<String> = vec![
146            self.paint(GREEN, "✓"),
147            self.paint(BOLD, verb),
148            self.paint(BOLD, subject),
149        ];
150        let mut plain = format!(" ✓ {verb} {subject}");
151        if !detail.is_empty() {
152            parts.push(self.paint(DIM, detail));
153            plain.push_str(&format!(" {detail}"));
154        }
155        if !timing.is_empty() {
156            parts.push(self.paint(DIM, timing.trim_start()));
157            plain.push_str(&format!(" {}", timing.trim_start()));
158        }
159        self.out(&plain, &parts.join(" "));
160    }
161
162    /// An info line (trust notice, next-step hint).
163    pub fn note(&self, text: &str) {
164        self.out(
165            &format!(" ▸ {text}"),
166            &format!(" {} {}", self.paint(DIM, "▸"), self.paint(DIM, text)),
167        );
168    }
169
170    /// Start a braille spinner on a background thread; returns a guard
171    /// whose Drop stops it and clears the line. The label is the
172    /// in-progress message.
173    pub fn spinner(&self, label: &str) -> SpinnerGuard {
174        if let Some(log) = &self.log {
175            // Recorder mode: the label is the step line, no thread.
176            log.lock()
177                .unwrap_or_else(|e| e.into_inner())
178                .push(format!(" ● {label}…"));
179            return SpinnerGuard {
180                stop: Arc::new(AtomicBool::new(true)),
181                handle: None,
182            };
183        }
184        let stop = Arc::new(AtomicBool::new(false));
185        let frame = Arc::new(AtomicUsize::new(0));
186        let color = self.color;
187        let label_owned = label.to_string();
188        let handle = if self.stderr_tty {
189            let stop = Arc::clone(&stop);
190            let frame = Arc::clone(&frame);
191            Some(std::thread::spawn(move || {
192                let mut err = std::io::stderr().lock();
193                loop {
194                    if stop.load(Ordering::Relaxed) {
195                        break;
196                    }
197                    let i = frame.load(Ordering::Relaxed) % SPINNER.len();
198                    let sp = SPINNER[i];
199                    let _ = write!(err, "\r\x1b[2K");
200                    if color {
201                        let _ = write!(err, "{CYAN}{sp}{RESET} {DIM}{label_owned}…{RESET}");
202                    } else {
203                        let _ = write!(err, "{sp} {label_owned}…");
204                    }
205                    let _ = err.flush();
206                    std::thread::sleep(Duration::from_millis(90));
207                    frame.fetch_add(1, Ordering::Relaxed);
208                }
209                let _ = write!(err, "\r\x1b[2K");
210                let _ = err.flush();
211            }))
212        } else {
213            // Non-TTY: print the label once, no animation.
214            eprintln!("  {label_owned}…");
215            None
216        };
217        SpinnerGuard { stop, handle }
218    }
219
220    /// A list row: `  gitlab  v0.1.0  rootledev/rootle-gitlab  ← ACTIVE`.
221    pub fn row(&self, name: &str, version: &str, source: &str, active: bool, pinned: bool) {
222        let mut err = std::io::stderr().lock();
223        let pin = if pinned {
224            format!(" {}", self.paint(YELLOW, "📌"))
225        } else {
226            String::new()
227        };
228        let marker = if active {
229            format!(" {}", self.paint(&format!("{BOLD}{GREEN}"), "← active"))
230        } else {
231            String::new()
232        };
233        let _ = writeln!(
234            err,
235            "  {} {} {}{}{}",
236            self.paint(BOLD, name),
237            self.paint(GREEN, version),
238            self.paint(DIM, &format!("from {source}")),
239            pin,
240            marker,
241        );
242        let _ = err.flush();
243    }
244
245    pub fn empty_hint(&self) {
246        let mut err = std::io::stderr().lock();
247        let _ = writeln!(
248            err,
249            "  {} no providers installed — {}",
250            self.paint(DIM, "·"),
251            self.paint(&format!("{BOLD}{CYAN}"), "rootle provider install gitlab"),
252        );
253        let _ = err.flush();
254    }
255}
256
257impl Default for ProgressOutput {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263/// Stops the spinner thread on Drop; clears the line.
264pub struct SpinnerGuard {
265    stop: Arc<AtomicBool>,
266    handle: Option<std::thread::JoinHandle<()>>,
267}
268
269impl Drop for SpinnerGuard {
270    fn drop(&mut self) {
271        self.stop.store(true, Ordering::Relaxed);
272        if let Some(h) = self.handle.take() {
273            let _ = h.join();
274        }
275    }
276}
277
278/// A wall-clock helper for the summary timing.
279pub struct Timer {
280    start: Instant,
281}
282
283impl Timer {
284    pub fn start() -> Timer {
285        Timer {
286            start: Instant::now(),
287        }
288    }
289
290    pub fn elapsed(&self) -> Duration {
291        self.start.elapsed()
292    }
293}