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}
26
27enum Mode {
28 Bar(ProgressBar),
29 /// Non-TTY fallback: print one line every LOG_INTERVAL ticks.
30 Plain,
31 /// --silent: no bar, no periodic lines. Errors still print (see println).
32 Silent,
33}
34
35const LOG_INTERVAL: u64 = 25;
36
37impl Progress {
38 /// Creates a progress reporter for `total` items. When stderr is a TTY,
39 /// renders an in-place bar. When it isn't, falls back to one plain-text
40 /// line every `LOG_INTERVAL` items. `silent` suppresses both.
41 pub fn new(total: u64, silent: bool) -> Self {
42 let mode = if silent {
43 Mode::Silent
44 } else if std::io::stderr().is_terminal() {
45 let bar = ProgressBar::new(total);
46 bar.set_style(
47 ProgressStyle::with_template("{bar:40} {percent}%")
48 .unwrap()
49 .progress_chars("=> "),
50 );
51 Mode::Bar(bar)
52 } else {
53 Mode::Plain
54 };
55 Progress {
56 total,
57 done: AtomicU64::new(0),
58 mode,
59 }
60 }
61
62 /// Advance by one item. Safe to call concurrently from multiple threads
63 /// (e.g. from inside a `rayon` `.par_iter()` closure) via a shared
64 /// `&Progress`, no external synchronization needed.
65 pub fn tick(&self) {
66 self.tick_by(1);
67 }
68
69 /// Advance by `n` items at once (for callers that complete work in
70 /// batches rather than one item at a time, e.g. `videre embed`'s
71 /// chunked pipeline). `n` must not exceed the number of items remaining
72 /// toward `total` (mirrors the same implicit contract `tick()` already
73 /// has: callers are responsible for not calling it more times, or with
74 /// a larger cumulative `n`, than `total` allows). Safe to call
75 /// concurrently from multiple threads, same as `tick()`.
76 pub fn tick_by(&self, n: u64) {
77 let before = self.done.fetch_add(n, Ordering::Relaxed);
78 let after = before + n;
79 match &self.mode {
80 Mode::Bar(bar) => bar.set_position(after),
81 Mode::Plain => {
82 if after / LOG_INTERVAL != before / LOG_INTERVAL || after == self.total {
83 eprintln!("{}/{} images processed", after, self.total);
84 }
85 }
86 Mode::Silent => {}
87 }
88 }
89
90 /// Print a line that survives an active progress bar without corrupting
91 /// its rendering. Always prints, regardless of `silent`, matches the
92 /// existing unconditional behavior of per-image error messages
93 /// (`detect failed ...`, `embed_batch failed ...`, `write failed ...`),
94 /// which must stay visible even under --silent since they indicate data
95 /// loss, not routine progress.
96 pub fn println(&self, msg: &str) {
97 match &self.mode {
98 Mode::Bar(bar) => bar.println(msg),
99 Mode::Plain | Mode::Silent => eprintln!("{msg}"),
100 }
101 }
102
103 /// Clears the bar (if any) so the final summary prints cleanly below it
104 /// rather than being overwritten. Does not print anything itself, the
105 /// caller assembles and prints its own summary line(s).
106 pub fn finish(self) {
107 if let Mode::Bar(bar) = self.mode {
108 bar.finish_and_clear();
109 }
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn silent_mode_tick_does_not_panic() {
119 let p = Progress::new(10, true);
120 for _ in 0..10 {
121 p.tick();
122 }
123 p.finish();
124 }
125
126 #[test]
127 fn silent_mode_println_still_prints() {
128 // println() must not panic in silent mode; it always writes to
129 // stderr regardless of `silent` (verified by not panicking here;
130 // capturing stderr output itself is not practical in a unit test).
131 let p = Progress::new(5, true);
132 p.println("an error message");
133 }
134
135 #[test]
136 fn zero_total_does_not_panic() {
137 let p = Progress::new(0, true);
138 p.tick();
139 p.finish();
140 }
141
142 #[test]
143 fn silent_mode_tick_by_does_not_panic() {
144 let p = Progress::new(100, true);
145 p.tick_by(40);
146 p.tick_by(60);
147 p.finish();
148 }
149
150 #[test]
151 fn concurrent_tick_from_multiple_threads_reaches_correct_total() {
152 use std::sync::Arc;
153 let progress = Arc::new(Progress::new(1000, true));
154 let handles: Vec<_> = (0..10)
155 .map(|_| {
156 let p = Arc::clone(&progress);
157 std::thread::spawn(move || {
158 for _ in 0..100 {
159 p.tick();
160 }
161 })
162 })
163 .collect();
164 for h in handles {
165 h.join().unwrap();
166 }
167 assert_eq!(progress.done.load(Ordering::Relaxed), 1000);
168 }
169}