import * as Duration from "effect/Duration"; /** `1536` -> `"1.5 KB"`. Decimal units, because that is what registries report. */ export const bytes = (value: number): string => { if (!Number.isFinite(value) || value < 0) return "?"; if (value < 1000) return `${value} B`; const units = ["KB", "MB", "GB", "TB"]; let scaled = value / 1000; let unit = 0; while (scaled >= 1000 && unit < units.length - 1) { scaled /= 1000; unit += 1; } return `${scaled < 10 ? scaled.toFixed(1) : Math.round(scaled)} ${units[unit]}`; }; const SECOND = 1000; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; /** * `93000` -> `"1m 33s"`. * * `Duration.format` does the decomposition — there is no reason to * reimplement "how many minutes are in 93 seconds" — but it renders *every* * non-zero part it finds, so 3,700,000 ms comes out as `1h 1m 40s`. Two units * is as much as a progress line can spend on a number nobody is reading * precisely, and the seconds place on an hour-long run is noise that changes * every frame. So the value is quantised first and formatted after. * * Sub-second values keep their own branch: `Duration.format` renders zero as * `"0"`, and `Done in 0` reads like a bug. */ export const duration = (ms: number): string => { if (!Number.isFinite(ms) || ms < 0) return "?"; if (ms < SECOND) return `${Math.round(ms)}ms`; return Duration.format(Duration.millis(quantize(ms))); }; /** Rounds to whole seconds, then drops everything below the second-largest unit. */ const quantize = (ms: number): number => { const rounded = Math.round(ms / SECOND) * SECOND; const unit = rounded >= DAY ? HOUR : rounded >= HOUR ? MINUTE : SECOND; return Math.floor(rounded / unit) * unit; }; /** * `4200` -> `"4s"`. * * Whole seconds, unlike `duration`, because this is for a counter that is * redrawn ten times a second: rounding to the nearest millisecond would make * every frame differ, and a number that never stops changing is harder to read * than one that ticks once a second. */ export const seconds = (ms: number): string => `${Number.isFinite(ms) ? Math.max(0, Math.floor(ms / SECOND)) : 0}s`; /** `pluralize(1, "package")` -> `"1 package"`. */ export const pluralize = (count: number, singular: string, plural = `${singular}s`): string => `${count} ${count === 1 ? singular : plural}`; /** * A fixed-width progress bar. * * Uses block-drawing characters, which every terminal that reports itself as a * TTY has handled for a decade. */ export const bar = (fraction: number, width = 24): string => { const clamped = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0)); const filled = Math.round(clamped * width); return `${"█".repeat(filled)}${"░".repeat(width - filled)}`; }; /** * Truncates to `width`, keeping the end of the string. * * Package names are most distinctive at the tail (`@babel/plugin-transform-…` * tells you nothing; `…-modules-commonjs` tells you everything), so the head is * what gets dropped. */ export const truncateStart = (value: string, width: number): string => { if (width <= 1) return value.slice(-width); return value.length <= width ? value : `…${value.slice(-(width - 1))}`; }; /** Pads or truncates to exactly `width`, for stable single-line redraws. */ export const fit = (value: string, width: number): string => { if (value.length === width) return value; return value.length < width ? value.padEnd(width) : truncateStart(value, width); }; /** Estimates remaining time from a completion rate. */ export const eta = (completed: number, total: number, elapsedMs: number): string | undefined => { if (completed <= 0 || total <= completed || elapsedMs <= 0) return; const perItem = elapsedMs / completed; return duration(perItem * (total - completed)); };