rootle-manager 0.11.0

Install, update, pin, and sweep rootle's stdio provider binaries (krew-model atomicity)
Documentation
//! CLI output aesthetics for the provider manager (plans/0010):
//! the uv/mise grammar — bold-cyan in-progress verbs that flip to
//! bold-green on completion, a braille spinner during downloads, dim
//! step summaries with bold counts and timings. Std only: raw ANSI,
//! `IsTerminal` gating, `NO_COLOR` respected.
//!
//! `Ui::recorder()` swaps the writes for a capture buffer — tests
//! assert the rendered step sequence without touching real stderr.

use std::io::{IsTerminal, Write};
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::{Duration, Instant};

const DIM: &str = "\x1b[2m";
const BOLD: &str = "\x1b[1m";
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
const CYAN: &str = "\x1b[36m";
const YELLOW: &str = "\x1b[33m";
const RESET: &str = "\x1b[0m";

const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

pub struct ProgressOutput {
    color: bool,
    stderr_tty: bool,
    /// Recorder mode (tests): lines land here instead of stderr.
    log: Option<Arc<Mutex<Vec<String>>>>,
}

impl ProgressOutput {
    pub fn new() -> ProgressOutput {
        let stderr_tty = std::io::stderr().is_terminal();
        let color = std::env::var("NO_COLOR").is_err() && stderr_tty;
        ProgressOutput {
            color,
            stderr_tty,
            log: None,
        }
    }

    /// A capturing Ui for tests: renders nothing, records the plain
    /// line each method would have written.
    pub fn recorder() -> (ProgressOutput, Arc<Mutex<Vec<String>>>) {
        let log = Arc::new(Mutex::new(Vec::new()));
        (
            ProgressOutput {
                color: false,
                stderr_tty: false,
                log: Some(Arc::clone(&log)),
            },
            log,
        )
    }

    fn paint(&self, code: &str, text: &str) -> String {
        if self.color {
            format!("{code}{text}{RESET}")
        } else {
            text.to_string()
        }
    }

    /// One output line: recorded in recorder mode, written to stderr
    /// otherwise (clearing the previous line first on a tty).
    fn out(&self, plain: &str, colored: &str) {
        if let Some(log) = &self.log {
            log.lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(plain.to_string());
            return;
        }
        let mut err = std::io::stderr().lock();
        if self.stderr_tty {
            let _ = write!(err, "\r\x1b[2K");
        }
        let _ = writeln!(err, "{}", if self.color { colored } else { plain });
        let _ = err.flush();
    }

    /// A bare heading line: `Updating rootle`.
    pub fn heading(&self, text: &str) {
        self.out(text, &self.paint(BOLD, text));
    }

    /// `⠋ Resolving rootledev/rootle-gitlab…` — the in-progress line,
    /// replaced by the done line on the next step call.
    pub fn step(&self, verb: &str, detail: &str) {
        self.out(
            &format!(" ● {verb} {detail}…"),
            &format!(
                " {} {} {detail}…",
                self.paint(CYAN, "●"),
                self.paint(&format!("{BOLD}{CYAN}"), verb),
            ),
        );
    }

    /// `✓ Resolved rootledev/rootle-gitlab` — the completed step.
    /// Replaces the in-progress line (same position, same verb, now
    /// green, past tense implied by the check).
    pub fn done(&self, verb: &str, detail: &str) {
        self.out(
            &format!(" ✓ {verb} {detail}"),
            &format!(
                " {} {} {detail}",
                self.paint(GREEN, "✓"),
                self.paint(&format!("{BOLD}{GREEN}"), verb),
            ),
        );
    }

    /// A sweep row (0019 M1): ` ✓ gitlab  v0.2.0 → v0.2.1`. The glyph
    /// colors itself — ✓ green, ✗ red, everything else dim.
    pub fn update_row(&self, glyph: &str, name: &str, detail: &str) {
        let color = match glyph {
            "✓" => GREEN,
            "✗" => RED,
            "📌" => YELLOW,
            _ => DIM,
        };
        self.out(
            &format!(" {glyph} {name}  {detail}"),
            &format!(
                " {} {}  {}",
                self.paint(color, glyph),
                self.paint(BOLD, name),
                self.paint(DIM, detail),
            ),
        );
    }

    /// The dim summary line: `Installed gitlab v0.1.0 in 2.3s`.
    pub fn summary(&self, verb: &str, subject: &str, detail: &str, elapsed: Duration) {
        let secs = elapsed.as_secs_f64();
        let timing = if secs >= 1.0 {
            format!(" in {secs:.1}s")
        } else if secs * 1000.0 >= 100.0 {
            format!(" in {:.0}ms", secs * 1000.0)
        } else {
            String::new() // instant — no timing
        };
        let mut parts: Vec<String> = vec![
            self.paint(GREEN, "✓"),
            self.paint(BOLD, verb),
            self.paint(BOLD, subject),
        ];
        let mut plain = format!(" ✓ {verb} {subject}");
        if !detail.is_empty() {
            parts.push(self.paint(DIM, detail));
            plain.push_str(&format!(" {detail}"));
        }
        if !timing.is_empty() {
            parts.push(self.paint(DIM, timing.trim_start()));
            plain.push_str(&format!(" {}", timing.trim_start()));
        }
        self.out(&plain, &parts.join(" "));
    }

    /// An info line (trust notice, next-step hint).
    pub fn note(&self, text: &str) {
        self.out(
            &format!(" ▸ {text}"),
            &format!(" {} {}", self.paint(DIM, "▸"), self.paint(DIM, text)),
        );
    }

    /// Start a braille spinner on a background thread; returns a guard
    /// whose Drop stops it and clears the line. The label is the
    /// in-progress message.
    pub fn spinner(&self, label: &str) -> SpinnerGuard {
        if let Some(log) = &self.log {
            // Recorder mode: the label is the step line, no thread.
            log.lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(format!(" ● {label}…"));
            return SpinnerGuard {
                stop: Arc::new(AtomicBool::new(true)),
                handle: None,
            };
        }
        let stop = Arc::new(AtomicBool::new(false));
        let frame = Arc::new(AtomicUsize::new(0));
        let color = self.color;
        let label_owned = label.to_string();
        let handle = if self.stderr_tty {
            let stop = Arc::clone(&stop);
            let frame = Arc::clone(&frame);
            Some(std::thread::spawn(move || {
                let mut err = std::io::stderr().lock();
                loop {
                    if stop.load(Ordering::Relaxed) {
                        break;
                    }
                    let i = frame.load(Ordering::Relaxed) % SPINNER.len();
                    let sp = SPINNER[i];
                    let _ = write!(err, "\r\x1b[2K");
                    if color {
                        let _ = write!(err, "{CYAN}{sp}{RESET} {DIM}{label_owned}…{RESET}");
                    } else {
                        let _ = write!(err, "{sp} {label_owned}…");
                    }
                    let _ = err.flush();
                    std::thread::sleep(Duration::from_millis(90));
                    frame.fetch_add(1, Ordering::Relaxed);
                }
                let _ = write!(err, "\r\x1b[2K");
                let _ = err.flush();
            }))
        } else {
            // Non-TTY: print the label once, no animation.
            eprintln!("  {label_owned}…");
            None
        };
        SpinnerGuard { stop, handle }
    }

    /// A list row: `  gitlab  v0.1.0  rootledev/rootle-gitlab  ← ACTIVE`.
    pub fn row(&self, name: &str, version: &str, source: &str, active: bool, pinned: bool) {
        let mut err = std::io::stderr().lock();
        let pin = if pinned {
            format!(" {}", self.paint(YELLOW, "📌"))
        } else {
            String::new()
        };
        let marker = if active {
            format!(" {}", self.paint(&format!("{BOLD}{GREEN}"), "← active"))
        } else {
            String::new()
        };
        let _ = writeln!(
            err,
            "  {} {} {}{}{}",
            self.paint(BOLD, name),
            self.paint(GREEN, version),
            self.paint(DIM, &format!("from {source}")),
            pin,
            marker,
        );
        let _ = err.flush();
    }

    pub fn empty_hint(&self) {
        let mut err = std::io::stderr().lock();
        let _ = writeln!(
            err,
            "  {} no providers installed — {}",
            self.paint(DIM, "·"),
            self.paint(&format!("{BOLD}{CYAN}"), "rootle provider install gitlab"),
        );
        let _ = err.flush();
    }
}

impl Default for ProgressOutput {
    fn default() -> Self {
        Self::new()
    }
}

/// Stops the spinner thread on Drop; clears the line.
pub struct SpinnerGuard {
    stop: Arc<AtomicBool>,
    handle: Option<std::thread::JoinHandle<()>>,
}

impl Drop for SpinnerGuard {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
    }
}

/// A wall-clock helper for the summary timing.
pub struct Timer {
    start: Instant,
}

impl Timer {
    pub fn start() -> Timer {
        Timer {
            start: Instant::now(),
        }
    }

    pub fn elapsed(&self) -> Duration {
        self.start.elapsed()
    }
}