Skip to main content

videre_core/
progress.rs

1use indicatif::{ProgressBar, ProgressStyle};
2use std::io::IsTerminal;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5/// Reports progress for a batch of N items as an in-place bar (brew/docker/
6/// npm style) when stderr is a terminal, or periodic plain-text lines when
7/// it isn't (piped to a file, CI log), so a long run never looks hung in a
8/// log file, without per-item spam either way. `silent` suppresses the bar
9/// and periodic lines entirely, but NOT error output (see `println`) or the
10/// caller's own decision about whether to print a final summary.
11///
12/// Does not track elapsed time itself: callers that need it (e.g.
13/// `faces.rs`, whose summary spans both detection and clustering, not just
14/// the `Progress`-tracked detection phase) should use their own `Instant`
15/// spanning whatever the summary needs to cover.
16///
17/// Safe to share across threads: every method takes `&self`, so a single
18/// `Progress` value can be ticked concurrently from multiple `rayon`
19/// worker threads (e.g. from inside a `.par_iter()` closure) with no
20/// external `Arc`/`Mutex` wrapping needed at the call site.
21pub struct Progress {
22    total: u64,
23    done: AtomicU64,
24    mode: Mode,
25    /// What is being counted, for the non-TTY line. "images" for most
26    /// callers, but `videre locations` counts coordinates and clusters, and
27    /// a log claiming "26744/26744 images processed" for a 70,601-file
28    /// library is a number a reader cannot reconcile with anything.
29    noun: &'static str,
30}
31
32enum Mode {
33    Bar(ProgressBar),
34    /// Non-TTY fallback: print one line every LOG_INTERVAL ticks.
35    Plain,
36    /// --silent: no bar, no periodic lines. Errors still print (see println).
37    Silent,
38}
39
40const LOG_INTERVAL: u64 = 25;
41
42impl Progress {
43    /// Creates a progress reporter for `total` items. When stderr is a TTY,
44    /// renders an in-place bar. When it isn't, falls back to one plain-text
45    /// line every `LOG_INTERVAL` items. `silent` suppresses both.
46    pub fn new(total: u64, silent: bool) -> Self {
47        let mode = if silent {
48            Mode::Silent
49        } else if std::io::stderr().is_terminal() {
50            let bar = ProgressBar::new(total);
51            bar.set_style(
52                ProgressStyle::with_template("{bar:40} {percent}%")
53                    .unwrap()
54                    .progress_chars("=> "),
55            );
56            Mode::Bar(bar)
57        } else {
58            Mode::Plain
59        };
60        Progress {
61            total,
62            done: AtomicU64::new(0),
63            mode,
64            noun: "images",
65        }
66    }
67
68    /// `new`, counting something other than images. Affects only the
69    /// non-TTY text line; the bar renders a percentage either way.
70    pub fn new_counting(total: u64, silent: bool, noun: &'static str) -> Self {
71        Progress {
72            noun,
73            ..Progress::new(total, silent)
74        }
75    }
76
77    /// Advance by one item. Safe to call concurrently from multiple threads
78    /// (e.g. from inside a `rayon` `.par_iter()` closure) via a shared
79    /// `&Progress`, no external synchronization needed.
80    pub fn tick(&self) {
81        self.tick_by(1);
82    }
83
84    /// Advance by `n` items at once (for callers that complete work in
85    /// batches rather than one item at a time, e.g. `videre embed`'s
86    /// chunked pipeline). `n` must not exceed the number of items remaining
87    /// toward `total` (mirrors the same implicit contract `tick()` already
88    /// has: callers are responsible for not calling it more times, or with
89    /// a larger cumulative `n`, than `total` allows). Safe to call
90    /// concurrently from multiple threads, same as `tick()`.
91    pub fn tick_by(&self, n: u64) {
92        let before = self.done.fetch_add(n, Ordering::Relaxed);
93        let after = before + n;
94        match &self.mode {
95            Mode::Bar(bar) => bar.set_position(after),
96            Mode::Plain => {
97                if after / LOG_INTERVAL != before / LOG_INTERVAL || after == self.total {
98                    eprintln!("{}/{} {} processed", after, self.total, self.noun);
99                }
100            }
101            Mode::Silent => {}
102        }
103    }
104
105    /// Print a line that survives an active progress bar without corrupting
106    /// its rendering. Always prints, regardless of `silent`, matches the
107    /// existing unconditional behavior of per-image error messages
108    /// (`detect failed ...`, `embed_batch failed ...`, `write failed ...`),
109    /// which must stay visible even under --silent since they indicate data
110    /// loss, not routine progress.
111    pub fn println(&self, msg: &str) {
112        match &self.mode {
113            Mode::Bar(bar) => bar.println(msg),
114            Mode::Plain | Mode::Silent => eprintln!("{msg}"),
115        }
116    }
117
118    /// Clears the bar (if any) so the final summary prints cleanly below it
119    /// rather than being overwritten. Does not print anything itself, the
120    /// caller assembles and prints its own summary line(s).
121    pub fn finish(self) {
122        if let Mode::Bar(bar) = self.mode {
123            bar.finish_and_clear();
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn silent_mode_tick_does_not_panic() {
134        let p = Progress::new(10, true);
135        for _ in 0..10 {
136            p.tick();
137        }
138        p.finish();
139    }
140
141    #[test]
142    fn silent_mode_println_still_prints() {
143        // println() must not panic in silent mode; it always writes to
144        // stderr regardless of `silent` (verified by not panicking here;
145        // capturing stderr output itself is not practical in a unit test).
146        let p = Progress::new(5, true);
147        p.println("an error message");
148    }
149
150    #[test]
151    fn zero_total_does_not_panic() {
152        let p = Progress::new(0, true);
153        p.tick();
154        p.finish();
155    }
156
157    #[test]
158    fn silent_mode_tick_by_does_not_panic() {
159        let p = Progress::new(100, true);
160        p.tick_by(40);
161        p.tick_by(60);
162        p.finish();
163    }
164
165    #[test]
166    fn a_caller_can_count_something_other_than_images() {
167        // Regression guard for a real wrong-noun bug: `videre locations`
168        // counts coordinates, and printed "26744/26744 images processed"
169        // for a library with 70,601 files and 37,767 photos with GPS.
170        let p = Progress::new_counting(10, true, "coordinates");
171        assert_eq!(p.noun, "coordinates");
172        assert_eq!(Progress::new(10, true).noun, "images");
173    }
174
175    #[test]
176    fn concurrent_tick_from_multiple_threads_reaches_correct_total() {
177        use std::sync::Arc;
178        let progress = Arc::new(Progress::new(1000, true));
179        let handles: Vec<_> = (0..10)
180            .map(|_| {
181                let p = Arc::clone(&progress);
182                std::thread::spawn(move || {
183                    for _ in 0..100 {
184                        p.tick();
185                    }
186                })
187            })
188            .collect();
189        for h in handles {
190            h.join().unwrap();
191        }
192        assert_eq!(progress.done.load(Ordering::Relaxed), 1000);
193    }
194}
195
196/// Formats a duration the way a person reads one.
197///
198/// `stats` printed raw milliseconds, so a run that took an hour and a half read
199/// as `5412000ms`, and every finished-in line elsewhere printed whole seconds,
200/// so a two-hour `faces` run said `done in 7284s`. Both are technically the
201/// number and neither is the answer to "how long did that take".
202///
203/// Lives here rather than in `stats` because four commands print elapsed time -
204/// `embed`, `classify`, `faces` and `locations` - and each had rolled its own.
205///
206/// Sub-second keeps milliseconds, since that is the resolution that matters
207/// when something is fast. Above a minute the seconds are dropped from the
208/// hours form: nobody reads `2h 14m 7s`.
209pub fn human_duration(d: std::time::Duration) -> String {
210    let ms = d.as_millis();
211    if ms < 1000 {
212        return format!("{ms}ms");
213    }
214    let secs = d.as_secs();
215    match secs {
216        0..=9 => {
217            // One decimal only while it still carries information: at 3.2s the
218            // tenths are a tenth of the runtime, at 41s they are noise.
219            let s = d.as_millis() as f64 / 1000.0;
220            format!("{s:.1}s")
221        }
222        10..=59 => format!("{secs}s"),
223        60..=3599 => {
224            let (m, s) = (secs / 60, secs % 60);
225            if s == 0 {
226                format!("{m}m")
227            } else {
228                format!("{m}m {s}s")
229            }
230        }
231        _ => {
232            let (h, m) = (secs / 3600, (secs % 3600) / 60);
233            if m == 0 {
234                format!("{h}h")
235            } else {
236                format!("{h}h {m}m")
237            }
238        }
239    }
240}
241
242/// `human_duration` for a millisecond count, which is how `pipeline_runs`
243/// stores what it recorded.
244pub fn human_duration_ms(ms: u64) -> String {
245    human_duration(std::time::Duration::from_millis(ms))
246}
247
248#[cfg(test)]
249mod duration_tests {
250    use super::{human_duration, human_duration_ms};
251    use std::time::Duration;
252
253    #[test]
254    fn reads_the_way_a_person_would_say_it() {
255        let cases = [
256            (Duration::from_millis(0), "0ms"),
257            (Duration::from_millis(840), "840ms"),
258            (Duration::from_millis(1000), "1.0s"),
259            (Duration::from_millis(3240), "3.2s"),
260            (Duration::from_secs(41), "41s"),
261            (Duration::from_secs(59), "59s"),
262            (Duration::from_secs(60), "1m"),
263            (Duration::from_secs(95), "1m 35s"),
264            (Duration::from_secs(3599), "59m 59s"),
265            (Duration::from_secs(3600), "1h"),
266            (Duration::from_secs(8040), "2h 14m"),
267        ];
268        for (d, want) in cases {
269            assert_eq!(human_duration(d), want, "for {d:?}");
270        }
271    }
272
273    #[test]
274    fn the_millisecond_form_matches() {
275        // What `stats` has: pipeline_runs stores duration_ms.
276        assert_eq!(human_duration_ms(0), "0ms");
277        assert_eq!(human_duration_ms(5_412_000), "1h 30m");
278    }
279
280    #[test]
281    fn no_unit_is_ever_shown_as_zero() {
282        // "2h 0m" and "1m 0s" are noise; the shorter form says the same thing.
283        for secs in [3600, 7200, 60, 120, 600] {
284            let s = human_duration(Duration::from_secs(secs));
285            assert!(!s.contains(" 0m") && !s.contains(" 0s"), "{secs}s gave {s}");
286        }
287    }
288}