aube 1.10.2

Aube — a fast Node.js package manager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Shared rendering primitives for the install progress UI.
//!
//! CI mode and TTY mode both want the same label content (cur/total
//! pkgs, downloaded / estimated bytes, transfer rate, ETA, phase
//! word) — only the bar and frame differ. The label assembly lives
//! here so a tweak to the segment order or styling lands in both
//! modes without drift.

use super::ci::Snap;
use clx::style;
use std::sync::atomic::{AtomicBool, Ordering};

/// Rough gzip compression ratio for npm tarballs. `dist.unpackedSize`
/// is what aube installs to disk, not what crosses the wire — typical
/// JS/TS code minified through gzip lands around 0.20-0.35×, with a
/// long tail. 0.30 is a middle-of-the-distribution constant that
/// keeps the estimate within ~30% on most installs without per-package
/// content-type tuning. Used solely for the `~13.8 MB` display
/// segment, never persisted to lockfiles or the store.
const TARBALL_COMPRESSION_RATIO: f64 = 0.30;

/// Build the full `<bar> <label>` line for one heartbeat tick.
/// Returns an empty string when the snapshot has nothing meaningful
/// to show — the heartbeat skips empty lines so a phase=0 snapshot
/// stays quiet instead of printing a blank.
pub(super) fn progress_line(snap: Snap, term_width: usize, bar_width: usize) -> String {
    if snap.phase == 0 {
        return String::new();
    }
    // Compute the clamped numerator once per render so the
    // `WARN_AUBE_PROGRESS_OVERFLOW` warning isn't double-fired across
    // the bar + label call sites; both helpers consume the result via
    // a parameter rather than re-loading the atomics. Resolving phase
    // doesn't display a numerator so we don't bother computing it.
    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; // reserved for future right-align/truncate logic
    format!("{bar} {label}")
}

/// The fixed-width left-aligned bar. Empty portion is dim throughout;
/// the filled portion takes its color from the current phase (cyan
/// while fetching, green for linking/done) so the bar visually tracks
/// the same phase progression that the right-side label spells out
/// in words. Resolving has no fill — phase 1 always renders an empty
/// bar — so it doesn't need its own arm here.
pub(super) fn bar_only(snap: Snap, width: usize, completed: usize) -> String {
    let (numerator, denominator) = if snap.phase == 1 {
        (0, 1)
    } else {
        let denom = snap.resolved.max(1);
        (completed, denom)
    };
    let filled = numerator
        .checked_mul(width)
        .and_then(|v| v.checked_div(denominator))
        .unwrap_or(0)
        .min(width);
    let empty = width - filled;
    let fill = "".repeat(filled);
    let empty = "".repeat(empty);
    let styled_fill = if snap.phase == 2 {
        style::ecyan(fill).to_string()
    } else {
        style::egreen(fill).to_string()
    };
    format!("{}{}", styled_fill, style::edim(empty))
}

/// Phase-specific label content. Format:
///
/// * resolving: `   N pkgs · resolving`
/// * fetching:  `  cur/total pkgs · 4.2 MB / ~13.8 MB · 1.4 MB/s · ETA 5s`
/// * linking:   ` cur/total pkgs · linking · 13.8 MB`
///
/// Numbers are right-aligned to a min-width-4 column so the right edge
/// of the count stays put across heartbeats — without it, the visible
/// digits jump left every time `snap.resolved` crosses a power of ten
/// during streaming resolve. The ETA segment is omitted entirely when
/// we don't yet have enough fetch-window data to extrapolate, instead
/// of showing a flapping `ETA …` placeholder.
fn label_for(snap: Snap, completed: usize) -> String {
    let dot = format!(" {} ", style::edim("·"));
    match snap.phase {
        1 => {
            let count = pad_count(snap.resolved, snap.resolved);
            let parts = [
                format!("{} {}", style::ecyan(count).bold(), style::edim("pkgs")),
                style::eyellow("resolving").bold().to_string(),
            ];
            parts.join(&dot)
        }
        2 => {
            let cur = pad_count(completed, snap.resolved);
            let mut parts = Vec::with_capacity(4);
            parts.push(format!(
                "{}/{} {}",
                style::ecyan(cur).bold(),
                style::ecyan(snap.resolved).bold(),
                style::edim("pkgs"),
            ));
            // Skip the bytes segment when nothing has landed and no
            // unpackedSize estimate is available — older publishes
            // and the lockfile fast path both miss the field. Pushing
            // an empty string would produce `pkgs ·  · ETA …` with a
            // doubled separator after the `parts.join` below.
            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 => {
            // Suppress the bytes segment when nothing was downloaded
            // (fully warm cache) — `0 B` would be visual noise. Order
            // is `linking · bytes` so the active phase word reads first
            // and the static byte total trails it.
            let cur = pad_count(completed, snap.resolved);
            let mut parts = vec![format!(
                "{}/{} {}",
                style::ecyan(cur).bold(),
                style::ecyan(snap.resolved).bold(),
                style::edim("pkgs"),
            )];
            parts.push(style::ecyan("linking").bold().to_string());
            if snap.bytes > 0 {
                parts.push(style::edim(format_bytes(snap.bytes)).to_string());
            }
            parts.join(&dot)
        }
        _ => String::new(),
    }
}

/// Right-align `count` to a column at least 4 wide and at least as
/// wide as `total`'s digit count. The min-4 floor keeps the column
/// stable for installs up to 9999 packages even before the total is
/// known (resolving phase passes `count == total == snap.resolved`).
fn pad_count(count: usize, total: usize) -> String {
    let width = total.to_string().len().max(4);
    format!("{count:>width$}")
}

/// `4.2 MB` running, optionally `4.2 MB / ~13.8 MB` when the
/// estimated total is known. The estimate is `unpackedSize ×
/// TARBALL_COMPRESSION_RATIO` so it lands in the same units as the
/// running download counter. Drops the estimate suffix once the
/// running total has caught up — at that point we know the actual
/// total and the estimate is just noise.
fn bytes_segment(snap: Snap) -> String {
    let estimated_download = estimated_download_bytes(snap.estimated);
    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 {
        // Fetching just started but no bytes have landed — show the
        // estimated size so the user has a sense of total scope.
        format!("~{}", style::edim(format_bytes(estimated_download)),)
    } else {
        // No bytes, no estimate. Avoid emitting a stray `0 B` segment
        // that would just be visual noise.
        String::new()
    }
}

/// Convert a sum of `unpackedSize` values to an estimated tarball
/// (download) byte count. Pure helper so the call sites that build
/// the segment — CI's heartbeat render and TTY's `refresh_bytes_segment`
/// — stay aligned on the same conversion. Without this both modes
/// would have to copy the constant; TTY previously displayed the raw
/// unpacked sum (~3.3× too high) before this was hoisted.
pub(super) fn estimated_download_bytes(unpacked: u64) -> u64 {
    if unpacked == 0 {
        return 0;
    }
    (unpacked as f64 * TARBALL_COMPRESSION_RATIO) as u64
}

/// `ETA 5s` once we have enough fetch-window data to extrapolate;
/// empty string while we don't (the caller drops the segment so the
/// label doesn't carry a flapping `ETA …` placeholder). Uses
/// *fetch-window* throughput (completions since `set_phase("fetching")`
/// divided by `fetch_elapsed_ms`) so the estimate reflects per-package
/// work-rate during fetching, not the inflated install-elapsed
/// denominator that would include lockfile parse and resolve time.
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()
}

/// Bytes-per-second over the fetching window only. Returns `None`
/// when no bytes have landed or the fetch window hasn't opened yet —
/// the rate segment is then dropped from the label.
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)
}

/// Process-wide latch: once the overflow warning has fired, every
/// subsequent render skips it. The bookkeeping condition tends to
/// recur across multiple heartbeats once tripped — without this
/// gate the CLI would log dozens of identical warnings to stderr,
/// drowning out the actual install output. One warning per CLI
/// session is enough to flag the regression for diagnosis.
static OVERFLOW_WARNED: AtomicBool = AtomicBool::new(false);

/// Defensive clamp: numerator can never exceed denominator. The two
/// known sources of overrun (the catch-up bookkeeping bug and
/// streamed-then-pruned packages) are fixed at their roots, but if a
/// new code path regresses we want the display to stay sane and the
/// `WARN_AUBE_PROGRESS_OVERFLOW` warning to fire — once.
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)
}

/// Format a byte count using the same SI units pnpm / npm show: `B`,
/// `kB`, `MB`, `GB`. Decimal (1000-based) because that's what every
/// package manager uses for on-the-wire sizes.
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")
    }
}

/// Format an elapsed duration compactly. Mirrors `ci::format_duration`
/// to avoid a cross-module call from the inline summary path; kept
/// as a single function so future tweaks land in one place.
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,
            reused: completed,
            downloaded: 0,
            bytes,
            estimated,
            fetch_elapsed_ms: 3_000,
            // Tests model an install where fetching started at zero
            // completions; the eta_segment then derives its rate from
            // `completed - 0 / fetch_elapsed_ms`.
            completed_at_fetch_start: Some(0),
        }
    }

    fn strip_ansi(s: &str) -> String {
        // Strip simple SGR sequences for assertion stability (env-dependent
        // colors_enabled would otherwise break expected-string tests).
        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));
        // Both lines should align "pkgs" at the same column (min-width-4
        // pad). Exact substring match catches a regression where
        // padding gets applied to only one of the two.
        assert!(small.contains("   5 pkgs"), "got: {small}");
        assert!(big.contains("1237 pkgs"), "got: {big}");
    }

    #[test]
    fn linking_phase_orders_linking_before_bytes() {
        let line = strip_ansi(&progress_line(
            snap(3, 142, 142, 13_800_000, 13_800_000),
            80,
            15,
        ));
        let linking_pos = line.find("linking").expect("linking present");
        let mb_pos = line.find("13.8 MB").expect("byte total present");
        assert!(
            linking_pos < mb_pos,
            "linking should precede byte total: {line}"
        );
    }

    #[test]
    fn fetching_phase_shows_bytes_and_estimate() {
        // Estimated unpacked = 46 MB → 0.30× = ~13.8 MB compressed,
        // which exceeds the 4.2 MB downloaded so far so the
        // `/ ~estimated` segment renders.
        let line = strip_ansi(&progress_line(
            snap(2, 142, 23, 4_200_000, 46_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() {
        // Estimated unpacked × 0.30 (≈ 4.1 MB) is below the running
        // 4.2 MB, so the `/ ~estimated` segment is dropped — at that
        // point the running figure is the better number anyway.
        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 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}");
    }
}