1use std::time::Instant;
2
3use log::Level;
4
5const COUNT_WIDTH: usize = 13;
7
8pub(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#[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
32pub struct ProgressLogger {
39 name: &'static str,
40 unit: &'static str,
41 every: u64,
42 count: u64,
43 next_milestone: u64,
45 last_milestone: Instant,
46 start: Instant,
47}
48
49impl ProgressLogger {
50 #[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 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 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 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 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}