haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Human-readable [`Duration`] formatting for the per-task
//! summary lines and the run-level epilogue.
//!
//! The grammar picks the two coarsest magnitudes that fit:
//!
//! | Duration         | Rendered as     |
//! | ---------------- | --------------- |
//! | `< 1s`           | `176ms`         |
//! | `< 1m`           | `7s 856ms`      |
//! | `< 1h`           | `1m 23s`        |
//! | `>= 1h`          | `1h 23m`        |
//!
//! Sub-millisecond durations round to the nearest millisecond
//! (clamping at `1ms` so a sub-millisecond run does not display
//! as `0ms`). The grammar deliberately drops fractional digits;
//! readers want order-of-magnitude information, not microsecond
//! precision.

use std::time::Duration;

/// Render `duration` in the grammar described at the module
/// level. Allocates a single [`String`].
#[must_use]
pub fn format_duration(duration: Duration) -> String {
    let total_secs = duration.as_secs();
    let sub_secs_millis = duration.subsec_millis();

    if total_secs == 0 {
        // Sub-second: render in milliseconds, clamping to at
        // least 1ms so a sub-millisecond duration is not
        // misrendered as `0ms`.
        let min_millis = u32::from(!duration.is_zero());
        let millis = sub_secs_millis.max(min_millis);
        return format!("{millis}ms");
    }

    if total_secs < 60 {
        return format!("{total_secs}s {sub_secs_millis}ms");
    }

    let total_minutes = total_secs / 60;
    let seconds_part = total_secs % 60;
    if total_minutes < 60 {
        return format!("{total_minutes}m {seconds_part}s");
    }

    let total_hours = total_minutes / 60;
    let minutes_part = total_minutes % 60;
    format!("{total_hours}h {minutes_part}m")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sub_millisecond_renders_as_one_ms() {
        assert_eq!(format_duration(Duration::from_micros(500)), "1ms");
    }

    #[test]
    fn zero_renders_as_zero_ms() {
        assert_eq!(format_duration(Duration::ZERO), "0ms");
    }

    #[test]
    fn millisecond_grain_under_one_second() {
        assert_eq!(format_duration(Duration::from_millis(176)), "176ms");
        assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
    }

    #[test]
    fn second_plus_millisecond_under_one_minute() {
        assert_eq!(format_duration(Duration::from_millis(7_856)), "7s 856ms");
        assert_eq!(format_duration(Duration::from_secs(1)), "1s 0ms");
        assert_eq!(format_duration(Duration::from_millis(59_999)), "59s 999ms");
    }

    #[test]
    fn minute_plus_second_under_one_hour() {
        assert_eq!(format_duration(Duration::from_mins(1)), "1m 0s");
        assert_eq!(format_duration(Duration::from_secs(83)), "1m 23s");
        assert_eq!(format_duration(Duration::from_secs(3_599)), "59m 59s");
    }

    #[test]
    fn hour_plus_minute() {
        assert_eq!(format_duration(Duration::from_hours(1)), "1h 0m");
        assert_eq!(format_duration(Duration::from_mins(83)), "1h 23m");
    }
}