Skip to main content

ic_host_text/duration/
mod.rs

1//! Positive whole-second duration parsing and compact human-readable display.
2//! Callers own deadlines, retention/freshness policy, units in persisted data and IO.
3
4use std::fmt;
5
6/// Error returned when a duration value cannot be parsed into positive seconds.
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub enum DurationParseError {
10    /// The input did not produce a positive whole-second value.
11    Invalid {
12        /// Original rejected input.
13        value: String,
14    },
15}
16
17impl fmt::Display for DurationParseError {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        let Self::Invalid { value } = self;
20        write!(
21            f,
22            "invalid duration {value:?}; use positive seconds or a value ending in s, m, h, or d"
23        )
24    }
25}
26
27impl std::error::Error for DurationParseError {}
28
29/// Parses a positive duration string into seconds.
30///
31/// Accepts bare seconds or integer values ending in `s`, `m`, `h`, or `d`.
32/// Uses Rust's unsigned integer syntax, including an optional leading `+`,
33/// without trimming, fractional values or uppercase units.
34///
35/// # Errors
36/// Rejects malformed input, zero seconds and multiplication beyond `u64`.
37///
38/// ```
39/// use ic_host_text::duration::parse_duration_seconds;
40/// assert_eq!(parse_duration_seconds("2h").unwrap(), 7_200);
41/// ```
42pub fn parse_duration_seconds(value: &str) -> Result<u64, DurationParseError> {
43    let (number, multiplier) = match value.as_bytes().last().copied() {
44        Some(b's') => (&value[..value.len() - 1], 1),
45        Some(b'm') => (&value[..value.len() - 1], 60),
46        Some(b'h') => (&value[..value.len() - 1], 60 * 60),
47        Some(b'd') => (&value[..value.len() - 1], 24 * 60 * 60),
48        Some(b'0'..=b'9') => (value, 1),
49        _ => {
50            return Err(DurationParseError::Invalid {
51                value: value.to_string(),
52            });
53        }
54    };
55    number
56        .parse::<u64>()
57        .ok()
58        .and_then(|amount| amount.checked_mul(multiplier))
59        .filter(|seconds| *seconds > 0)
60        .ok_or_else(|| DurationParseError::Invalid {
61            value: value.to_string(),
62        })
63}
64
65/// Renders seconds using the largest readable duration unit.
66///
67/// Uses `d`, `h`, `m` or `s`, two fractional digits for inexact selected units,
68/// and integer half-up rounding. Zero renders `0s`; this display is not a
69/// parseable or lossless storage representation.
70///
71/// ```
72/// use ic_host_text::duration::display_duration_seconds;
73/// assert_eq!(display_duration_seconds(5_400), "1.50h");
74/// ```
75#[must_use]
76pub fn display_duration_seconds(seconds: u64) -> String {
77    const MINUTE: u64 = 60;
78    const HOUR: u64 = 60 * MINUTE;
79    const DAY: u64 = 24 * HOUR;
80
81    if seconds == 0 {
82        "0s".to_string()
83    } else if seconds >= DAY {
84        scaled_duration_unit_text(seconds, DAY, "d")
85    } else if seconds >= HOUR {
86        scaled_duration_unit_text(seconds, HOUR, "h")
87    } else if seconds >= MINUTE {
88        scaled_duration_unit_text(seconds, MINUTE, "m")
89    } else {
90        format!("{seconds}s")
91    }
92}
93
94fn scaled_duration_unit_text(seconds: u64, unit_seconds: u64, suffix: &str) -> String {
95    if seconds.is_multiple_of(unit_seconds) {
96        return format!("{}{suffix}", seconds / unit_seconds);
97    }
98    let hundredths =
99        ((u128::from(seconds) * 100) + (u128::from(unit_seconds) / 2)) / u128::from(unit_seconds);
100    let whole = hundredths / 100;
101    let fractional = hundredths % 100;
102    format!("{whole}.{fractional:02}{suffix}")
103}
104
105/// Format a duration in compact largest units for CLI tables and summaries.
106#[must_use]
107pub fn compact_duration(seconds: u64) -> String {
108    const MINUTE: u64 = 60;
109    const HOUR: u64 = 60 * MINUTE;
110    const DAY: u64 = 24 * HOUR;
111    const WEEK: u64 = 7 * DAY;
112
113    match seconds {
114        0 => "0s".to_string(),
115        1..MINUTE => format!("{seconds}s"),
116        MINUTE..HOUR => compact_duration_pair(seconds, MINUTE, "m", 1, "s"),
117        HOUR..DAY => compact_duration_pair(seconds, HOUR, "h", MINUTE, "m"),
118        DAY..WEEK => compact_duration_pair(seconds, DAY, "d", HOUR, "h"),
119        _ => compact_duration_pair(seconds, WEEK, "w", DAY, "d"),
120    }
121}
122
123fn compact_duration_pair(
124    seconds: u64,
125    major_seconds: u64,
126    major_unit: &str,
127    minor_seconds: u64,
128    minor_unit: &str,
129) -> String {
130    let major = seconds / major_seconds;
131    let minor = (seconds % major_seconds) / minor_seconds;
132    if minor == 0 {
133        format!("{major}{major_unit}")
134    } else {
135        format!("{major}{major_unit} {minor}{minor_unit}")
136    }
137}
138
139#[cfg(test)]
140mod tests;