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
47/// An animated spinner that prints to stderr on a background thread.
48///
49/// The spinner is suppressed in quiet mode or when stderr is not a terminal.
50/// Drop the spinner (or call [`Spinner::finish`]) to stop it and clear the line.
51///
52/// ```no_run
53/// use skillfile_core::output::Spinner;
54///
55/// let spinner = Spinner::new("Searching registries");
56/// // ... blocking work ...
57/// spinner.finish(); // or just let it drop
58/// ```
59pub struct Spinner {
60    stop: Arc<AtomicBool>,
61    handle: Option<std::thread::JoinHandle<()>>,
62}
63
64impl Spinner {
65    /// Start a spinner with the given message.
66    ///
67    /// Returns immediately. The spinner animates on a background thread until
68    /// dropped or [`Spinner::finish`] is called.
69    pub fn new(message: &str) -> Self {
70        let stop = Arc::new(AtomicBool::new(false));
71
72        if is_quiet() || !std::io::stderr().is_terminal() {
73            return Self { stop, handle: None };
74        }
75
76        let stop_clone = stop.clone();
77        let msg = message.to_string();
78
79        let handle = std::thread::spawn(move || {
80            let mut i = 0;
81            let mut stderr = std::io::stderr();
82            while !stop_clone.load(Ordering::Relaxed) {
83                let _ = write!(
84                    stderr,
85                    "\r{} {msg}",
86                    SPINNER_FRAMES[i % SPINNER_FRAMES.len()]
87                );
88                let _ = stderr.flush();
89                i += 1;
90                std::thread::sleep(std::time::Duration::from_millis(80));
91            }
92            // Clear the spinner line
93            let _ = write!(stderr, "\r{}\r", " ".repeat(msg.len() + 3));
94            let _ = stderr.flush();
95        });
96
97        Self {
98            stop,
99            handle: Some(handle),
100        }
101    }
102
103    /// Stop the spinner and clear the line. Equivalent to dropping.
104    pub fn finish(self) {
105        drop(self);
106    }
107}
108
109impl Drop for Spinner {
110    fn drop(&mut self) {
111        self.stop.store(true, Ordering::Relaxed);
112        if let Some(h) = self.handle.take() {
113            let _ = h.join();
114        }
115    }
116}