Skip to main content

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}