mirror: stop per-file lines resetting the progress gauge to 0%

parse_rclone_stats still accepted rclone's `Transferring:` entries
(`*  a.bin: 25% / 60 MiB, 0 B/s, -`) because they contain " / " and "%".
rclone writes them right after the byte-weighted line on every tick, and
since the last per-file entry is glued to the *next* tick's byte line,
the parsed order per second is: good byte line -> per-file 0% -> (one
second later) glued byte line. The gauge therefore displayed 0% almost
continuously and only ever looked right at the end.

Require a real stats body to start with the transferred byte amount.
Per-file lines now only feed the "current file" display (they already
did, as a fallback) and can no longer touch pct/done/total.

Verified with the real engine + rclone (bwlimit): mirror state now goes
0 -> 25 -> 50 -> 75 -> 100 with proper byte counts. The replay test now
asserts the exact parse sequence [10%, 40%] so a regression fails when a
per-file or file-count line sneaks in as 0%.

Bump to 0.3.1.
This commit is contained in:
Sebastian
2026-09-10 20:03:34 +02:00
parent 42a3f0c35a
commit 0ffe32ae08
4 changed files with 33 additions and 6 deletions
Generated
+1 -1
View File
@@ -253,7 +253,7 @@ dependencies = [
[[package]]
name = "dap-tui"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dap-tui"
version = "0.3.0"
version = "0.3.1"
edition = "2021"
description = "Device sync for portable players — TUI edition (ratatui)"
license = "MIT"
+3
View File
@@ -944,6 +944,9 @@ mod tests {
assert!(seen[1].contains("second stats line"));
}
#[cfg(unix)]
fn process_is_alive(pid: i32) -> bool {
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
+28 -4
View File
@@ -193,6 +193,14 @@ pub fn parse_rclone_stats(line: &str) -> Option<RcloneStats> {
return None;
}
// A real byte-stats body starts with the transferred amount. This rejects
// per-file `Transferring` entries such as
// `* a.bin: 25% / 60 MiB, 0 B/s, -`, which rclone emits right after the
// byte line on every tick. They used to parse as 0% and reset the gauge.
if !body.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
let mut parts = body.split(',');
let sizes = parts.next()?.trim();
let (done, total) = sizes.split_once('/')?;
@@ -348,6 +356,20 @@ mod tests {
assert!(parse_rclone_current(" * ").is_none());
}
#[test]
fn rejects_rclone_per_file_lines() {
// Emitted every tick right after the byte line; must never reset the
// gauge to 0%.
assert!(parse_rclone_stats(
" * a.bin: 25% / 60 MiB, 0 B/s, -"
)
.is_none());
assert!(parse_rclone_stats("* big.bin: 5% / 40 MiB, 2.059 MiB/s, 18s").is_none());
// The glued variant still has to yield the trailing stats block.
let glued = "* a.bin: 25% / 60 MiB, 0 B/s, -Transferred: \t 60.086 MiB / 120 MiB, 50%, 30.041 MiB/s, ETA 1s";
assert_eq!(parse_rclone_stats(glued).unwrap().pct, 50);
}
#[test]
fn survives_real_rclone_progress_blocks() {
// Captured from `rclone sync --progress --stats 1s` with a pipe: the
@@ -366,14 +388,16 @@ Transferring:
Checks: 0 / 0, -, Listed 7
Transferred: 2 / 6, 33%
";
let mut last: Option<RcloneStats> = None;
// Only the two byte lines may parse; every per-file or count line must
// be rejected, so the gauge can never snap back to 0 between ticks.
let mut pcts = Vec::new();
for line in sample.lines() {
if let Some(p) = parse_rclone_stats(line) {
last = Some(p);
pcts.push(p.pct);
}
}
let last = last.expect("byte stats must parse");
assert_eq!(last.pct, 40);
assert_eq!(pcts, vec![10, 40]);
let last = parse_rclone_stats(sample.lines().nth(7).unwrap()).unwrap();
assert_eq!(last.done, "32.082 MiB");
assert_eq!(last.total, "80 MiB");
}