Skip to main content

bee/swarm/
duration.rs

1//! Bee-flavored [`Duration`] type. Mirrors bee-go's
2//! `pkg/swarm/duration.go` and bee-js `Duration`.
3//!
4//! Non-negative whole-second duration. Negative inputs clamp to
5//! zero, fractional seconds round up. The type wraps an `i64`
6//! seconds count and is `Copy`. Use [`Duration::to_std`] to convert
7//! to [`std::time::Duration`].
8
9use std::fmt;
10use std::str::FromStr;
11
12use crate::swarm::Error;
13
14/// Non-negative whole-second duration.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct Duration {
17    seconds: i64,
18}
19
20const SECONDS_IN_MINUTE: i64 = 60;
21const SECONDS_IN_HOUR: i64 = 60 * SECONDS_IN_MINUTE;
22const SECONDS_IN_DAY: i64 = 24 * SECONDS_IN_HOUR;
23const SECONDS_IN_WEEK: i64 = 7 * SECONDS_IN_DAY;
24const SECONDS_IN_MONTH: i64 = 30 * SECONDS_IN_DAY;
25const SECONDS_IN_YEAR: i64 = 365 * SECONDS_IN_DAY;
26
27impl Duration {
28    /// Zero-length duration.
29    pub const ZERO: Duration = Duration { seconds: 0 };
30
31    /// Build from whole seconds (rounds up if fractional, clamps
32    /// negatives / NaN to zero).
33    pub fn from_seconds(s: f64) -> Self {
34        Self::new(s)
35    }
36
37    /// Build from milliseconds.
38    pub fn from_milliseconds(ms: f64) -> Self {
39        Self::new(ms / 1000.0)
40    }
41
42    /// Build from minutes.
43    pub fn from_minutes(m: f64) -> Self {
44        Self::new(m * SECONDS_IN_MINUTE as f64)
45    }
46
47    /// Build from hours.
48    pub fn from_hours(h: f64) -> Self {
49        Self::new(h * SECONDS_IN_HOUR as f64)
50    }
51
52    /// Build from days.
53    pub fn from_days(d: f64) -> Self {
54        Self::new(d * SECONDS_IN_DAY as f64)
55    }
56
57    /// Build from weeks.
58    pub fn from_weeks(w: f64) -> Self {
59        Self::new(w * SECONDS_IN_WEEK as f64)
60    }
61
62    /// Build from 30-day months.
63    pub fn from_months(m: f64) -> Self {
64        Self::new(m * SECONDS_IN_MONTH as f64)
65    }
66
67    /// Build from 365-day years.
68    pub fn from_years(y: f64) -> Self {
69        Self::new(y * SECONDS_IN_YEAR as f64)
70    }
71
72    /// Build from a [`std::time::Duration`].
73    pub fn from_std(d: std::time::Duration) -> Self {
74        Self::new(d.as_secs_f64())
75    }
76
77    /// Parse strings like `"1.5h"`, `"5 d"`, `"2weeks"`, `"30s"`,
78    /// `"1d 4h 5m 30s"`. Case-insensitive, whitespace-tolerant.
79    /// Supported unit families: `ms` / `s` / `m` / `h` / `d` / `w` /
80    /// `month` / `y`.
81    pub fn parse(s: &str) -> Result<Self, Error> {
82        <Self as FromStr>::from_str(s)
83    }
84
85    /// Whole-second count.
86    pub const fn to_seconds(self) -> i64 {
87        self.seconds
88    }
89
90    /// Milliseconds (whole-second precision).
91    pub const fn to_milliseconds(self) -> i64 {
92        self.seconds * 1000
93    }
94
95    /// Fractional minutes.
96    pub fn to_minutes(self) -> f64 {
97        self.seconds as f64 / SECONDS_IN_MINUTE as f64
98    }
99
100    /// Fractional hours.
101    pub fn to_hours(self) -> f64 {
102        self.seconds as f64 / SECONDS_IN_HOUR as f64
103    }
104
105    /// Fractional days.
106    pub fn to_days(self) -> f64 {
107        self.seconds as f64 / SECONDS_IN_DAY as f64
108    }
109
110    /// Fractional weeks.
111    pub fn to_weeks(self) -> f64 {
112        self.seconds as f64 / SECONDS_IN_WEEK as f64
113    }
114
115    /// Fractional 365-day years.
116    pub fn to_years(self) -> f64 {
117        self.seconds as f64 / SECONDS_IN_YEAR as f64
118    }
119
120    /// Convert to a [`std::time::Duration`].
121    pub fn to_std(self) -> std::time::Duration {
122        std::time::Duration::from_secs(self.seconds.max(0) as u64)
123    }
124
125    /// True iff the duration is zero.
126    pub const fn is_zero(self) -> bool {
127        self.seconds == 0
128    }
129
130    fn new(seconds: f64) -> Self {
131        if seconds.is_nan() || seconds < 0.0 {
132            return Self::ZERO;
133        }
134        Self {
135            seconds: seconds.ceil() as i64,
136        }
137    }
138}
139
140impl fmt::Display for Duration {
141    /// Render as "1y 4w 2d 3h 5m 30s" (only non-zero parts; zero
142    /// renders as `"0s"`).
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        if self.seconds == 0 {
145            return f.write_str("0s");
146        }
147        let parts: [(&str, i64); 6] = [
148            ("y", SECONDS_IN_YEAR),
149            ("w", SECONDS_IN_WEEK),
150            ("d", SECONDS_IN_DAY),
151            ("h", SECONDS_IN_HOUR),
152            ("m", SECONDS_IN_MINUTE),
153            ("s", 1),
154        ];
155        let mut remaining = self.seconds;
156        let mut wrote_any = false;
157        for (unit, size) in parts {
158            if remaining >= size {
159                let n = remaining / size;
160                remaining -= n * size;
161                if wrote_any {
162                    f.write_str(" ")?;
163                }
164                write!(f, "{n}{unit}")?;
165                wrote_any = true;
166            }
167        }
168        Ok(())
169    }
170}
171
172impl FromStr for Duration {
173    type Err = Error;
174
175    fn from_str(s: &str) -> Result<Self, Error> {
176        let clean: String = s.chars().filter(|c| !c.is_whitespace()).collect();
177        let lower = clean.to_ascii_lowercase();
178        if lower.is_empty() {
179            return Err(Error::argument("empty duration string"));
180        }
181        let mut total: f64 = 0.0;
182        let mut chars = lower.chars().peekable();
183        let mut found = false;
184        while chars.peek().is_some() {
185            let mut num = String::new();
186            while let Some(&c) = chars.peek() {
187                if c.is_ascii_digit() || c == '.' {
188                    num.push(c);
189                    chars.next();
190                } else {
191                    break;
192                }
193            }
194            if num.is_empty() {
195                return Err(Error::argument(format!(
196                    "unrecognized duration string: {s}"
197                )));
198            }
199            let value: f64 = num
200                .parse()
201                .map_err(|_| Error::argument(format!("invalid duration number: {num}")))?;
202
203            let mut unit = String::new();
204            while let Some(&c) = chars.peek() {
205                if c.is_ascii_alphabetic() {
206                    unit.push(c);
207                    chars.next();
208                } else {
209                    break;
210                }
211            }
212            if unit.is_empty() {
213                return Err(Error::argument(format!("missing unit in: {s}")));
214            }
215            total += value * unit_to_seconds(&unit)?;
216            found = true;
217        }
218        if !found {
219            return Err(Error::argument(format!(
220                "unrecognized duration string: {s}"
221            )));
222        }
223        Ok(Self::new(total))
224    }
225}
226
227fn unit_to_seconds(unit: &str) -> Result<f64, Error> {
228    Ok(match unit {
229        "ms" | "milli" | "millis" | "millisecond" | "milliseconds" => 0.001,
230        "s" | "sec" | "second" | "seconds" => 1.0,
231        "m" | "min" | "minute" | "minutes" => SECONDS_IN_MINUTE as f64,
232        "h" | "hour" | "hours" => SECONDS_IN_HOUR as f64,
233        "d" | "day" | "days" => SECONDS_IN_DAY as f64,
234        "w" | "week" | "weeks" => SECONDS_IN_WEEK as f64,
235        "month" | "months" => SECONDS_IN_MONTH as f64,
236        "y" | "year" | "years" => SECONDS_IN_YEAR as f64,
237        other => {
238            return Err(Error::argument(format!(
239                "unsupported duration unit: {other}"
240            )));
241        }
242    })
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn negative_or_nan_clamps_to_zero() {
251        assert_eq!(Duration::from_seconds(-1.0), Duration::ZERO);
252        assert_eq!(Duration::from_seconds(f64::NAN), Duration::ZERO);
253    }
254
255    #[test]
256    fn fractional_seconds_round_up() {
257        assert_eq!(Duration::from_seconds(0.1).to_seconds(), 1);
258        assert_eq!(Duration::from_milliseconds(1500.0).to_seconds(), 2);
259    }
260
261    #[test]
262    fn unit_constructors_match_seconds() {
263        assert_eq!(Duration::from_minutes(1.0).to_seconds(), 60);
264        assert_eq!(Duration::from_hours(1.0).to_seconds(), 3600);
265        assert_eq!(Duration::from_days(1.0).to_seconds(), 86_400);
266        assert_eq!(Duration::from_weeks(1.0).to_seconds(), 7 * 86_400);
267        assert_eq!(Duration::from_years(1.0).to_seconds(), 365 * 86_400);
268    }
269
270    #[test]
271    fn parse_compound_string() {
272        let d = Duration::parse("1d 4h 5m 30s").unwrap();
273        let want = SECONDS_IN_DAY + 4 * SECONDS_IN_HOUR + 5 * SECONDS_IN_MINUTE + 30;
274        assert_eq!(d.to_seconds(), want);
275    }
276
277    #[test]
278    fn parse_decimal_hours() {
279        let d = Duration::parse("1.5h").unwrap();
280        assert_eq!(d.to_seconds(), 5400);
281    }
282
283    #[test]
284    fn parse_handles_whitespace_and_case() {
285        let d = Duration::parse("  2 Weeks  ").unwrap();
286        assert_eq!(d.to_seconds(), 14 * SECONDS_IN_DAY);
287    }
288
289    #[test]
290    fn parse_milliseconds() {
291        // 1500 ms → 2s after ceil rounding.
292        let d = Duration::parse("1500ms").unwrap();
293        assert_eq!(d.to_seconds(), 2);
294    }
295
296    #[test]
297    fn parse_rejects_empty() {
298        assert!(Duration::parse("").is_err());
299        assert!(Duration::parse("   ").is_err());
300    }
301
302    #[test]
303    fn parse_rejects_unknown_unit() {
304        assert!(Duration::parse("3decades").is_err());
305    }
306
307    #[test]
308    fn display_decomposes_into_units() {
309        assert_eq!(Duration::ZERO.to_string(), "0s");
310        let d = Duration::from_seconds((SECONDS_IN_DAY + 4 * SECONDS_IN_HOUR + 5) as f64);
311        assert_eq!(d.to_string(), "1d 4h 5s");
312    }
313
314    #[test]
315    fn round_trip_through_std() {
316        let d = Duration::from_minutes(2.5);
317        let std = d.to_std();
318        let back = Duration::from_std(std);
319        assert_eq!(d, back);
320    }
321}