const TAIL_KEEP: usize = 128;
const TAIL_LIMIT: usize = 512;
#[derive(Default)]
pub(super) struct ProgressScanner {
tail: Vec<u8>,
last: Option<(u64, u64)>,
}
impl ProgressScanner {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn push(&mut self, chunk: &[u8]) -> Option<(u64, u64)> {
self.tail.extend_from_slice(chunk);
let progress = last_count(&self.tail);
if self.tail.len() > TAIL_LIMIT {
let drain_to = self.tail.len() - TAIL_KEEP;
self.tail.drain(..drain_to);
}
if progress.is_some() && progress != self.last {
self.last = progress;
return progress;
}
None
}
}
fn last_count(bytes: &[u8]) -> Option<(u64, u64)> {
let mut last = None;
let mut i = 0;
while i < bytes.len() {
if !bytes[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
let mid = i;
if i >= bytes.len() || bytes[i] != b'/' {
continue;
}
i += 1;
if i >= bytes.len() || !bytes[i].is_ascii_digit() {
continue;
}
let t_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
let cur: Option<u64> = std::str::from_utf8(&bytes[start..mid])
.ok()
.and_then(|s| s.parse().ok());
let total: Option<u64> = std::str::from_utf8(&bytes[t_start..i])
.ok()
.and_then(|s| s.parse().ok());
if let (Some(c), Some(t)) = (cur, total)
&& c <= t
&& t > 0
{
last = Some((c, t));
}
}
last
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_the_last_count_in_a_chunk() {
let mut scanner = ProgressScanner::new();
assert_eq!(
scanner.push(b" Building [=> ] 1/34: a\r Building [==> ] 2/34: b\r"),
Some((2, 34))
);
}
#[test]
fn an_unchanged_count_is_reported_once() {
let mut scanner = ProgressScanner::new();
assert_eq!(scanner.push(b" 3/9: app\r"), Some((3, 9)));
assert_eq!(scanner.push(b" 3/9: app(build)\r"), None);
assert_eq!(scanner.push(b" 4/9: app\r"), Some((4, 9)));
}
#[test]
fn a_count_split_across_chunks_is_seen_whole() {
let mut scanner = ProgressScanner::new();
assert_eq!(scanner.push(b" Building [===> ] 12"), None);
assert_eq!(scanner.push(b"/34: app\r"), Some((12, 34)));
}
#[test]
fn pairs_that_are_not_counts_are_ignored() {
let mut scanner = ProgressScanner::new();
assert_eq!(scanner.push(b" checked out 2026/07 "), None);
assert_eq!(scanner.push(b" 5/3 "), None);
assert_eq!(scanner.push(b" 0/0 "), None);
}
}