Skip to main content

skillfile_core/
output.rs

1use std::io::{IsTerminal, Write};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::Arc;
4
5static QUIET: AtomicBool = AtomicBool::new(false);
6
7/// Enable or disable quiet mode (suppresses progress output).
8pub fn set_quiet(quiet: bool) {
9    QUIET.store(quiet, Ordering::Relaxed);
10}
11
12/// Returns `true` if quiet mode is active.
13pub fn is_quiet() -> bool {
14    QUIET.load(Ordering::Relaxed)
15}
16
17/// Print a progress message to stderr (suppressed with `--quiet`).
18///
19/// Usage: `progress!("Syncing {count} entries...");`
20#[macro_export]
21macro_rules! progress {
22    ($($arg:tt)*) => {
23        if !$crate::output::is_quiet() {
24            eprintln!($($arg)*);
25        }
26    };
27}
28
29/// Print an inline progress message to stderr without a newline (suppressed with `--quiet`).
30///
31/// Usage: `progress_inline!("  resolving ...");`
32#[macro_export]
33macro_rules! progress_inline {
34    ($($arg:tt)*) => {
35        if !$crate::output::is_quiet() {
36            eprint!($($arg)*);
37        }
38    };
39}
40
41// ===========================================================================
42// Spinner
43// ===========================================================================
44
45const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
46
47fn run_spinner_loop(stop: &AtomicBool, msg: &str) {
48    let mut i = 0usize;
49    let mut stderr = std::io::stderr();
50    while !stop.load(Ordering::Relaxed) {
51        let _ = write!(
52            stderr,
53            "\r{} {msg}",
54            SPINNER_FRAMES[i % SPINNER_FRAMES.len()]
55        );
56        let _ = stderr.flush();
57        i += 1;
58        std::thread::sleep(std::time::Duration::from_millis(80));
59    }
60    // Clear the spinner line
61    let _ = write!(stderr, "\r{}\r", " ".repeat(msg.len() + 3));
62    let _ = stderr.flush();
63}
64
65/// An animated spinner that prints to stderr on a background thread.
66///
67/// The spinner is suppressed in quiet mode or when stderr is not a terminal.
68/// Drop the spinner (or call [`Spinner::finish`]) to stop it and clear the line.
69///
70/// ```no_run
71/// use skillfile_core::output::Spinner;
72///
73/// let spinner = Spinner::new("Searching registries");
74/// // ... blocking work ...
75/// spinner.finish(); // or just let it drop
76/// ```
77pub struct Spinner {
78    stop: Arc<AtomicBool>,
79    handle: Option<std::thread::JoinHandle<()>>,
80}
81
82impl Spinner {
83    /// Start a spinner with the given message.
84    ///
85    /// Returns immediately. The spinner animates on a background thread until
86    /// dropped or [`Spinner::finish`] is called.
87    pub fn new(message: &str) -> Self {
88        let stop = Arc::new(AtomicBool::new(false));
89
90        if is_quiet() || !std::io::stderr().is_terminal() {
91            return Self { stop, handle: None };
92        }
93
94        let stop_clone = stop.clone();
95        let msg = message.to_string();
96
97        let handle = std::thread::spawn(move || run_spinner_loop(&stop_clone, &msg));
98
99        Self {
100            stop,
101            handle: Some(handle),
102        }
103    }
104
105    /// Stop the spinner and clear the line. Equivalent to dropping.
106    pub fn finish(self) {
107        drop(self);
108    }
109}
110
111impl Drop for Spinner {
112    fn drop(&mut self) {
113        self.stop.store(true, Ordering::Relaxed);
114        if let Some(h) = self.handle.take() {
115            let _ = h.join();
116        }
117    }
118}