use super::ci::Snap;
use clx::style;
use std::sync::atomic::{AtomicBool, Ordering};
const TARBALL_COMPRESSION_RATIO: f64 = 0.20;
pub(super) fn progress_line(snap: Snap, term_width: usize, bar_width: usize) -> String {
if snap.phase == 0 {
return String::new();
}
let completed = if snap.phase == 1 {
0
} else {
clamped_completed(snap)
};
let label = label_for(snap, completed);
if label.is_empty() {
return String::new();
}
let bar = bar_only(snap, bar_width, completed);
let _ = term_width; format!("{bar} {label}")
}
const RESOLVE_BAR_WEIGHT: f64 = 0.15;
const FETCH_BAR_WEIGHT: f64 = 0.80;
pub(super) fn unified_progress(snap: Snap, completed: usize) -> f64 {
let fetch_end = RESOLVE_BAR_WEIGHT + FETCH_BAR_WEIGHT;
match snap.phase {
1 if snap.target_total > 0 => {
let estimate = snap.target_total.max(snap.resolved).max(1) as f64;
RESOLVE_BAR_WEIGHT * (snap.resolved as f64 / estimate).min(1.0)
}
2 => {
let total = snap.resolved.max(1) as f64;
let fetch_progress = (completed as f64 / total).min(1.0);
if snap.target_total > 0 {
RESOLVE_BAR_WEIGHT + FETCH_BAR_WEIGHT * fetch_progress
} else {
fetch_end * fetch_progress
}
}
3 => fetch_end,
4 => 1.0,
_ => 0.0,
}
}
pub(super) fn bar_only(snap: Snap, width: usize, completed: usize) -> String {
let progress = unified_progress(snap, completed);
let filled = ((progress * width as f64).round() as usize).min(width);
let empty = width - filled;
let fill = "█".repeat(filled);
let empty = "░".repeat(empty);
format!("{}{}", style::ecyan(fill), style::edim(empty))
}
pub(super) fn count_segment(snap: Snap, completed: usize) -> String {
match snap.phase {
1 if snap.target_total > snap.resolved => {
let cur = pad_count(snap.resolved, snap.target_total);
format!(
"{}/{} {}",
style::ebold(cur),
style::ebold(snap.target_total),
style::edim("pkgs"),
)
}
1 => {
let count = pad_count(snap.resolved, snap.resolved);
format!("{} {}", style::ebold(count), style::edim("pkgs"))
}
2..=4 => {
let cur = pad_count(completed, snap.resolved);
format!(
"{}/{} {}",
style::ebold(cur),
style::ebold(snap.resolved),
style::edim("pkgs"),
)
}
_ => String::new(),
}
}
fn label_for(snap: Snap, completed: usize) -> String {
let dot = format!(" {} ", style::edim("·"));
match snap.phase {
1 => {
let parts = [
count_segment(snap, completed),
style::ecyan("resolving").bold().to_string(),
];
parts.join(&dot)
}
2 => {
let mut parts = Vec::with_capacity(4);
parts.push(count_segment(snap, completed));
let seg = bytes_segment(snap);
if !seg.is_empty() {
parts.push(seg);
}
if let Some(rate) = transfer_rate(snap) {
parts.push(style::edim(format!("{}/s", format_bytes(rate))).to_string());
}
let eta = eta_segment(snap, completed);
if !eta.is_empty() {
parts.push(eta);
}
parts.join(&dot)
}
3 => {
let parts = [
count_segment(snap, completed),
style::ecyan("linking").bold().to_string(),
];
parts.join(&dot)
}
4 => count_segment(snap, completed),
_ => String::new(),
}
}
fn pad_count(count: usize, total: usize) -> String {
let width = total.to_string().len().max(4);
format!("{count:>width$}")
}
fn bytes_segment(snap: Snap) -> String {
let expected_to_download = snap.resolved.saturating_sub(snap.reused);
let estimated_download = estimated_total_download(
snap.estimated,
snap.bytes,
snap.downloaded,
expected_to_download,
);
if estimated_download > snap.bytes && snap.bytes > 0 {
format!(
"{} / ~{}",
style::ebold(format_bytes(snap.bytes)),
style::edim(format_bytes(estimated_download)),
)
} else if snap.bytes > 0 {
style::ebold(format_bytes(snap.bytes)).to_string()
} else if estimated_download > 0 {
format!("~{}", style::edim(format_bytes(estimated_download)),)
} else {
String::new()
}
}
const OBSERVED_SAMPLE_FLOOR: usize = 20;
pub(super) fn estimated_total_download(
unpacked: u64,
bytes_done: u64,
downloaded_pkgs: usize,
expected_total_pkgs: usize,
) -> u64 {
let static_estimate = (unpacked as f64 * TARBALL_COMPRESSION_RATIO) as u64;
if downloaded_pkgs < OBSERVED_SAMPLE_FLOOR || expected_total_pkgs == 0 {
return static_estimate;
}
let observed_avg = bytes_done as f64 / downloaded_pkgs as f64;
let extrapolated = observed_avg * expected_total_pkgs as f64;
let frac = (downloaded_pkgs as f64 / expected_total_pkgs as f64).clamp(0.0, 1.0);
let weight = frac.sqrt();
let blended = (1.0 - weight) * static_estimate as f64 + weight * extrapolated;
blended as u64
}
fn eta_segment(snap: Snap, completed: usize) -> String {
if completed >= snap.resolved {
return String::new();
}
let Some(baseline) = snap.completed_at_fetch_start else {
return String::new();
};
let fetch_completed = completed.saturating_sub(baseline);
if fetch_completed == 0 || snap.fetch_elapsed_ms == 0 {
return String::new();
}
let remaining = snap.resolved - completed;
let eta_ms = snap.fetch_elapsed_ms.saturating_mul(remaining as u64) / fetch_completed as u64;
style::edim(format!(
"ETA {}",
format_duration(std::time::Duration::from_millis(eta_ms))
))
.to_string()
}
fn transfer_rate(snap: Snap) -> Option<u64> {
if snap.bytes == 0 || snap.fetch_elapsed_ms == 0 {
return None;
}
Some(snap.bytes.saturating_mul(1000) / snap.fetch_elapsed_ms)
}
static OVERFLOW_WARNED: AtomicBool = AtomicBool::new(false);
fn clamped_completed(snap: Snap) -> usize {
let raw = snap.reused + snap.downloaded;
if raw > snap.resolved && snap.resolved > 0 && !OVERFLOW_WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_PROGRESS_OVERFLOW,
raw_completed = raw,
resolved = snap.resolved,
"progress numerator exceeded resolved-package denominator; clamping display"
);
}
raw.min(snap.resolved)
}
pub(super) fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1_000;
const MB: u64 = 1_000_000;
const GB: u64 = 1_000_000_000;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.0} kB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}
pub(super) fn format_duration(d: std::time::Duration) -> String {
super::ci::format_duration(d)
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(phase: usize, resolved: usize, completed: usize, bytes: u64, estimated: u64) -> Snap {
Snap {
phase,
resolved,
target_total: 0,
reused: completed,
downloaded: 0,
bytes,
estimated,
fetch_elapsed_ms: 3_000,
completed_at_fetch_start: Some(0),
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
for esc_c in chars.by_ref() {
if esc_c.is_ascii_alphabetic() {
break;
}
}
continue;
}
out.push(c);
}
out
}
#[test]
fn resolving_phase_shows_count_without_eta_placeholder() {
let line = strip_ansi(&progress_line(snap(1, 89, 0, 0, 0), 80, 15));
assert!(line.contains("89 pkgs"), "got: {line}");
assert!(line.contains("resolving"), "got: {line}");
assert!(
!line.contains("ETA"),
"no ETA placeholder in resolving: {line}"
);
}
#[test]
fn resolving_phase_pads_count_for_stable_column() {
let small = strip_ansi(&progress_line(snap(1, 5, 0, 0, 0), 80, 15));
let big = strip_ansi(&progress_line(snap(1, 1237, 0, 0, 0), 80, 15));
assert!(small.contains(" 5 pkgs"), "got: {small}");
assert!(big.contains("1237 pkgs"), "got: {big}");
}
#[test]
fn done_phase_fills_bar_and_drops_phase_word() {
let mut s = snap(2, 1230, 1230, 56_000_000, 0);
s.phase = 4;
s.reused = 0;
s.downloaded = 1230;
let bar = strip_ansi(&bar_only(s, 15, 1230));
assert_eq!(
bar.matches('\u{2588}').count(),
15,
"done phase must fill the bar: {bar}"
);
let line = strip_ansi(&progress_line(s, 80, 15));
assert!(line.contains("1230/1230 pkgs"), "got: {line}");
assert!(!line.contains("linking"), "no phase word at done: {line}");
assert!(!line.contains("fetching"), "no phase word at done: {line}");
}
#[test]
fn linking_phase_omits_byte_total() {
let line = strip_ansi(&progress_line(
snap(3, 142, 142, 13_800_000, 13_800_000),
80,
15,
));
assert!(line.contains("linking"), "got: {line}");
assert!(
!line.contains("MB"),
"byte total must drop in linking: {line}"
);
}
#[test]
fn fetching_phase_shows_bytes_and_estimate() {
let line = strip_ansi(&progress_line(
snap(2, 142, 23, 4_200_000, 69_000_000),
80,
15,
));
assert!(line.contains("23/142 pkgs"), "got: {line}");
assert!(line.contains("4.2 MB"), "got: {line}");
assert!(line.contains("~13.8 MB"), "got: {line}");
}
#[test]
fn fetching_phase_drops_estimate_when_running_exceeds_it() {
let line = strip_ansi(&progress_line(
snap(2, 142, 23, 4_200_000, 13_800_000),
80,
15,
));
assert!(line.contains("4.2 MB"), "got: {line}");
assert!(!line.contains("~"), "estimate should drop: {line}");
}
#[test]
fn linking_phase_drops_rate_and_eta() {
let line = strip_ansi(&progress_line(
snap(3, 142, 142, 13_800_000, 13_800_000),
80,
15,
));
assert!(line.contains("142/142"), "got: {line}");
assert!(line.contains("linking"), "got: {line}");
assert!(!line.contains("MB/s"), "rate must drop in linking: {line}");
assert!(!line.contains("ETA"), "eta must drop in linking: {line}");
}
#[test]
fn resolving_with_target_total_shows_cur_total_and_filled_bar() {
let mut s = snap(1, 500, 0, 0, 0);
s.target_total = 1230;
let line = strip_ansi(&progress_line(s, 80, 15));
assert!(line.contains("500/1230 pkgs"), "got: {line}");
assert!(line.contains("resolving"), "got: {line}");
assert!(line.contains('\u{2588}'), "expected filled fill: {line}");
assert!(line.contains('\u{2591}'), "expected empty fill: {line}");
}
#[test]
fn resolving_without_target_total_keeps_bare_count() {
let line = strip_ansi(&progress_line(snap(1, 500, 0, 0, 0), 80, 15));
assert!(line.contains("500 pkgs"), "got: {line}");
assert!(!line.contains("/"), "no cur/total without estimate: {line}");
assert!(
!line.contains('\u{2588}'),
"no fill without estimate: {line}"
);
}
#[test]
fn resolving_target_total_undershoot_caps_at_resolve_weight() {
let mut s = snap(1, 1300, 0, 0, 0);
s.target_total = 1230;
let line = strip_ansi(&progress_line(s, 80, 15));
assert!(line.contains("1300 pkgs"), "got: {line}");
assert!(
line.contains('\u{2591}'),
"resolving bar must not extend past its slice: {line}"
);
}
#[test]
fn fetch_start_without_estimate_does_not_jump_to_resolve_offset() {
let empty_resolve = snap(1, 0, 0, 0, 0);
let resolve_bar = strip_ansi(&bar_only(empty_resolve, 15, 0));
assert_eq!(
resolve_bar.matches('\u{2588}').count(),
0,
"resolving without estimate must render empty: {resolve_bar}"
);
let fetch_start = snap(2, 142, 0, 0, 0);
let fetch_bar = strip_ansi(&bar_only(fetch_start, 15, 0));
assert_eq!(
fetch_bar.matches('\u{2588}').count(),
0,
"fetch start without estimate must not snap to RESOLVE_BAR_WEIGHT: {fetch_bar}"
);
let fetch_end = snap(2, 142, 142, 0, 0);
let end_bar = strip_ansi(&bar_only(fetch_end, 15, 142));
assert_eq!(
end_bar.matches('\u{2588}').count(),
14,
"fetch end without estimate must cap at fetch-end edge: {end_bar}"
);
}
#[test]
fn unified_bar_continues_from_resolve_into_fetch() {
let mut end_resolve = snap(1, 1230, 0, 0, 0);
end_resolve.target_total = 1230;
let resolve_bar = strip_ansi(&bar_only(end_resolve, 15, 0));
let resolve_filled = resolve_bar.matches('\u{2588}').count();
let mut start_fetch = snap(2, 1230, 0, 0, 0);
start_fetch.target_total = 1230;
let fetch_bar = strip_ansi(&bar_only(start_fetch, 15, 0));
let fetch_filled = fetch_bar.matches('\u{2588}').count();
assert!(
fetch_filled >= resolve_filled,
"fetch start ({fetch_filled}) must not regress below resolve end ({resolve_filled})"
);
let mut end_fetch = snap(2, 1230, 1230, 0, 0);
end_fetch.target_total = 1230;
let end_fetch_bar = strip_ansi(&bar_only(end_fetch, 15, 1230));
assert_eq!(
end_fetch_bar.matches('\u{2588}').count(),
14,
"got: {end_fetch_bar}"
);
}
#[test]
fn linking_phase_holds_below_full() {
let mut s = snap(3, 1230, 1230, 13_800_000, 13_800_000);
s.target_total = 1230;
let bar = strip_ansi(&bar_only(s, 15, 1230));
assert_eq!(
bar.matches('\u{2588}').count(),
14,
"linking must reserve the final cell: {bar}"
);
assert_eq!(
bar.matches('\u{2591}').count(),
1,
"linking must keep one empty cell: {bar}"
);
}
#[test]
fn estimate_falls_back_to_static_below_sample_floor() {
let estimate = estimated_total_download(100_000_000, 5_000_000, 5, 100);
assert_eq!(estimate, 20_000_000, "static fallback expected");
}
#[test]
fn estimate_converges_to_observed_late_in_install() {
let estimate = estimated_total_download(276_000_000, 56_000_000, 1230, 1237);
assert!(
(55_000_000..58_000_000).contains(&estimate),
"expected ~56 MB convergence, got {estimate}"
);
}
#[test]
fn estimate_corrects_when_static_is_way_off() {
let estimate = estimated_total_download(300_000_000, 50_000_000, 90, 100);
assert!(
(54_000_000..58_000_000).contains(&estimate),
"expected dynamic correction below static overshoot, got {estimate}"
);
}
#[test]
fn clamps_overflow_to_resolved() {
let mut s = snap(2, 5, 7, 0, 0);
s.reused = 7;
let line = strip_ansi(&progress_line(s, 80, 15));
assert!(line.contains("5/5 pkgs"), "got: {line}");
}
}