1use std::fmt;
10use std::str::FromStr;
11
12use crate::swarm::Error;
13
14#[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 pub const ZERO: Duration = Duration { seconds: 0 };
30
31 pub fn from_seconds(s: f64) -> Self {
34 Self::new(s)
35 }
36
37 pub fn from_milliseconds(ms: f64) -> Self {
39 Self::new(ms / 1000.0)
40 }
41
42 pub fn from_minutes(m: f64) -> Self {
44 Self::new(m * SECONDS_IN_MINUTE as f64)
45 }
46
47 pub fn from_hours(h: f64) -> Self {
49 Self::new(h * SECONDS_IN_HOUR as f64)
50 }
51
52 pub fn from_days(d: f64) -> Self {
54 Self::new(d * SECONDS_IN_DAY as f64)
55 }
56
57 pub fn from_weeks(w: f64) -> Self {
59 Self::new(w * SECONDS_IN_WEEK as f64)
60 }
61
62 pub fn from_months(m: f64) -> Self {
64 Self::new(m * SECONDS_IN_MONTH as f64)
65 }
66
67 pub fn from_years(y: f64) -> Self {
69 Self::new(y * SECONDS_IN_YEAR as f64)
70 }
71
72 pub fn from_std(d: std::time::Duration) -> Self {
74 Self::new(d.as_secs_f64())
75 }
76
77 pub fn parse(s: &str) -> Result<Self, Error> {
82 <Self as FromStr>::from_str(s)
83 }
84
85 pub const fn to_seconds(self) -> i64 {
87 self.seconds
88 }
89
90 pub const fn to_milliseconds(self) -> i64 {
92 self.seconds * 1000
93 }
94
95 pub fn to_minutes(self) -> f64 {
97 self.seconds as f64 / SECONDS_IN_MINUTE as f64
98 }
99
100 pub fn to_hours(self) -> f64 {
102 self.seconds as f64 / SECONDS_IN_HOUR as f64
103 }
104
105 pub fn to_days(self) -> f64 {
107 self.seconds as f64 / SECONDS_IN_DAY as f64
108 }
109
110 pub fn to_weeks(self) -> f64 {
112 self.seconds as f64 / SECONDS_IN_WEEK as f64
113 }
114
115 pub fn to_years(self) -> f64 {
117 self.seconds as f64 / SECONDS_IN_YEAR as f64
118 }
119
120 pub fn to_std(self) -> std::time::Duration {
122 std::time::Duration::from_secs(self.seconds.max(0) as u64)
123 }
124
125 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 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 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}