From 2d25d651ce4ca1195eb9a83365cee07ba4d7bed4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 23 Jul 2026 00:39:11 -0700 Subject: [PATCH] feat: add hm-common crate with compact duration formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the hm-common crate, seeded with a formatting module: a sealed CompactDuration extension trait on std::time::Duration returning a StopwatchDurationDisplay (allocation-free Display wrapper). Rendering follows humantime semantics — lossless, most-significant unit first, zero units omitted — in compact notation (1s500ms, 1h1m1s, 2h, 1d2h). Covered by rstest cases plus proptest round-trip/well-formedness. Wire it into hm-render's progress output, replacing the hand-rolled format_duration (which capped at minutes, so >=1h rendered as e.g. "61m1s"). Machine-facing "duration={ms}ms" log lines stay integer ms. Also adopt the human_units crate for byte-size rendering, collapsing four hand-rolled formatters (human_mb, human_bytes, and an inline mb closure) into `.format_size()`. Output shifts to correct IEC labels (e.g. "6 MiB" instead of the mislabeled 1024-base "6.0 MB"). --- Cargo.lock | 207 +++++++++++++++++- Cargo.toml | 4 + crates/hm-common/Cargo.toml | 34 +++ .../hm-common/proptest-regressions/format.txt | 8 + crates/hm-common/src/format.rs | 148 +++++++++++++ crates/hm-common/src/lib.rs | 3 + crates/hm-exec/Cargo.toml | 1 + crates/hm-exec/src/cloud/backend.rs | 20 +- crates/hm-render/Cargo.toml | 1 + crates/hm-render/src/progress.rs | 34 ++- crates/hm/Cargo.toml | 1 + crates/hm/src/commands/cache/clean.rs | 19 +- crates/hm/src/commands/run/mod.rs | 11 +- 13 files changed, 428 insertions(+), 63 deletions(-) create mode 100644 crates/hm-common/Cargo.toml create mode 100644 crates/hm-common/proptest-regressions/format.txt create mode 100644 crates/hm-common/src/format.rs create mode 100644 crates/hm-common/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 217d31c1..ef2d11b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,6 +262,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" @@ -943,6 +958,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -1019,6 +1040,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "globset" version = "0.4.18" @@ -1105,6 +1132,7 @@ dependencies = [ "hm-render", "hm-util", "hm-vm", + "human-units", "indicatif", "is_ci", "once_cell", @@ -1221,6 +1249,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hm-common" +version = "0.0.0-dev" +dependencies = [ + "anyhow", + "async-trait", + "derive_more", + "include_dir", + "proptest", + "rstest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "which 7.0.3", +] + [[package]] name = "hm-config" version = "0.0.0-dev" @@ -1267,6 +1313,7 @@ dependencies = [ "hm-plugin-protocol", "hm-util", "hm-vm", + "human-units", "ignore", "serde", "serde_json", @@ -1333,6 +1380,7 @@ dependencies = [ "anyhow", "chrono", "futures", + "hm-common", "hm-plugin-protocol", "indicatif", "owo-colors", @@ -1425,6 +1473,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "human-units" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de83553769446dd944c447e8379c92872bd8d507a84053eeb0982529e809d59e" +dependencies = [ + "pastey", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2122,6 +2179,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pear" version = "0.2.9" @@ -2252,6 +2315,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2274,6 +2346,31 @@ dependencies = [ "yansi", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.9" @@ -2410,6 +2507,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2488,6 +2594,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.13.3" @@ -2545,6 +2657,36 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -2681,6 +2823,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3279,8 +3433,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -3292,6 +3446,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3301,9 +3464,30 @@ dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -3458,6 +3642,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "uncased" version = "0.9.10" @@ -4048,6 +4238,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index b3397710..662edade 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/hm", + "crates/hm-common", "crates/hm-config", "crates/hm-plugin-protocol", "crates/hm-pipeline-ir", @@ -14,6 +15,7 @@ members = [ ] default-members = [ "crates/hm", + "crates/hm-common", "crates/hm-config", "crates/hm-plugin-protocol", "crates/hm-pipeline-ir", @@ -40,6 +42,7 @@ hm-config = { path = "crates/hm-config", version = "0.0.0-dev" hm-dsl-engine = { path = "crates/hm-dsl-engine", version = "0.0.0-dev" } hm-render = { path = "crates/hm-render", version = "0.0.0-dev" } hm-vm = { path = "crates/hm-vm", version = "0.0.0-dev" } +hm-common = { path = "crates/hm-common", version = "0.0.0-dev" } harmont-cloud = "0.2" harmont-cloud-raw = "0.2" anyhow = "1" @@ -54,6 +57,7 @@ semver = { version = "1", features = ["serde"] } uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } thiserror = "2" +human-units = "0.5.4" derive_more = { version = "1", default-features = false, features = ["full"] } smart-default = "0.7" tokio = { version = "1", features = ["full"] } diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml new file mode 100644 index 00000000..42cdde69 --- /dev/null +++ b/crates/hm-common/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "hm-common" +version = "0.0.0-dev" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Harmont common utilities" +keywords = ["ci", "harmont"] +categories = ["command-line-utilities"] + +[lib] +path = "src/lib.rs" + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +derive_more = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +include_dir = "0.7" +tempfile = "3" +tokio = { version = "1", features = ["process", "fs"] } +tracing = "0.1" +which = "7" + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +which = "7" +rstest = "0.23" +proptest = "1" + +[lints] +workspace = true diff --git a/crates/hm-common/proptest-regressions/format.txt b/crates/hm-common/proptest-regressions/format.txt new file mode 100644 index 00000000..309acc60 --- /dev/null +++ b/crates/hm-common/proptest-regressions/format.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc ea62521148a9051340c0ec681ceeb7c1b5ca8e832dcb94be80ce19851ed03d35 # shrinks to ms = 1 +cc 177303707a04af1373782980ccf8f62b9aa385d6b8899dcda28ef0ee531177c4 # shrinks to ms = 0 diff --git a/crates/hm-common/src/format.rs b/crates/hm-common/src/format.rs new file mode 100644 index 00000000..1b6a95b3 --- /dev/null +++ b/crates/hm-common/src/format.rs @@ -0,0 +1,148 @@ +//! Formatting utilities. + +use core::fmt::{self, Display}; +use core::time::Duration; + +mod sealed { + pub trait Sealed {} + impl Sealed for core::time::Duration {} +} + +/// Extension trait adding a compact, stopwatch-style [`Display`] rendering to +/// [`std::time::Duration`]. +/// +/// ``` +/// # use hm_common::format::CompactDuration; +/// # use std::time::Duration; +/// assert_eq!(Duration::from_millis(3_661_000).compact().to_string(), "1h1m1s"); +/// ``` +pub trait CompactDuration: sealed::Sealed { + /// Wrap `self` in a [`StopwatchDurationDisplay`] for compact rendering. + fn compact(self) -> StopwatchDurationDisplay; +} + +impl CompactDuration for Duration { + fn compact(self) -> StopwatchDurationDisplay { + StopwatchDurationDisplay { + total_ms: u64::try_from(self.as_millis()).unwrap_or(u64::MAX), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StopwatchDurationDisplay { + total_ms: u64, +} + +impl Display for StopwatchDurationDisplay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + const UNITS: [(&str, u64); 5] = [ + ("d", 1000 * 60 * 60 * 24), + ("h", 1000 * 60 * 60), + ("m", 1000 * 60), + ("s", 1000), + ("ms", 1), + ]; + + if self.total_ms == 0 { + return f.write_str("0s"); + } + let mut rem = self.total_ms; + for (suffix, scale) in UNITS { + let n = rem / scale; + rem %= scale; + if n > 0 { + write!(f, "{n}{suffix}")?; + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::panic, reason = "test helpers assert on well-formed input")] +mod tests { + use super::*; + use proptest::prelude::*; + use rstest::rstest; + + fn render(ms: u64) -> String { + Duration::from_millis(ms).compact().to_string() + } + + #[rstest] + #[case(0, "0s")] + #[case(5, "5ms")] + #[case(500, "500ms")] + #[case(999, "999ms")] + #[case(1_000, "1s")] + #[case(1_050, "1s50ms")] + #[case(1_500, "1s500ms")] + #[case(60_000, "1m")] + #[case(61_000, "1m1s")] + #[case(90_000, "1m30s")] + #[case(123_456, "2m3s456ms")] + #[case(3_600_000, "1h")] + #[case(3_661_000, "1h1m1s")] + #[case(7_200_000, "2h")] + #[case(86_400_000, "1d")] + #[case(93_600_000, "1d2h")] + fn renders_expected(#[case] ms: u64, #[case] expected: &str) { + assert_eq!(render(ms), expected); + } + + /// Sum the components back out of a rendered string. Mirrors nothing in the + /// implementation, so it is an independent check of losslessness. + fn parse_back(s: &str) -> u64 { + let b = s.as_bytes(); + let mut i = 0; + let mut total = 0u64; + while i < b.len() { + let mut n = 0u64; + while i < b.len() && b[i].is_ascii_digit() { + n = n * 10 + u64::from(b[i] - b'0'); + i += 1; + } + // `ms` before the bare `m`/`s` cases so it is matched first. + let scale = if b[i] == b'm' && i + 1 < b.len() && b[i + 1] == b's' { + i += 2; + 1 + } else { + let unit = b[i]; + i += 1; + match unit { + b's' => 1_000, + b'm' => 1_000 * 60, + b'h' => 1_000 * 60 * 60, + b'd' => 1_000 * 60 * 60 * 24, + other => panic!("unexpected unit byte {other}"), + } + }; + total += n * scale; + } + total + } + + proptest! { + /// Never panics, never empty, uses only the expected glyphs, and ends + /// on a unit letter — for the entire `u64` millisecond range. + #[test] + fn well_formed_over_full_range(ms in any::()) { + let out = render(ms); + prop_assert!(!out.is_empty()); + prop_assert!( + out.bytes().all(|c| c.is_ascii_digit() || matches!(c, b'd' | b'h' | b'm' | b's')), + "unexpected glyph in {out:?}" + ); + prop_assert!(matches!(out.bytes().last(), Some(b'd' | b'h' | b'm' | b's'))); + } + + /// humantime semantics are lossless: the rendered components sum back to + /// the exact input (bounded below 30 days, where our unit ladder — up to + /// days — matches a full decomposition with no rollover to months). + #[test] + fn round_trips_losslessly(ms in 0u64..30 * 24 * 60 * 60 * 1_000) { + prop_assert_eq!(parse_back(&render(ms)), ms); + } + } +} diff --git a/crates/hm-common/src/lib.rs b/crates/hm-common/src/lib.rs new file mode 100644 index 00000000..b3a0b877 --- /dev/null +++ b/crates/hm-common/src/lib.rs @@ -0,0 +1,3 @@ +//! Harmont common utilities shared across the `hm` workspace. + +pub mod format; diff --git a/crates/hm-exec/Cargo.toml b/crates/hm-exec/Cargo.toml index a697b34b..dfb2329d 100644 --- a/crates/hm-exec/Cargo.toml +++ b/crates/hm-exec/Cargo.toml @@ -25,6 +25,7 @@ serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } +human-units = { workspace = true } tar = "0.4" flate2 = "1" ignore = "0.4" diff --git a/crates/hm-exec/src/cloud/backend.rs b/crates/hm-exec/src/cloud/backend.rs index 0ec75ccd..23c17952 100644 --- a/crates/hm-exec/src/cloud/backend.rs +++ b/crates/hm-exec/src/cloud/backend.rs @@ -7,6 +7,7 @@ use harmont_cloud::{ builds::{NewBuild, NewRepoBuild}, }; use hm_plugin_protocol::events::{BuildEvent, BuildRef}; +use human_units::FormatSize; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -258,13 +259,6 @@ fn dashboard_build_url(app_base: &str, org: &str, slug: &str, number: i64) -> St format!("{app_base}/{org}/pipelines/{slug}/builds/{number}") } -/// Render a byte count as a human "N.N MB" (mebibytes, one decimal). -fn human_mb(bytes: u64) -> String { - #[allow(clippy::cast_precision_loss)] // display-only; precision is irrelevant - let mb = bytes as f64 / (1024.0 * 1024.0); - format!("{mb:.1} MB") -} - /// Guard the source-archive upload: announce its size, warn when large, and /// reject (fail fast) when over the cap. /// @@ -277,7 +271,7 @@ fn guard_archive_size(archive_len: usize, repo_root: &Path) -> Result<()> { // Always close the gulf of evaluation: a multi-second silent upload is a // wide gulf. Name the size up front. - tracing::info!("uploading source archive ({})", human_mb(bytes)); + tracing::info!("uploading source archive ({})", bytes.format_size()); if bytes <= ARCHIVE_WARN_BYTES { return Ok(()); @@ -297,12 +291,12 @@ fn guard_archive_size(archive_len: usize, repo_root: &Path) -> Result<()> { // Over the warn threshold but under the cap: nudge toward a .gitignore fix. let hint = offenders .iter() - .map(|(name, sz)| format!("{name} ({})", human_mb(*sz))) + .map(|(name, sz)| format!("{name} ({})", sz.format_size())) .collect::>() .join(", "); tracing::warn!( "source archive is {} (largest: {}). Add big build artifacts to .gitignore to speed up uploads.", - human_mb(bytes), + bytes.format_size(), if hint.is_empty() { "—".to_string() } else { @@ -349,10 +343,4 @@ mod tests { other => panic!("expected SourceTooLarge, got {other:?}"), } } - - #[test] - fn human_mb_formats_one_decimal() { - assert_eq!(human_mb(6 * 1024 * 1024), "6.0 MB"); - assert_eq!(human_mb(1536 * 1024), "1.5 MB"); - } } diff --git a/crates/hm-render/Cargo.toml b/crates/hm-render/Cargo.toml index 95ee6436..68cd4d64 100644 --- a/crates/hm-render/Cargo.toml +++ b/crates/hm-render/Cargo.toml @@ -8,6 +8,7 @@ description = "Build-event renderers (human / progress / JSON) shared by the hm [dependencies] hm-plugin-protocol = { workspace = true } +hm-common = { workspace = true } anyhow = "1" indicatif = "0.18" owo-colors = "4" diff --git a/crates/hm-render/src/progress.rs b/crates/hm-render/src/progress.rs index 9da9c4b7..2cea8115 100644 --- a/crates/hm-render/src/progress.rs +++ b/crates/hm-render/src/progress.rs @@ -10,7 +10,9 @@ use std::collections::HashMap; use std::fmt; use std::io::Write; +use std::time::Duration; +use hm_common::format::CompactDuration as _; use hm_plugin_protocol::BuildEvent; use indicatif::ProgressStyle; use owo_colors::{OwoColorize, Style}; @@ -67,20 +69,6 @@ fn failed_style(color: bool) -> ProgressStyle { ProgressStyle::with_template(&tpl).unwrap_or_else(|_| ProgressStyle::default_spinner()) } -fn format_duration(ms: u64) -> String { - if ms < 1000 { - format!("{ms}ms") - } else if ms < 60_000 { - let secs = ms / 1000; - let tenths = (ms % 1000) / 100; - format!("{secs}.{tenths}s") - } else { - let mins = ms / 60_000; - let secs = (ms % 60_000) / 1000; - format!("{mins}m{secs}s") - } -} - /// Progress-bar renderer. /// /// Generic over `W: Write` so tests can capture text output into a @@ -166,7 +154,7 @@ impl ProgressRenderer { Some(StepOutcome::Succeeded { duration_ms }) => ( styled("✓", Style::new().green(), self.color), styled( - &format_duration(*duration_ms), + &Duration::from_millis(*duration_ms).compact().to_string(), Style::new().dimmed(), self.color, ), @@ -177,7 +165,10 @@ impl ProgressRenderer { }) => ( styled("✗", Style::new().red(), self.color), styled( - &format!("{} exit {exit_code}", format_duration(*duration_ms)), + &format!( + "{} exit {exit_code}", + Duration::from_millis(*duration_ms).compact() + ), Style::new().red(), self.color, ), @@ -185,7 +176,10 @@ impl ProgressRenderer { Some(StepOutcome::Cancelled { duration_ms }) => ( styled("-", Style::new().dimmed(), self.color), styled( - &format!("{} cancelled", format_duration(*duration_ms)), + &format!( + "{} cancelled", + Duration::from_millis(*duration_ms).compact() + ), Style::new().dimmed(), self.color, ), @@ -310,7 +304,7 @@ where } } else if let Some(span) = self.step_spans.get(step_id) { let name = self.step_names.get(step_id).map_or("?", String::as_str); - let dur = format_duration(*duration_ms); + let dur = Duration::from_millis(*duration_ms).compact(); span.pb_set_style(&completed_style(self.color)); span.pb_set_message(&format!("{name} ({dur})")); } @@ -355,7 +349,7 @@ where if *exit_code != 0 { self.print_failure_report(); - let dur = format_duration(*duration_ms); + let dur = Duration::from_millis(*duration_ms).compact(); let msg = format!("✗ Build failed in {dur}"); let _ = writeln!( self.out, @@ -363,7 +357,7 @@ where styled(&msg, Style::new().red().bold(), self.color) ); } else { - let dur = format_duration(*duration_ms); + let dur = Duration::from_millis(*duration_ms).compact(); let msg = format!("✓ Build succeeded in {dur}"); let _ = writeln!( self.out, diff --git a/crates/hm/Cargo.toml b/crates/hm/Cargo.toml index 370de3a4..a19ea1f5 100644 --- a/crates/hm/Cargo.toml +++ b/crates/hm/Cargo.toml @@ -53,6 +53,7 @@ webbrowser = "1" rand = "0.8" uuid = { version = "1", features = ["serde"] } bytes = "1" +human-units = { workspace = true } futures = "0.3" futures-util = "0.3" which = "6" diff --git a/crates/hm/src/commands/cache/clean.rs b/crates/hm/src/commands/cache/clean.rs index aa71cb02..0b7fe184 100644 --- a/crates/hm/src/commands/cache/clean.rs +++ b/crates/hm/src/commands/cache/clean.rs @@ -1,5 +1,6 @@ use anyhow::Result; use hm_vm::VmBackend as _; +use human_units::FormatSize as _; /// # Errors /// Returns an error if workspace cache removal fails. @@ -12,7 +13,7 @@ pub async fn handle_clean() -> Result { tracing::info!( path = %ws_cache.display(), "removed workspace cache ({})", - human_bytes(size), + size.format_size(), ); true } else { @@ -114,19 +115,3 @@ fn dir_size(path: &std::path::Path) -> u64 { walk(path) } -#[allow( - clippy::cast_precision_loss, - reason = "human-readable display; sub-byte precision irrelevant" -)] -fn human_bytes(bytes: u64) -> String { - let b = bytes as f64; - if bytes < 1024 { - format!("{bytes}B") - } else if bytes < 1024 * 1024 { - format!("{:.1}KB", b / 1024.0) - } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1}MB", b / (1024.0 * 1024.0)) - } else { - format!("{:.1}GB", b / (1024.0 * 1024.0 * 1024.0)) - } -} diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index c3c4e1ae..f7fac486 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use anyhow::{Context, Result}; use hm_dsl_engine::{DslEngine, detect}; +use human_units::FormatSize as _; use crate::cli::RunArgs; use crate::context::RunContext; @@ -677,14 +678,12 @@ error[log_stream]: live logs interrupted — {m} cap_bytes, largest_paths, } => { - #[allow(clippy::cast_precision_loss)] // display-only - let mb = |b: u64| format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)); let biggest = if largest_paths.is_empty() { " (no large top-level paths identified)".to_string() } else { largest_paths .iter() - .map(|(name, sz)| format!(" {name} — {}", mb(*sz))) + .map(|(name, sz)| format!(" {name} — {}", sz.format_size())) .collect::>() .join("\n") }; @@ -693,8 +692,8 @@ error[log_stream]: live logs interrupted — {m} error[source_too_large]: worktree archive is {observed} (cap {cap}) biggest\n{biggest} fix add the offending paths to .gitignore (build output, caches, vendored deps), then re-run `hm run`", - observed = mb(*observed_bytes), - cap = mb(*cap_bytes), + observed = observed_bytes.format_size(), + cap = cap_bytes.format_size(), ) } other => format!("error[backend]: {other}"), @@ -817,7 +816,7 @@ mod tests { }); assert!(big.contains("error[source_too_large]")); // Points precisely (observed + cap), names the offender, states the fix. - assert!(big.contains("7.0 MB") && big.contains("6.0 MB")); + assert!(big.contains("7 MiB") && big.contains("6 MiB")); assert!(big.contains("node_modules") && big.contains(".gitignore")); // Doc URLs were removed (the pages 404); no error should link to them. for s in [