1use 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 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 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 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 pub fn heading(&self, text: &str) {
85 self.out(text, &self.paint(BOLD, text));
86 }
87
88 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 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 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 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() };
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 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 pub fn spinner(&self, label: &str) -> SpinnerGuard {
174 if let Some(log) = &self.log {
175 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 eprintln!(" {label_owned}…");
215 None
216 };
217 SpinnerGuard { stop, handle }
218 }
219
220 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
263pub 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
278pub 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}