Skip to main content

k2tools_lib/
progress.rs

1use std::time::Instant;
2
3use log::Level;
4
5/// Width of the formatted count field, enough for `"1,000,000,000"` (13 chars).
6const COUNT_WIDTH: usize = 13;
7
8/// Format a count with commas as thousands separators (e.g. `1000000` → `"1,000,000"`).
9pub(crate) fn format_count(n: u64) -> String {
10    let s = n.to_string();
11    s.as_bytes()
12        .rchunks(3)
13        .rev()
14        .map(|c| std::str::from_utf8(c).unwrap())
15        .collect::<Vec<_>>()
16        .join(",")
17}
18
19/// Format a duration as `MMm SSs` with zero-padded two-digit minutes and seconds.
20#[expect(
21    clippy::cast_possible_truncation,
22    clippy::cast_sign_loss,
23    reason = "elapsed seconds are non-negative and fit in u64 for any practical duration"
24)]
25fn format_elapsed(secs: f64) -> String {
26    let total_secs = secs as u64;
27    let m = total_secs / 60;
28    let s = total_secs % 60;
29    format!("{m:02}m {s:02}s")
30}
31
32/// Logs progress every N records.
33///
34/// Designed for high-throughput loops where millions of items are processed.
35/// The counter is checked against a pre-computed milestone to avoid a division
36/// on every call; only when the milestone is reached does the logger perform
37/// any formatting or I/O.
38pub struct ProgressLogger {
39    name: &'static str,
40    unit: &'static str,
41    every: u64,
42    count: u64,
43    /// Next count at which to emit a progress message. Avoids per-call division.
44    next_milestone: u64,
45    last_milestone: Instant,
46    start: Instant,
47}
48
49impl ProgressLogger {
50    /// Creates a new progress logger.
51    ///
52    /// * `name` — log target (appears in `[target]` in log output)
53    /// * `unit` — label for items being counted (e.g. `"reads"`, `"records"`)
54    /// * `every` — emit a progress line every N items
55    #[must_use]
56    pub fn new(name: &'static str, unit: &'static str, every: u64) -> Self {
57        let now = Instant::now();
58        Self { name, unit, every, count: 0, next_milestone: every, last_milestone: now, start: now }
59    }
60
61    /// Record one item and log if the interval has been reached.
62    pub fn record(&mut self) {
63        self.count += 1;
64        if self.count >= self.next_milestone {
65            self.next_milestone += self.every;
66            self.emit();
67        }
68    }
69
70    /// Advance the counter by `n` and emit a progress message if a milestone
71    /// boundary is crossed. If `n` spans multiple milestones only one message
72    /// is emitted.
73    pub fn record_n(&mut self, n: u64) {
74        if n == 0 {
75            return;
76        }
77        self.count += n;
78        if self.count >= self.next_milestone {
79            while self.next_milestone <= self.count {
80                self.next_milestone += self.every;
81            }
82            self.emit();
83        }
84    }
85
86    /// Log final totals.
87    pub fn finish(&self) {
88        let total = format_elapsed(self.start.elapsed().as_secs_f64());
89        log::log!(
90            target: self.name, Level::Info,
91            "Processed {:>COUNT_WIDTH$} {} total in {total}.",
92            format_count(self.count), self.unit,
93        );
94    }
95
96    /// Emit a progress line with timing information.
97    fn emit(&mut self) {
98        let milestone_secs = self.last_milestone.elapsed().as_secs_f64();
99        let total_elapsed = format_elapsed(self.start.elapsed().as_secs_f64());
100        let last_took = format!("last {} took {:.1}s", format_count(self.every), milestone_secs);
101
102        log::log!(
103            target: self.name, Level::Info,
104            "Processed {:>COUNT_WIDTH$} {} - elapsed time {total_elapsed} - {last_took}.",
105            format_count(self.count), self.unit,
106        );
107        self.last_milestone = Instant::now();
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_format_count_small() {
117        assert_eq!(format_count(0), "0");
118        assert_eq!(format_count(1), "1");
119        assert_eq!(format_count(999), "999");
120    }
121
122    #[test]
123    fn test_format_count_thousands() {
124        assert_eq!(format_count(1_000), "1,000");
125        assert_eq!(format_count(10_000), "10,000");
126        assert_eq!(format_count(100_000), "100,000");
127    }
128
129    #[test]
130    fn test_format_count_millions() {
131        assert_eq!(format_count(1_000_000), "1,000,000");
132        assert_eq!(format_count(10_000_000), "10,000,000");
133        assert_eq!(format_count(1_234_567_890), "1,234,567,890");
134    }
135
136    #[test]
137    fn test_format_elapsed_seconds_only() {
138        assert_eq!(format_elapsed(5.3), "00m 05s");
139    }
140
141    #[test]
142    fn test_format_elapsed_minutes_and_seconds() {
143        assert_eq!(format_elapsed(125.7), "02m 05s");
144    }
145
146    #[test]
147    fn test_format_elapsed_exact_minute() {
148        assert_eq!(format_elapsed(60.0), "01m 00s");
149    }
150
151    #[test]
152    fn test_record_no_panic() {
153        let mut pl = ProgressLogger::new("test", "reads", 10);
154        for _ in 0..25 {
155            pl.record();
156        }
157        pl.finish();
158    }
159
160    #[test]
161    fn test_record_n_zero_is_noop() {
162        let mut pl = ProgressLogger::new("test", "reads", 10);
163        pl.record_n(0);
164        pl.record_n(0);
165        pl.record_n(0);
166        pl.finish();
167    }
168
169    #[test]
170    fn test_record_n_crosses_milestones() {
171        let mut pl = ProgressLogger::new("test", "reads", 10);
172        pl.record_n(35);
173        pl.finish();
174    }
175}