Skip to main content

cfgd_core/output/
spinner.rs

1//! `Spinner` and `ProgressBar` — live progress indicators.
2//!
3//! `Spinner::finish_ok` / `finish_warn` / `finish_fail` / `finish_skipped`
4//! return a `StatusBuilder` so the caller can chain `.detail` / `.duration`
5//! / `.target` before the Status commits on Drop.
6//!
7//! A `Spinner` dropped without an explicit finish emits a `Status(Info)` so
8//! the spinner doesn't disappear silently — abandonment leaves a record.
9use std::io::IsTerminal;
10use std::marker::PhantomData;
11use std::sync::Arc;
12use std::time::Duration;
13
14use indicatif::{ProgressBar as IndProgressBar, ProgressStyle};
15
16use super::Role;
17use super::renderer::{Renderer, Writer};
18use super::status_builder::StatusBuilder;
19
20pub(crate) fn stderr_is_terminal() -> bool {
21    std::io::stderr().is_terminal()
22}
23
24/// Live spinner. Drop without `finish_*()` emits a `Status(Info)` with the
25/// spinner message at the active depth — leaves a record so the spinner
26/// doesn't disappear silently.
27pub struct Spinner<'p> {
28    pub(crate) renderer: Arc<Renderer>,
29    pub(crate) sink: Arc<dyn Writer>,
30    pub(crate) depth: usize,
31    pub(crate) bar: IndProgressBar,
32    pub(crate) message: String,
33    pub(crate) finished: bool,
34    pub(crate) _phantom: PhantomData<&'p ()>,
35}
36
37impl<'p> Spinner<'p> {
38    pub fn set_message(&self, text: impl Into<String>) {
39        self.bar.set_message(text.into());
40    }
41
42    pub fn finish_ok(self, final_text: impl Into<String>) -> StatusBuilder<'p> {
43        self.finish_with(Role::Ok, final_text)
44    }
45    pub fn finish_warn(self, final_text: impl Into<String>) -> StatusBuilder<'p> {
46        self.finish_with(Role::Warn, final_text)
47    }
48    pub fn finish_fail(self, final_text: impl Into<String>) -> StatusBuilder<'p> {
49        self.finish_with(Role::Fail, final_text)
50    }
51    pub fn finish_skipped(self, final_text: impl Into<String>) -> StatusBuilder<'p> {
52        self.finish_with(Role::Skipped, final_text)
53    }
54
55    fn finish_with(mut self, role: Role, subject: impl Into<String>) -> StatusBuilder<'p> {
56        self.bar.finish_and_clear();
57        self.finished = true;
58        // The Arc clones below give the returned StatusBuilder an
59        // independent reference to the renderer and sink. `self` is moved
60        // into this fn and dropped at the end of the call, but the
61        // StatusBuilder must outlive it (Drop fires when the caller drops
62        // the builder).
63        StatusBuilder::new(
64            self.renderer.clone(),
65            self.sink.clone(),
66            self.depth,
67            role,
68            subject,
69        )
70    }
71}
72
73impl Drop for Spinner<'_> {
74    fn drop(&mut self) {
75        if self.finished {
76            return;
77        }
78        self.bar.finish_and_clear();
79        // Emit an Info Status so the spinner leaves a record.
80        //
81        // The `self.renderer.clone()` and `self.sink.clone()` Arc-clones
82        // inside `StatusBuilder::new` (passed as arguments below) are
83        // LOAD-BEARING. The StatusBuilder needs an independent Arc so that
84        // when `self` finishes dropping and its Arc fields are released,
85        // the builder (whose own Drop fires at the end of this function via
86        // the `drop(sb)` call) still holds a live reference to the
87        // renderer and sink.
88        let msg = std::mem::take(&mut self.message);
89        let sb = StatusBuilder::new(
90            self.renderer.clone(),
91            self.sink.clone(),
92            self.depth,
93            Role::Info,
94            msg,
95        );
96        drop(sb);
97    }
98}
99
100/// Bounded progress bar.
101pub struct ProgressBar<'p> {
102    pub(crate) bar: IndProgressBar,
103    pub(crate) _phantom: PhantomData<&'p ()>,
104}
105
106impl<'p> ProgressBar<'p> {
107    pub fn inc(&self, delta: u64) {
108        self.bar.inc(delta);
109    }
110    pub fn set_position(&self, pos: u64) {
111        self.bar.set_position(pos);
112    }
113    pub fn set_message(&self, m: impl Into<String>) {
114        self.bar.set_message(m.into());
115    }
116    pub fn finish(self) {
117        self.bar.finish_and_clear();
118    }
119}
120
121/// Return the appropriate spinner bar for the current verbosity/TTY state:
122/// a hidden bar under Quiet or non-TTY, otherwise a styled spinner attached
123/// to the MultiProgress. Used by both `Printer::spinner` and
124/// `SectionGuard::spinner` to avoid duplicating the gate.
125pub(crate) fn make_spinner_bar(
126    multi: &indicatif::MultiProgress,
127    renderer: &Renderer,
128    verbosity: super::Verbosity,
129    message: &str,
130) -> IndProgressBar {
131    if verbosity == super::Verbosity::Quiet || !stderr_is_terminal() {
132        IndProgressBar::hidden()
133    } else {
134        build_spinner(multi, renderer, message)
135    }
136}
137
138/// Same gate as `make_spinner_bar`, for bounded progress bars.
139pub(crate) fn make_progress_bar(
140    multi: &indicatif::MultiProgress,
141    total: u64,
142    verbosity: super::Verbosity,
143    message: &str,
144) -> IndProgressBar {
145    if verbosity == super::Verbosity::Quiet || !stderr_is_terminal() {
146        IndProgressBar::hidden()
147    } else {
148        build_progress_bar(multi, total, message)
149    }
150}
151
152/// Build a styled spinner ProgressBar attached to a MultiProgress.
153pub(crate) fn build_spinner(
154    multi: &indicatif::MultiProgress,
155    renderer: &Renderer,
156    message: &str,
157) -> IndProgressBar {
158    let pb = multi.add(IndProgressBar::new_spinner());
159    let frames_raw = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
160    let styled: Vec<String> = frames_raw
161        .iter()
162        .map(|f| renderer.theme.info.apply_to(f).to_string())
163        .collect();
164    let mut tick_refs: Vec<&str> = styled.iter().map(|s| s.as_str()).collect();
165    tick_refs.push(" ");
166    pb.set_style(
167        ProgressStyle::with_template("{spinner} {msg}")
168            .unwrap_or_else(|_| ProgressStyle::default_spinner())
169            .tick_strings(&tick_refs),
170    );
171    pb.set_message(message.to_string());
172    pb.enable_steady_tick(Duration::from_millis(80));
173    pb
174}
175
176pub(crate) fn build_progress_bar(
177    multi: &indicatif::MultiProgress,
178    total: u64,
179    message: &str,
180) -> IndProgressBar {
181    let pb = multi.add(IndProgressBar::new(total));
182    pb.set_style(
183        ProgressStyle::with_template("{spinner:.cyan} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
184            .unwrap_or_else(|_| ProgressStyle::default_bar())
185            .progress_chars("━╸─"),
186    );
187    pb.set_message(message.to_string());
188    pb
189}
190
191#[cfg(test)]
192mod tests {
193    use std::sync::{Arc, Mutex};
194
195    use super::super::renderer::{Renderer, StringSink};
196    use super::super::{Theme, Verbosity};
197    use super::*;
198    use crate::output::strip_ansi;
199
200    fn renderer() -> Arc<Renderer> {
201        Arc::new(Renderer::new(Theme::default(), Verbosity::Normal))
202    }
203
204    fn sink_for(buf: &Arc<Mutex<String>>) -> Arc<dyn Writer> {
205        Arc::new(StringSink(buf.clone()))
206    }
207
208    #[test]
209    fn finish_ok_emits_status_at_section_depth() {
210        let r = renderer();
211        let buf = Arc::new(Mutex::new(String::new()));
212        let sink = sink_for(&buf);
213        // Hidden bar (no TTY in test); finish_ok still emits the Status line.
214        let sp = Spinner {
215            renderer: r.clone(),
216            sink: sink.clone(),
217            depth: 1,
218            bar: indicatif::ProgressBar::hidden(),
219            message: "doing work".into(),
220            finished: false,
221            _phantom: std::marker::PhantomData,
222        };
223        let _ = sp.finish_ok("done");
224        // _ drops here → Status committed
225        let out = strip_ansi(&buf.lock().unwrap());
226        assert!(out.contains("  ✓ done"), "got: {out:?}");
227    }
228
229    #[test]
230    fn drop_without_finish_emits_info_record() {
231        let r = renderer();
232        let buf = Arc::new(Mutex::new(String::new()));
233        let sink = sink_for(&buf);
234        {
235            let _sp = Spinner {
236                renderer: r.clone(),
237                sink: sink.clone(),
238                depth: 0,
239                bar: indicatif::ProgressBar::hidden(),
240                message: "abandoned".into(),
241                finished: false,
242                _phantom: std::marker::PhantomData,
243            };
244        }
245        let out = strip_ansi(&buf.lock().unwrap());
246        // Info role has no icon; subject text appears.
247        assert!(out.contains("abandoned"), "got: {out:?}");
248    }
249
250    #[test]
251    fn quiet_printer_returns_hidden_spinner() {
252        use super::super::printer::Printer;
253        let p = Printer::with_format(
254            super::super::Verbosity::Quiet,
255            None,
256            super::super::OutputFormat::Table,
257        );
258        let sp = p.spinner("x");
259        assert!(sp.bar.is_hidden(), "Quiet should yield a hidden bar");
260    }
261}