fgumi_lib/progress.rs
1//! Progress tracking utilities
2//!
3//! This module provides a thread-safe progress tracker for logging progress at regular intervals.
4//! The tracker maintains an internal count and logs when interval boundaries are crossed.
5
6use log::info;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9/// Thread-safe progress tracker for logging progress at regular intervals.
10///
11/// Maintains an internal count and logs progress messages when the count crosses
12/// interval boundaries. Safe to use from multiple threads.
13///
14/// # Example
15/// ```
16/// use fgumi_lib::progress::ProgressTracker;
17///
18/// let tracker = ProgressTracker::new("Processed records")
19/// .with_interval(100);
20///
21/// // Add items and log at interval boundaries
22/// for _ in 0..250 {
23/// tracker.log_if_needed(1); // Logs at 100, 200
24/// }
25/// tracker.log_final(); // Logs "Processed records 250 (complete)"
26/// ```
27///
28/// # Multi-threaded Example
29/// ```
30/// use fgumi_lib::progress::ProgressTracker;
31/// use std::sync::Arc;
32///
33/// let tracker = Arc::new(ProgressTracker::new("Processed records").with_interval(1000));
34///
35/// // Multiple threads can safely add to the same tracker
36/// let tracker_clone = Arc::clone(&tracker);
37/// std::thread::spawn(move || {
38/// tracker_clone.log_if_needed(500);
39/// });
40/// ```
41pub struct ProgressTracker {
42 /// The logging interval - progress is logged when count crosses multiples of this.
43 interval: u64,
44 /// Message prefix for log output.
45 message: String,
46 /// Internal count of items processed (thread-safe).
47 count: AtomicU64,
48}
49
50impl ProgressTracker {
51 /// Create a new progress tracker with the specified message.
52 ///
53 /// The tracker starts with a count of 0 and a default interval of 10,000.
54 ///
55 /// # Arguments
56 /// * `message` - Message prefix for progress logs (e.g., "Processed records")
57 #[must_use]
58 pub fn new(message: impl Into<String>) -> Self {
59 Self { interval: 10_000, message: message.into(), count: AtomicU64::new(0) }
60 }
61
62 /// Set the logging interval.
63 ///
64 /// Progress will be logged each time the count crosses a multiple of this interval.
65 /// For example, with interval=1000, logs will occur at 1000, 2000, 3000, etc.
66 ///
67 /// # Arguments
68 /// * `interval` - The interval between progress logs
69 #[must_use]
70 pub fn with_interval(mut self, interval: u64) -> Self {
71 self.interval = interval;
72 self
73 }
74
75 /// Add to the count and log if an interval boundary was crossed.
76 ///
77 /// This method is thread-safe and can be called from multiple threads.
78 /// It atomically adds `additional` to the internal count and logs progress
79 /// for each interval boundary crossed.
80 ///
81 /// The behavior is equivalent to incrementing the counter one-by-one and
82 /// checking at each step, but implemented efficiently with a single atomic
83 /// operation.
84 ///
85 /// # Arguments
86 /// * `additional` - Number of items to add to the count
87 ///
88 /// # Returns
89 /// `true` if the final count is exactly a multiple of the interval,
90 /// `false` otherwise. This is useful for `log_final()` to know if a
91 /// final message is needed.
92 ///
93 /// # Example
94 /// ```
95 /// use fgumi_lib::progress::ProgressTracker;
96 ///
97 /// let tracker = ProgressTracker::new("Items").with_interval(100);
98 /// tracker.log_if_needed(50); // count=50, returns false, no log
99 /// tracker.log_if_needed(60); // count=110, returns false, logs "Items 100"
100 /// tracker.log_if_needed(90); // count=200, returns true, logs "Items 200"
101 /// ```
102 pub fn log_if_needed(&self, additional: u64) -> bool {
103 if additional == 0 {
104 // No change, just check if current count is on interval
105 let count = self.count.load(Ordering::Relaxed);
106 return count > 0 && count.is_multiple_of(self.interval);
107 }
108
109 let prev = self.count.fetch_add(additional, Ordering::Relaxed);
110 let new_count = prev + additional;
111
112 // Calculate how many interval boundaries we crossed
113 let prev_intervals = prev / self.interval;
114 let new_intervals = new_count / self.interval;
115
116 // Log for each interval boundary crossed
117 for i in (prev_intervals + 1)..=new_intervals {
118 let milestone = i * self.interval;
119 info!("{} {}", self.message, milestone);
120 }
121
122 // Return true if we landed exactly on an interval
123 new_count.is_multiple_of(self.interval)
124 }
125
126 /// Log final progress.
127 ///
128 /// If the current count is not exactly on an interval boundary (i.e., if
129 /// `log_if_needed(0)` returns `false`), logs a final message with "(complete)".
130 /// If the count is exactly on an interval, the last `log_if_needed` call
131 /// already logged it, so no additional message is needed.
132 ///
133 /// # Example
134 /// ```
135 /// use fgumi_lib::progress::ProgressTracker;
136 ///
137 /// let tracker = ProgressTracker::new("Items").with_interval(100);
138 /// tracker.log_if_needed(250); // Logs "Items 100", "Items 200"
139 /// tracker.log_final(); // Logs "Items 250 (complete)"
140 ///
141 /// let tracker2 = ProgressTracker::new("Items").with_interval(100);
142 /// tracker2.log_if_needed(200); // Logs "Items 100", "Items 200"
143 /// tracker2.log_final(); // No log (200 is exactly on interval)
144 /// ```
145 pub fn log_final(&self) {
146 if !self.log_if_needed(0) {
147 let count = self.count.load(Ordering::Relaxed);
148 if count > 0 {
149 info!("{} {} (complete)", self.message, count);
150 }
151 }
152 }
153
154 /// Get the current count.
155 ///
156 /// # Returns
157 /// The current count of items processed.
158 #[must_use]
159 pub fn count(&self) -> u64 {
160 self.count.load(Ordering::Relaxed)
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_progress_tracker_new() {
170 let tracker = ProgressTracker::new("Processing");
171 assert_eq!(tracker.interval, 10_000);
172 assert_eq!(tracker.message, "Processing");
173 assert_eq!(tracker.count(), 0);
174 }
175
176 #[test]
177 fn test_progress_tracker_with_interval() {
178 let tracker = ProgressTracker::new("Processing").with_interval(100);
179 assert_eq!(tracker.interval, 100);
180 }
181
182 #[test]
183 fn test_log_if_needed_returns_correctly() {
184 let tracker = ProgressTracker::new("Test").with_interval(10);
185
186 // Not on interval
187 assert!(!tracker.log_if_needed(5)); // count=5
188 assert!(!tracker.log_if_needed(3)); // count=8
189
190 // Crosses interval, lands on it
191 assert!(tracker.log_if_needed(2)); // count=10, exactly on interval
192
193 // Not on interval
194 assert!(!tracker.log_if_needed(5)); // count=15
195
196 // Crosses interval, doesn't land on it
197 assert!(!tracker.log_if_needed(10)); // count=25, crossed 20
198 }
199
200 #[test]
201 fn test_log_if_needed_zero() {
202 let tracker = ProgressTracker::new("Test").with_interval(10);
203
204 // Zero count, zero additional
205 assert!(!tracker.log_if_needed(0));
206
207 // Add to exactly on interval
208 tracker.log_if_needed(10);
209 assert!(tracker.log_if_needed(0)); // count=10, exactly on interval
210
211 // Add more, not on interval
212 tracker.log_if_needed(5);
213 assert!(!tracker.log_if_needed(0)); // count=15
214 }
215
216 #[test]
217 fn test_count() {
218 let tracker = ProgressTracker::new("Test").with_interval(100);
219
220 assert_eq!(tracker.count(), 0);
221 tracker.log_if_needed(50);
222 assert_eq!(tracker.count(), 50);
223 tracker.log_if_needed(75);
224 assert_eq!(tracker.count(), 125);
225 }
226
227 #[test]
228 fn test_crossing_multiple_intervals() {
229 let tracker = ProgressTracker::new("Test").with_interval(10);
230
231 // Cross multiple intervals at once (10, 20, 30)
232 assert!(!tracker.log_if_needed(35)); // count=35, crossed 10, 20, 30 but not on interval
233 assert_eq!(tracker.count(), 35);
234
235 // Cross to exactly on interval
236 assert!(tracker.log_if_needed(5)); // count=40
237 }
238
239 #[test]
240 fn test_thread_safety() {
241 use std::sync::Arc;
242 use std::thread;
243
244 let tracker = Arc::new(ProgressTracker::new("Test").with_interval(1000));
245 let mut handles = vec![];
246
247 // Spawn 10 threads, each adding 100 items
248 for _ in 0..10 {
249 let tracker_clone = Arc::clone(&tracker);
250 handles.push(thread::spawn(move || {
251 for _ in 0..100 {
252 tracker_clone.log_if_needed(1);
253 }
254 }));
255 }
256
257 for handle in handles {
258 handle.join().unwrap();
259 }
260
261 // Total should be 1000
262 assert_eq!(tracker.count(), 1000);
263 }
264}