From d01daf939aea47c29db69da77b77e14913312e40 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 16:26:39 -0700 Subject: [PATCH 1/6] refactor(hm-pipeline-ir): centralize duration-ms conversion in DurationMs Introduce a `DurationMs(u64)` newtype in hm-pipeline-ir, carried on the wire as a bare JSON number via `#[serde(transparent)]`, with a single saturating `From` conversion. Replaces the ad-hoc `elapsed().as_millis() as u64` conversions on `BuildEvent::{StepEnd,BuildEnd}` and `StepResultSummary`, which diverged between lossy `as u64` (3 sites in scheduler) and saturating `try_from` (1 site in cloud watch). The conversion now has exactly one home. u64 milliseconds stays the wire representation (~584M-year ceiling): `serde_json` cannot round-trip `u128`, and a raw `Duration` serializes as an awkward `{ secs, nanos }` pair non-Rust consumers must special-case. The render side rebuilds a `Duration` via `DurationMs::as_duration()`. --- crates/hm-exec/src/cloud/watch.rs | 10 +- crates/hm-exec/src/local/events.rs | 4 +- crates/hm-exec/src/local/scheduler.rs | 12 +-- crates/hm-exec/src/outcome.rs | 3 +- crates/hm-exec/tests/backend_contract.rs | 3 +- crates/hm-pipeline-ir/src/duration.rs | 101 ++++++++++++++++++ crates/hm-pipeline-ir/src/lib.rs | 2 + crates/hm-plugin-cloud/src/verbs/job.rs | 5 +- crates/hm-plugin-protocol/src/events.rs | 5 +- crates/hm-plugin-protocol/src/ir.rs | 2 +- crates/hm-plugin-protocol/tests/round_trip.rs | 5 +- crates/hm-render/src/progress.rs | 40 +++---- 12 files changed, 150 insertions(+), 42 deletions(-) create mode 100644 crates/hm-pipeline-ir/src/duration.rs diff --git a/crates/hm-exec/src/cloud/watch.rs b/crates/hm-exec/src/cloud/watch.rs index d02bc7b5..3d871391 100644 --- a/crates/hm-exec/src/cloud/watch.rs +++ b/crates/hm-exec/src/cloud/watch.rs @@ -18,6 +18,7 @@ use harmont_cloud::{ logs::{LogEvent, StreamKind}, models::{HarmontJob, OpenJobState, build_is_terminal, job_is_terminal}, }; +use hm_pipeline_ir::DurationMs; use hm_plugin_protocol::events::{BuildEvent, PlanSummary, StdStream}; use uuid::Uuid; @@ -49,10 +50,10 @@ pub(crate) fn ts_or_now(ts_unix_ns: Option) -> DateTime { /// Duration between two optional timestamps, in milliseconds (0 if either is /// missing or the interval is negative). -fn duration_ms(start: Option>, end: Option>) -> u64 { +fn duration_ms(start: Option>, end: Option>) -> DurationMs { match (start, end) { - (Some(s), Some(e)) => (e - s).num_milliseconds().max(0).cast_unsigned(), - _ => 0, + (Some(s), Some(e)) => DurationMs((e - s).num_milliseconds().max(0).cast_unsigned()), + _ => DurationMs(0), } } @@ -260,8 +261,7 @@ pub async fn watch_build( let _ = tx .send(BuildEvent::BuildEnd { exit_code: code, - // Saturate at u64::MAX (~584 million years) rather than panic. - duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + duration_ms: DurationMs::from(started.elapsed()), }) .await; Ok(code) diff --git a/crates/hm-exec/src/local/events.rs b/crates/hm-exec/src/local/events.rs index 9c205cfd..5f98c7ac 100644 --- a/crates/hm-exec/src/local/events.rs +++ b/crates/hm-exec/src/local/events.rs @@ -56,7 +56,7 @@ mod tests { let mut rx = bus.subscribe(); bus.emit(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 1, + duration_ms: hm_pipeline_ir::DurationMs(1), }); let ev = rx.recv().await.unwrap(); matches!(ev, BuildEvent::BuildEnd { exit_code: 0, .. }); @@ -67,7 +67,7 @@ mod tests { let bus = EventBus::new(); bus.emit(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 0, + duration_ms: hm_pipeline_ir::DurationMs(0), }); // Should not panic. } diff --git a/crates/hm-exec/src/local/scheduler.rs b/crates/hm-exec/src/local/scheduler.rs index 5d49f8d9..ff6d5217 100644 --- a/crates/hm-exec/src/local/scheduler.rs +++ b/crates/hm-exec/src/local/scheduler.rs @@ -39,7 +39,7 @@ use hm_plugin_protocol::{ }; use uuid::Uuid; -use hm_pipeline_ir::{EdgeKind, PipelineGraph, Transition}; +use hm_pipeline_ir::{DurationMs, EdgeKind, PipelineGraph, Transition}; use crate::local::runner::{RunnerRegistry, StepContext}; use crate::local::source::build_archive_bytes; @@ -211,7 +211,7 @@ pub(crate) async fn run( key: node_key, status, exit_code: None, - duration_ms: 0, + duration_ms: DurationMs(0), }), failed_or_skipped: true, }; @@ -258,7 +258,7 @@ pub(crate) async fn run( key: node_key, status: StepStatus::Failed, exit_code: Some(1), - duration_ms: 0, + duration_ms: DurationMs(0), }), failed_or_skipped: true, } @@ -334,7 +334,7 @@ pub(crate) async fn run( let steps: Vec = outcomes.iter().filter_map(|o| o.summary.clone()).collect(); - let dur = started_total.elapsed().as_millis() as u64; + let dur = DurationMs::from(started_total.elapsed()); bus.emit(BuildEvent::BuildEnd { exit_code: status.exit_code(), @@ -479,7 +479,7 @@ async fn execute_step( // Per-step wall-clock budget exceeded. Emit a step-end with the // conventional timeout exit code (124), fail the chain, and // cancel siblings — same shape as a non-zero exit below. - let dur_ms = started.elapsed().as_millis() as u64; + let dur_ms = DurationMs::from(started.elapsed()); bus.emit(BuildEvent::StepEnd { step_id, exit_code: 124, @@ -515,7 +515,7 @@ async fn execute_step( _ => exec.await, }; - let dur_ms = started.elapsed().as_millis() as u64; + let dur_ms = DurationMs::from(started.elapsed()); match result { Ok(sr) => { bus.emit(BuildEvent::StepEnd { diff --git a/crates/hm-exec/src/outcome.rs b/crates/hm-exec/src/outcome.rs index 204b0bd6..ace8fdc6 100644 --- a/crates/hm-exec/src/outcome.rs +++ b/crates/hm-exec/src/outcome.rs @@ -1,6 +1,7 @@ //! The typed result of a backend run. use chrono::{DateTime, Utc}; +use hm_pipeline_ir::DurationMs; use hm_plugin_protocol::events::BuildRef; use uuid::Uuid; @@ -48,7 +49,7 @@ pub struct StepResultSummary { pub key: String, pub status: StepStatus, pub exit_code: Option, - pub duration_ms: u64, + pub duration_ms: DurationMs, } /// The terminal result of a build. diff --git a/crates/hm-exec/tests/backend_contract.rs b/crates/hm-exec/tests/backend_contract.rs index 2056fb60..1234d60b 100644 --- a/crates/hm-exec/tests/backend_contract.rs +++ b/crates/hm-exec/tests/backend_contract.rs @@ -11,6 +11,7 @@ use futures::StreamExt; use rstest::rstest; use hm_exec::*; use hm_plugin_protocol::events::{BuildEvent, BuildRef}; +use hm_plugin_protocol::ir::DurationMs; use tokio_util::sync::CancellationToken; #[derive(Debug)] @@ -37,7 +38,7 @@ impl ExecutionBackend for FakeBackend { let _ = tx .send(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 5, + duration_ms: DurationMs(5), }) .await; Ok(BuildOutcome { diff --git a/crates/hm-pipeline-ir/src/duration.rs b/crates/hm-pipeline-ir/src/duration.rs new file mode 100644 index 00000000..b63c8f24 --- /dev/null +++ b/crates/hm-pipeline-ir/src/duration.rs @@ -0,0 +1,101 @@ +//! A wire-friendly duration scalar. + +use std::fmt; +use std::time::Duration; + +use schemars::JsonSchema as DeriveJsonSchema; +use serde::{Deserialize, Serialize}; + +/// A duration in whole milliseconds, carried on the wire as a bare JSON number. +/// +/// Milliseconds (rather than [`std::time::Duration`]) is the wire representation +/// because `serde_json` cannot round-trip `u128`, and a `Duration` serializes as +/// an awkward `{ secs, nanos }` pair that non-Rust consumers must special-case. A +/// `u64` millisecond count is a plain integer any JSON consumer reads directly, +/// and its ceiling (~584 million years) is unreachable in practice. +/// +/// The newtype gives the conversion from [`Duration`] exactly one home +/// ([`From`]), so callers never hand-roll the saturating +/// `as_millis` cast — which historically diverged between lossy `as u64` and +/// saturating `try_from` call sites. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, DeriveJsonSchema, +)] +#[serde(transparent)] +pub struct DurationMs(pub u64); + +impl DurationMs { + /// The millisecond count. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + /// Rebuild a [`Duration`] for computation or display. + #[must_use] + pub const fn as_duration(self) -> Duration { + Duration::from_millis(self.0) + } +} + +impl From for DurationMs { + /// Saturating: a duration beyond `u64::MAX` ms (~584 million years) clamps to + /// `u64::MAX` rather than panicking. `Duration::as_millis` returns `u128`, so + /// the narrowing is real even though the ceiling cannot occur in practice. + fn from(d: Duration) -> Self { + Self(u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) + } +} + +impl fmt::Display for DurationMs { + /// The bare millisecond count (no unit suffix), matching the wire form. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "test setup and assertions")] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::zero(0, 0)] + #[case::one(1, 1)] + #[case::typical(1_500, 1_500)] + fn from_duration_preserves_millis(#[case] millis: u64, #[case] expected: u64) { + assert_eq!(DurationMs::from(Duration::from_millis(millis)).get(), expected); + } + + #[rstest] + fn from_duration_saturates_at_u64_max() { + // A duration whose millisecond count exceeds u64::MAX clamps rather than + // wrapping or panicking. `Duration::MAX` is ~5.85e12 seconds * 1000 ms, + // well past u64::MAX ms. + assert_eq!(DurationMs::from(Duration::MAX), DurationMs(u64::MAX)); + } + + #[rstest] + fn as_duration_round_trips() { + let d = DurationMs(1_234); + assert_eq!(d.as_duration(), Duration::from_millis(1_234)); + } + + #[rstest] + #[case::zero(DurationMs(0), "0")] + #[case::typical(DurationMs(1_500), "1500")] + fn display_is_bare_integer(#[case] d: DurationMs, #[case] expected: &str) { + assert_eq!(d.to_string(), expected); + } + + #[rstest] + fn serializes_as_bare_json_number() { + // `#[serde(transparent)]` means the wire form is a plain number, not an + // object — the whole point of the newtype for cross-language consumers. + let json = serde_json::to_string(&DurationMs(1_500)).unwrap(); + assert_eq!(json, "1500"); + let back: DurationMs = serde_json::from_str(&json).unwrap(); + assert_eq!(back, DurationMs(1_500)); + } +} diff --git a/crates/hm-pipeline-ir/src/lib.rs b/crates/hm-pipeline-ir/src/lib.rs index cd55c2a9..e8d822eb 100644 --- a/crates/hm-pipeline-ir/src/lib.rs +++ b/crates/hm-pipeline-ir/src/lib.rs @@ -7,6 +7,8 @@ #![forbid(unsafe_code)] #![allow(clippy::multiple_crate_versions, clippy::cargo_common_metadata)] +mod duration; mod graph; +pub use duration::DurationMs; pub use graph::{Cache, CommandStep, EdgeKind, PipelineGraph, Transition}; diff --git a/crates/hm-plugin-cloud/src/verbs/job.rs b/crates/hm-plugin-cloud/src/verbs/job.rs index 1966a171..a1a555de 100644 --- a/crates/hm-plugin-cloud/src/verbs/job.rs +++ b/crates/hm-plugin-cloud/src/verbs/job.rs @@ -6,6 +6,7 @@ use anyhow::Result; use chrono::Utc; use harmont_cloud::HarmontClient; use hm_plugin_protocol::events::{BuildEvent, PlanSummary}; +use hm_plugin_protocol::ir::DurationMs; use uuid::Uuid; use crate::cli::JobCommand; @@ -117,14 +118,14 @@ async fn log_cmd( .send(BuildEvent::StepEnd { step_id: job_id, exit_code: 0, - duration_ms: 0, + duration_ms: DurationMs(0), snapshot: None, }) .await; let _ = tx .send(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 0, + duration_ms: DurationMs(0), }) .await; let _ = driver.await; diff --git a/crates/hm-plugin-protocol/src/events.rs b/crates/hm-plugin-protocol/src/events.rs index e8a5f9eb..231e6916 100644 --- a/crates/hm-plugin-protocol/src/events.rs +++ b/crates/hm-plugin-protocol/src/events.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::executor::SnapshotRef; +use crate::ir::DurationMs; #[derive( Debug, @@ -73,7 +74,7 @@ pub enum BuildEvent { StepEnd { step_id: Uuid, exit_code: i32, - duration_ms: u64, + duration_ms: DurationMs, snapshot: Option, }, /// Emitted when any step in a chain returns non-zero. Carries the @@ -90,7 +91,7 @@ pub enum BuildEvent { }, BuildEnd { exit_code: i32, - duration_ms: u64, + duration_ms: DurationMs, }, } diff --git a/crates/hm-plugin-protocol/src/ir.rs b/crates/hm-plugin-protocol/src/ir.rs index 727be450..35e86a2b 100644 --- a/crates/hm-plugin-protocol/src/ir.rs +++ b/crates/hm-plugin-protocol/src/ir.rs @@ -2,4 +2,4 @@ //! live in the `hm-pipeline-ir` crate; this module keeps the //! `hm_plugin_protocol::ir::*` import path working. -pub use hm_pipeline_ir::{Cache, CommandStep}; +pub use hm_pipeline_ir::{Cache, CommandStep, DurationMs}; diff --git a/crates/hm-plugin-protocol/tests/round_trip.rs b/crates/hm-plugin-protocol/tests/round_trip.rs index 3ac25132..41d69f40 100644 --- a/crates/hm-plugin-protocol/tests/round_trip.rs +++ b/crates/hm-plugin-protocol/tests/round_trip.rs @@ -11,6 +11,7 @@ reason = "serde round-trip tests: helpers unwrap and assert on JSON" )] +use hm_plugin_protocol::ir::DurationMs; use hm_plugin_protocol::*; use rstest::rstest; use uuid::Uuid; @@ -86,7 +87,7 @@ fn executor_input_round_trip() { #[case::step_end(BuildEvent::StepEnd { step_id: Uuid::nil(), exit_code: 0, - duration_ms: 1, + duration_ms: DurationMs(1), snapshot: None, })] #[case::chain_failed(BuildEvent::ChainFailed { @@ -99,7 +100,7 @@ fn executor_input_round_trip() { })] #[case::build_end(BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 2, + duration_ms: DurationMs(2), })] fn build_event_round_trips(#[case] event: BuildEvent) { rt(&event); diff --git a/crates/hm-render/src/progress.rs b/crates/hm-render/src/progress.rs index 8c01b982..b052bbf5 100644 --- a/crates/hm-render/src/progress.rs +++ b/crates/hm-render/src/progress.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::fmt; use std::io::Write; -use std::time::Duration; +use hm_plugin_protocol::ir::DurationMs; use hm_common::format::CompactDuration as _; use hm_plugin_protocol::BuildEvent; @@ -75,9 +75,9 @@ fn failed_style(color: bool) -> ProgressStyle { /// `Vec` while production code writes to `std::io::Stderr`. #[derive(Debug)] pub(crate) enum StepOutcome { - Succeeded { duration_ms: u64 }, - Failed { duration_ms: u64, exit_code: i32 }, - Cancelled { duration_ms: u64 }, + Succeeded { duration_ms: DurationMs }, + Failed { duration_ms: DurationMs, exit_code: i32 }, + Cancelled { duration_ms: DurationMs }, Cached, } @@ -154,7 +154,7 @@ impl ProgressRenderer { Some(StepOutcome::Succeeded { duration_ms }) => ( styled("✓", Style::new().green(), self.color), styled( - &Duration::from_millis(*duration_ms).compact().to_string(), + &duration_ms.as_duration().compact().to_string(), Style::new().dimmed(), self.color, ), @@ -167,7 +167,7 @@ impl ProgressRenderer { styled( &format!( "{} exit {exit_code}", - Duration::from_millis(*duration_ms).compact() + duration_ms.as_duration().compact() ), Style::new().red(), self.color, @@ -178,7 +178,7 @@ impl ProgressRenderer { styled( &format!( "{} cancelled", - Duration::from_millis(*duration_ms).compact() + duration_ms.as_duration().compact() ), Style::new().dimmed(), self.color, @@ -304,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 = Duration::from_millis(*duration_ms).compact(); + let dur = duration_ms.as_duration().compact(); span.pb_set_style(&completed_style(self.color)); span.pb_set_message(&format!("{name} ({dur})")); } @@ -349,7 +349,7 @@ where if *exit_code != 0 { self.print_failure_report(); - let dur = Duration::from_millis(*duration_ms).compact(); + let dur = duration_ms.as_duration().compact(); let msg = format!("✗ Build failed in {dur}"); let _ = writeln!( self.out, @@ -357,7 +357,7 @@ where styled(&msg, Style::new().red().bold(), self.color) ); } else { - let dur = Duration::from_millis(*duration_ms).compact(); + let dur = duration_ms.as_duration().compact(); let msg = format!("✓ Build succeeded in {dur}"); let _ = writeln!( self.out, @@ -452,13 +452,13 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id, exit_code: 1, - duration_ms: 500, + duration_ms: DurationMs(500), snapshot: None, }); r.on_event(&BuildEvent::BuildEnd { exit_code: 1, - duration_ms: 600, + duration_ms: DurationMs(600), }); let s = output(&r); @@ -503,13 +503,13 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id, exit_code: 0, - duration_ms: 200, + duration_ms: DurationMs(200), snapshot: None, }); r.on_event(&BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 250, + duration_ms: DurationMs(250), }); assert!( @@ -586,7 +586,7 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id, exit_code: 1, - duration_ms: 500, + duration_ms: DurationMs(500), snapshot: None, }); @@ -624,7 +624,7 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id: s1, exit_code: 0, - duration_ms: 200, + duration_ms: DurationMs(200), snapshot: None, }); r.on_event(&BuildEvent::StepQueued { @@ -637,12 +637,12 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id: s2, exit_code: 1, - duration_ms: 300, + duration_ms: DurationMs(300), snapshot: None, }); r.on_event(&BuildEvent::BuildEnd { exit_code: 1, - duration_ms: 600, + duration_ms: DurationMs(600), }); let s = output(&r); @@ -681,12 +681,12 @@ mod tests { r.on_event(&BuildEvent::StepEnd { step_id: s1, exit_code: 0, - duration_ms: 100, + duration_ms: DurationMs(100), snapshot: None, }); r.on_event(&BuildEvent::BuildEnd { exit_code: 0, - duration_ms: 150, + duration_ms: DurationMs(150), }); let s = output(&r); From a6444f2aacc67e12f2b26500a3fb8c24bafb0d15 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 16:55:12 -0700 Subject: [PATCH 2/6] deslop --- crates/hm-exec/src/cloud/watch.rs | 23 +- crates/hm-pipeline-ir/src/duration.rs | 11 - examples/rust/Cargo.lock | 352 ++++++++++++++++++++++++++ 3 files changed, 362 insertions(+), 24 deletions(-) diff --git a/crates/hm-exec/src/cloud/watch.rs b/crates/hm-exec/src/cloud/watch.rs index 3d871391..8bb87bc3 100644 --- a/crates/hm-exec/src/cloud/watch.rs +++ b/crates/hm-exec/src/cloud/watch.rs @@ -48,15 +48,6 @@ pub(crate) fn ts_or_now(ts_unix_ns: Option) -> DateTime { ts_unix_ns.map_or_else(Utc::now, DateTime::::from_timestamp_nanos) } -/// Duration between two optional timestamps, in milliseconds (0 if either is -/// missing or the interval is negative). -fn duration_ms(start: Option>, end: Option>) -> DurationMs { - match (start, end) { - (Some(s), Some(e)) => DurationMs((e - s).num_milliseconds().max(0).cast_unsigned()), - _ => DurationMs(0), - } -} - /// Parse an optional RFC 3339 timestamp string (the form the v1 API serializes /// `started_at` / `finished_at` as) into a UTC datetime, dropping unparseable /// or absent values to `None`. @@ -276,13 +267,19 @@ fn step_end(job: &HarmontJob, step_id: Uuid) -> BuildEvent { .exit_code // Saturate exit codes outside [i32::MIN, i32::MAX] rather than panic. .map_or_else(|| i32::from(!passed), |c| i32::try_from(c).unwrap_or(1)); + // Duration between the job's recorded start/finish timestamps, in + // milliseconds (0 if either is missing or the interval is negative). + let duration_ms = match ( + parse_rfc3339(job.started_at.as_deref()), + parse_rfc3339(job.finished_at.as_deref()), + ) { + (Some(s), Some(e)) => DurationMs((e - s).num_milliseconds().max(0).cast_unsigned()), + _ => DurationMs(0), + }; BuildEvent::StepEnd { step_id, exit_code, - duration_ms: duration_ms( - parse_rfc3339(job.started_at.as_deref()), - parse_rfc3339(job.finished_at.as_deref()), - ), + duration_ms, snapshot: None, } } diff --git a/crates/hm-pipeline-ir/src/duration.rs b/crates/hm-pipeline-ir/src/duration.rs index b63c8f24..b0cff5f0 100644 --- a/crates/hm-pipeline-ir/src/duration.rs +++ b/crates/hm-pipeline-ir/src/duration.rs @@ -7,17 +7,6 @@ use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; /// A duration in whole milliseconds, carried on the wire as a bare JSON number. -/// -/// Milliseconds (rather than [`std::time::Duration`]) is the wire representation -/// because `serde_json` cannot round-trip `u128`, and a `Duration` serializes as -/// an awkward `{ secs, nanos }` pair that non-Rust consumers must special-case. A -/// `u64` millisecond count is a plain integer any JSON consumer reads directly, -/// and its ceiling (~584 million years) is unreachable in practice. -/// -/// The newtype gives the conversion from [`Duration`] exactly one home -/// ([`From`]), so callers never hand-roll the saturating -/// `as_millis` cast — which historically diverged between lossy `as u64` and -/// saturating `try_from` call sites. #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, DeriveJsonSchema, )] diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock index b347024c..4cdb0af3 100644 --- a/examples/rust/Cargo.lock +++ b/examples/rust/Cargo.lock @@ -2,6 +2,358 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "harmont-example-rust" version = "0.1.0" +dependencies = [ + "rstest", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[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 2.0.119", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[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", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] From c4726eeb40ae8886dbf72f4782564d8eb95b04e9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 17:00:30 -0700 Subject: [PATCH 3/6] refactor(hm-pipeline-ir): idiomatic conversions for DurationMs - Replace the inherent `as_duration` with `impl From for Duration`, mirroring the existing `From` and reading as `Duration::from(ms)`. - Derive `Display` via `derive_more` (newtype-forwards to the inner `u64`) instead of a hand-written impl; output is unchanged. --- Cargo.lock | 1 + crates/hm-pipeline-ir/Cargo.toml | 1 + crates/hm-pipeline-ir/src/duration.rs | 34 ++++++++++++++++----------- crates/hm-render/src/progress.rs | 14 ++++++----- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bceb2f8b..0b821a7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1337,6 +1337,7 @@ name = "hm-pipeline-ir" version = "0.0.0-dev" dependencies = [ "daggy", + "derive_more", "insta", "rstest", "schemars 0.8.22", diff --git a/crates/hm-pipeline-ir/Cargo.toml b/crates/hm-pipeline-ir/Cargo.toml index f7388762..1e0d725b 100644 --- a/crates/hm-pipeline-ir/Cargo.toml +++ b/crates/hm-pipeline-ir/Cargo.toml @@ -8,6 +8,7 @@ description = "Pipeline IR — the v0 wire-format schema consumed by hm." [dependencies] daggy = { workspace = true } +derive_more = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } schemars = { workspace = true } diff --git a/crates/hm-pipeline-ir/src/duration.rs b/crates/hm-pipeline-ir/src/duration.rs index b0cff5f0..37934dff 100644 --- a/crates/hm-pipeline-ir/src/duration.rs +++ b/crates/hm-pipeline-ir/src/duration.rs @@ -1,14 +1,26 @@ //! A wire-friendly duration scalar. -use std::fmt; use std::time::Duration; use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; /// A duration in whole milliseconds, carried on the wire as a bare JSON number. +/// +/// `Display` renders the bare millisecond count (no unit suffix), matching the +/// wire form. #[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, DeriveJsonSchema, + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, + DeriveJsonSchema, + derive_more::Display, )] #[serde(transparent)] pub struct DurationMs(pub u64); @@ -19,12 +31,6 @@ impl DurationMs { pub const fn get(self) -> u64 { self.0 } - - /// Rebuild a [`Duration`] for computation or display. - #[must_use] - pub const fn as_duration(self) -> Duration { - Duration::from_millis(self.0) - } } impl From for DurationMs { @@ -36,10 +42,10 @@ impl From for DurationMs { } } -impl fmt::Display for DurationMs { - /// The bare millisecond count (no unit suffix), matching the wire form. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) +impl From for Duration { + /// Exact: a millisecond count always widens losslessly into a [`Duration`]. + fn from(d: DurationMs) -> Self { + Self::from_millis(d.0) } } @@ -66,9 +72,9 @@ mod tests { } #[rstest] - fn as_duration_round_trips() { + fn converts_to_duration_losslessly() { let d = DurationMs(1_234); - assert_eq!(d.as_duration(), Duration::from_millis(1_234)); + assert_eq!(Duration::from(d), Duration::from_millis(1_234)); } #[rstest] diff --git a/crates/hm-render/src/progress.rs b/crates/hm-render/src/progress.rs index b052bbf5..bb96be0b 100644 --- a/crates/hm-render/src/progress.rs +++ b/crates/hm-render/src/progress.rs @@ -10,6 +10,8 @@ use std::collections::HashMap; use std::fmt; use std::io::Write; +use std::time::Duration; + use hm_plugin_protocol::ir::DurationMs; use hm_common::format::CompactDuration as _; @@ -154,7 +156,7 @@ impl ProgressRenderer { Some(StepOutcome::Succeeded { duration_ms }) => ( styled("✓", Style::new().green(), self.color), styled( - &duration_ms.as_duration().compact().to_string(), + &Duration::from(*duration_ms).compact().to_string(), Style::new().dimmed(), self.color, ), @@ -167,7 +169,7 @@ impl ProgressRenderer { styled( &format!( "{} exit {exit_code}", - duration_ms.as_duration().compact() + Duration::from(*duration_ms).compact() ), Style::new().red(), self.color, @@ -178,7 +180,7 @@ impl ProgressRenderer { styled( &format!( "{} cancelled", - duration_ms.as_duration().compact() + Duration::from(*duration_ms).compact() ), Style::new().dimmed(), self.color, @@ -304,7 +306,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 = duration_ms.as_duration().compact(); + let dur = Duration::from(*duration_ms).compact(); span.pb_set_style(&completed_style(self.color)); span.pb_set_message(&format!("{name} ({dur})")); } @@ -349,7 +351,7 @@ where if *exit_code != 0 { self.print_failure_report(); - let dur = duration_ms.as_duration().compact(); + let dur = Duration::from(*duration_ms).compact(); let msg = format!("✗ Build failed in {dur}"); let _ = writeln!( self.out, @@ -357,7 +359,7 @@ where styled(&msg, Style::new().red().bold(), self.color) ); } else { - let dur = duration_ms.as_duration().compact(); + let dur = Duration::from(*duration_ms).compact(); let msg = format!("✓ Build succeeded in {dur}"); let _ = writeln!( self.out, From 6c5b85fb0d49bd71ca63440d59f69c392e800656 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 17:21:48 -0700 Subject: [PATCH 4/6] refactor(hm-common): generalize CompactDuration over Into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blanket-impl CompactDuration for any `T: Into` instead of only `Duration`, so `DurationMs` (and any future duration newtype) gets `.compact()` for free — no dependency edge into hm-pipeline-ir, since the impl never names the concrete type. Render sites drop `Duration::from(*ms).compact()` back to `ms.compact()`. --- crates/hm-common/src/format.rs | 13 ++++++++----- crates/hm-render/src/progress.rs | 13 ++++++------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/hm-common/src/format.rs b/crates/hm-common/src/format.rs index 1b6a95b3..95fee551 100644 --- a/crates/hm-common/src/format.rs +++ b/crates/hm-common/src/format.rs @@ -5,11 +5,13 @@ use core::time::Duration; mod sealed { pub trait Sealed {} - impl Sealed for core::time::Duration {} + impl> Sealed for T {} } -/// Extension trait adding a compact, stopwatch-style [`Display`] rendering to -/// [`std::time::Duration`]. +/// Adds a compact, stopwatch-style [`Display`] rendering to any duration. +/// +/// Implemented for any type convertible into a [`std::time::Duration`] — +/// including [`Duration`] itself and newtypes like `hm_pipeline_ir::DurationMs`. /// /// ``` /// # use hm_common::format::CompactDuration; @@ -21,10 +23,11 @@ pub trait CompactDuration: sealed::Sealed { fn compact(self) -> StopwatchDurationDisplay; } -impl CompactDuration for Duration { +impl> CompactDuration for T { fn compact(self) -> StopwatchDurationDisplay { + let d: Duration = self.into(); StopwatchDurationDisplay { - total_ms: u64::try_from(self.as_millis()).unwrap_or(u64::MAX), + total_ms: u64::try_from(d.as_millis()).unwrap_or(u64::MAX), } } } diff --git a/crates/hm-render/src/progress.rs b/crates/hm-render/src/progress.rs index bb96be0b..d2ae05bc 100644 --- a/crates/hm-render/src/progress.rs +++ b/crates/hm-render/src/progress.rs @@ -10,7 +10,6 @@ use std::collections::HashMap; use std::fmt; use std::io::Write; -use std::time::Duration; use hm_plugin_protocol::ir::DurationMs; @@ -156,7 +155,7 @@ impl ProgressRenderer { Some(StepOutcome::Succeeded { duration_ms }) => ( styled("✓", Style::new().green(), self.color), styled( - &Duration::from(*duration_ms).compact().to_string(), + &duration_ms.compact().to_string(), Style::new().dimmed(), self.color, ), @@ -169,7 +168,7 @@ impl ProgressRenderer { styled( &format!( "{} exit {exit_code}", - Duration::from(*duration_ms).compact() + duration_ms.compact() ), Style::new().red(), self.color, @@ -180,7 +179,7 @@ impl ProgressRenderer { styled( &format!( "{} cancelled", - Duration::from(*duration_ms).compact() + duration_ms.compact() ), Style::new().dimmed(), self.color, @@ -306,7 +305,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 = Duration::from(*duration_ms).compact(); + let dur = duration_ms.compact(); span.pb_set_style(&completed_style(self.color)); span.pb_set_message(&format!("{name} ({dur})")); } @@ -351,7 +350,7 @@ where if *exit_code != 0 { self.print_failure_report(); - let dur = Duration::from(*duration_ms).compact(); + let dur = duration_ms.compact(); let msg = format!("✗ Build failed in {dur}"); let _ = writeln!( self.out, @@ -359,7 +358,7 @@ where styled(&msg, Style::new().red().bold(), self.color) ); } else { - let dur = Duration::from(*duration_ms).compact(); + let dur = duration_ms.compact(); let msg = format!("✓ Build succeeded in {dur}"); let _ = writeln!( self.out, From 9820e21f472b26360de073ac29d77394e9f8221f Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 17:22:16 -0700 Subject: [PATCH 5/6] deslop --- crates/hm-common/src/format.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/hm-common/src/format.rs b/crates/hm-common/src/format.rs index 95fee551..5e29e608 100644 --- a/crates/hm-common/src/format.rs +++ b/crates/hm-common/src/format.rs @@ -10,8 +10,7 @@ mod sealed { /// Adds a compact, stopwatch-style [`Display`] rendering to any duration. /// -/// Implemented for any type convertible into a [`std::time::Duration`] — -/// including [`Duration`] itself and newtypes like `hm_pipeline_ir::DurationMs`. +/// Implemented for any type convertible into a [`std::time::Duration`]. /// /// ``` /// # use hm_common::format::CompactDuration; From d01071d2e93838a9de926c0b80462caec8df8b71 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 17:23:20 -0700 Subject: [PATCH 6/6] deslop --- crates/hm-pipeline-ir/src/duration.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/hm-pipeline-ir/src/duration.rs b/crates/hm-pipeline-ir/src/duration.rs index 37934dff..8af579ec 100644 --- a/crates/hm-pipeline-ir/src/duration.rs +++ b/crates/hm-pipeline-ir/src/duration.rs @@ -34,9 +34,6 @@ impl DurationMs { } impl From for DurationMs { - /// Saturating: a duration beyond `u64::MAX` ms (~584 million years) clamps to - /// `u64::MAX` rather than panicking. `Duration::as_millis` returns `u128`, so - /// the narrowing is real even though the ceiling cannot occur in practice. fn from(d: Duration) -> Self { Self(u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) }