Skip to main content

rustrails_support/
duration.rs

1use std::fmt::{self, Display, Formatter};
2use std::ops::{Add, Div, Mul, Neg, Sub};
3
4use chrono::{DateTime, Duration as ChronoDuration, Utc};
5
6const SECONDS_PER_MINUTE: i64 = 60;
7const SECONDS_PER_HOUR: i64 = 60 * SECONDS_PER_MINUTE;
8const SECONDS_PER_DAY: i64 = 24 * SECONDS_PER_HOUR;
9const SECONDS_PER_WEEK: i64 = 7 * SECONDS_PER_DAY;
10const SECONDS_PER_MONTH: i64 = 2_629_746;
11const SECONDS_PER_YEAR: i64 = 31_556_952;
12
13/// Named parts of a duration.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum DurationPart {
16    /// Whole seconds.
17    Seconds,
18    /// Whole minutes.
19    Minutes,
20    /// Whole hours.
21    Hours,
22    /// Whole days.
23    Days,
24    /// Whole weeks.
25    Weeks,
26    /// Whole months, approximated as one twelfth of a Gregorian year.
27    Months,
28    /// Whole years, approximated as 365.2425 days.
29    Years,
30}
31
32/// A duration expressed as named parts plus an approximated second value.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Duration {
35    parts: Vec<(DurationPart, i64)>,
36    value: i64,
37}
38
39impl Duration {
40    /// Creates a duration from named parts.
41    #[must_use]
42    pub fn new(parts: Vec<(DurationPart, i64)>) -> Self {
43        let parts = normalize_parts(parts);
44        let value = parts
45            .iter()
46            .map(|(part, amount)| part_seconds(*part).saturating_mul(*amount))
47            .sum();
48
49        Self { parts, value }
50    }
51
52    /// Creates a duration in seconds.
53    #[must_use]
54    pub fn seconds(n: i64) -> Self {
55        Self::new(vec![(DurationPart::Seconds, n)])
56    }
57
58    /// Creates a duration in minutes.
59    #[must_use]
60    pub fn minutes(n: i64) -> Self {
61        Self::new(vec![(DurationPart::Minutes, n)])
62    }
63
64    /// Creates a duration in hours.
65    #[must_use]
66    pub fn hours(n: i64) -> Self {
67        Self::new(vec![(DurationPart::Hours, n)])
68    }
69
70    /// Creates a duration in days.
71    #[must_use]
72    pub fn days(n: i64) -> Self {
73        Self::new(vec![(DurationPart::Days, n)])
74    }
75
76    /// Creates a duration in weeks.
77    #[must_use]
78    pub fn weeks(n: i64) -> Self {
79        Self::new(vec![(DurationPart::Weeks, n)])
80    }
81
82    /// Creates a duration in months.
83    #[must_use]
84    pub fn months(n: i64) -> Self {
85        Self::new(vec![(DurationPart::Months, n)])
86    }
87
88    /// Creates a duration in years.
89    #[must_use]
90    pub fn years(n: i64) -> Self {
91        Self::new(vec![(DurationPart::Years, n)])
92    }
93
94    /// Returns the approximated total duration in seconds.
95    #[must_use]
96    pub fn value(&self) -> i64 {
97        self.value
98    }
99
100    /// Returns the named parts that define this duration.
101    #[must_use]
102    pub fn parts(&self) -> &[(DurationPart, i64)] {
103        &self.parts
104    }
105
106    /// Returns the approximated total duration in seconds.
107    #[must_use]
108    pub fn in_seconds(&self) -> i64 {
109        self.value
110    }
111
112    /// Returns the approximated total duration in minutes.
113    #[must_use]
114    pub fn in_minutes(&self) -> f64 {
115        self.value as f64 / SECONDS_PER_MINUTE as f64
116    }
117
118    /// Returns the approximated total duration in hours.
119    #[must_use]
120    pub fn in_hours(&self) -> f64 {
121        self.value as f64 / SECONDS_PER_HOUR as f64
122    }
123
124    /// Returns the approximated total duration in days.
125    #[must_use]
126    pub fn in_days(&self) -> f64 {
127        self.value as f64 / SECONDS_PER_DAY as f64
128    }
129
130    /// Returns the current UTC time minus this duration.
131    #[must_use]
132    pub fn ago(&self) -> DateTime<Utc> {
133        self.until(Utc::now())
134    }
135
136    /// Returns the current UTC time plus this duration.
137    #[must_use]
138    pub fn from_now(&self) -> DateTime<Utc> {
139        self.since(Utc::now())
140    }
141
142    /// Returns `time` plus this duration.
143    #[must_use]
144    pub fn since(&self, time: DateTime<Utc>) -> DateTime<Utc> {
145        match time.checked_add_signed(ChronoDuration::seconds(self.value)) {
146            Some(result) => result,
147            None => time,
148        }
149    }
150
151    /// Returns `time` minus this duration.
152    #[must_use]
153    pub fn until(&self, time: DateTime<Utc>) -> DateTime<Utc> {
154        match time.checked_sub_signed(ChronoDuration::seconds(self.value)) {
155            Some(result) => result,
156            None => time,
157        }
158    }
159
160    /// Formats the duration as an ISO-8601 duration string.
161    #[must_use]
162    pub fn iso8601(&self) -> String {
163        if self.parts.is_empty() {
164            return String::from("PT0S");
165        }
166
167        let mut years = 0;
168        let mut months = 0;
169        let mut weeks = 0;
170        let mut days = 0;
171        let mut hours = 0;
172        let mut minutes = 0;
173        let mut seconds = 0;
174
175        for (part, amount) in &self.parts {
176            match part {
177                DurationPart::Years => years += amount,
178                DurationPart::Months => months += amount,
179                DurationPart::Weeks => weeks += amount,
180                DurationPart::Days => days += amount,
181                DurationPart::Hours => hours += amount,
182                DurationPart::Minutes => minutes += amount,
183                DurationPart::Seconds => seconds += amount,
184            }
185        }
186
187        let mut result = String::from("P");
188        let has_other_date_parts = years != 0 || months != 0 || days != 0;
189
190        if weeks != 0 && !has_other_date_parts && hours == 0 && minutes == 0 && seconds == 0 {
191            result.push_str(&format_component(weeks, 'W'));
192            return result;
193        }
194
195        if weeks != 0 {
196            days += weeks * 7;
197        }
198
199        result.push_str(&format_component(years, 'Y'));
200        result.push_str(&format_component(months, 'M'));
201        result.push_str(&format_component(days, 'D'));
202
203        if hours != 0 || minutes != 0 || seconds != 0 {
204            result.push('T');
205            result.push_str(&format_component(hours, 'H'));
206            result.push_str(&format_component(minutes, 'M'));
207            result.push_str(&format_component(seconds, 'S'));
208        }
209
210        if result == "P" {
211            String::from("PT0S")
212        } else if result.ends_with('T') {
213            format!("{result}0S")
214        } else {
215            result
216        }
217    }
218
219    /// Converts the duration to `std::time::Duration`.
220    ///
221    /// Negative durations clamp to zero because `std::time::Duration` is unsigned.
222    #[must_use]
223    pub fn to_std(&self) -> std::time::Duration {
224        if self.value <= 0 {
225            return std::time::Duration::ZERO;
226        }
227
228        std::time::Duration::from_secs(self.value as u64)
229    }
230}
231
232impl Add for Duration {
233    type Output = Duration;
234
235    fn add(self, rhs: Self) -> Self::Output {
236        let mut parts = self.parts;
237        parts.extend(rhs.parts);
238        Duration::new(parts)
239    }
240}
241
242impl Sub for Duration {
243    type Output = Duration;
244
245    fn sub(self, rhs: Self) -> Self::Output {
246        let mut parts = self.parts;
247        parts.extend(rhs.parts.into_iter().map(|(part, amount)| (part, -amount)));
248        Duration::new(parts)
249    }
250}
251
252impl Mul<i64> for Duration {
253    type Output = Duration;
254
255    fn mul(self, rhs: i64) -> Self::Output {
256        Duration::new(
257            self.parts
258                .into_iter()
259                .map(|(part, amount)| (part, amount.saturating_mul(rhs)))
260                .collect(),
261        )
262    }
263}
264
265impl Div<i64> for Duration {
266    type Output = Duration;
267
268    fn div(self, rhs: i64) -> Self::Output {
269        if rhs == 0 {
270            return Duration::seconds(0);
271        }
272
273        decompose_seconds(self.value / rhs)
274    }
275}
276
277impl Neg for Duration {
278    type Output = Duration;
279
280    fn neg(self) -> Self::Output {
281        Duration::new(
282            self.parts
283                .into_iter()
284                .map(|(part, amount)| (part, -amount))
285                .collect(),
286        )
287    }
288}
289
290impl Display for Duration {
291    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
292        if self.parts.is_empty() {
293            return write!(f, "0 seconds");
294        }
295
296        let segments: Vec<String> = self
297            .parts
298            .iter()
299            .map(|(part, amount)| {
300                let name = part_name(*part, amount.unsigned_abs() == 1);
301                format!("{amount} {name}")
302            })
303            .collect();
304
305        match segments.len() {
306            0 => write!(f, "0 seconds"),
307            1 => write!(f, "{}", segments[0]),
308            2 => write!(f, "{} and {}", segments[0], segments[1]),
309            _ => {
310                for segment in &segments[..segments.len() - 1] {
311                    write!(f, "{segment}, ")?;
312                }
313                write!(f, "and {}", segments[segments.len() - 1])
314            }
315        }
316    }
317}
318
319fn normalize_parts(parts: Vec<(DurationPart, i64)>) -> Vec<(DurationPart, i64)> {
320    let mut normalized = Vec::new();
321
322    for part in ordered_parts() {
323        let total: i64 = parts
324            .iter()
325            .filter(|(candidate, _)| candidate == &part)
326            .map(|(_, amount)| *amount)
327            .sum();
328        if total != 0 {
329            normalized.push((part, total));
330        }
331    }
332
333    normalized
334}
335
336fn ordered_parts() -> [DurationPart; 7] {
337    [
338        DurationPart::Years,
339        DurationPart::Months,
340        DurationPart::Weeks,
341        DurationPart::Days,
342        DurationPart::Hours,
343        DurationPart::Minutes,
344        DurationPart::Seconds,
345    ]
346}
347
348fn part_seconds(part: DurationPart) -> i64 {
349    match part {
350        DurationPart::Seconds => 1,
351        DurationPart::Minutes => SECONDS_PER_MINUTE,
352        DurationPart::Hours => SECONDS_PER_HOUR,
353        DurationPart::Days => SECONDS_PER_DAY,
354        DurationPart::Weeks => SECONDS_PER_WEEK,
355        DurationPart::Months => SECONDS_PER_MONTH,
356        DurationPart::Years => SECONDS_PER_YEAR,
357    }
358}
359
360fn part_name(part: DurationPart, singular: bool) -> &'static str {
361    match (part, singular) {
362        (DurationPart::Seconds, true) => "second",
363        (DurationPart::Seconds, false) => "seconds",
364        (DurationPart::Minutes, true) => "minute",
365        (DurationPart::Minutes, false) => "minutes",
366        (DurationPart::Hours, true) => "hour",
367        (DurationPart::Hours, false) => "hours",
368        (DurationPart::Days, true) => "day",
369        (DurationPart::Days, false) => "days",
370        (DurationPart::Weeks, true) => "week",
371        (DurationPart::Weeks, false) => "weeks",
372        (DurationPart::Months, true) => "month",
373        (DurationPart::Months, false) => "months",
374        (DurationPart::Years, true) => "year",
375        (DurationPart::Years, false) => "years",
376    }
377}
378
379fn format_component(amount: i64, suffix: char) -> String {
380    if amount == 0 {
381        String::new()
382    } else {
383        format!("{amount}{suffix}")
384    }
385}
386
387fn decompose_seconds(total_seconds: i64) -> Duration {
388    if total_seconds == 0 {
389        return Duration::seconds(0) - Duration::seconds(0);
390    }
391
392    let sign = if total_seconds < 0 { -1 } else { 1 };
393    let mut remaining = total_seconds.unsigned_abs() as i64;
394    let mut parts = Vec::new();
395
396    for part in ordered_parts() {
397        let unit = part_seconds(part);
398        if remaining >= unit {
399            let amount = remaining / unit;
400            remaining %= unit;
401            parts.push((part, amount * sign));
402        }
403    }
404
405    Duration::new(parts)
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn constructs_seconds() {
414        let duration = Duration::seconds(5);
415        assert_eq!(duration.value(), 5);
416        assert_eq!(duration.parts(), &[(DurationPart::Seconds, 5)]);
417    }
418
419    #[test]
420    fn constructs_minutes() {
421        let duration = Duration::minutes(2);
422        assert_eq!(duration.value(), 120);
423        assert_eq!(duration.parts(), &[(DurationPart::Minutes, 2)]);
424    }
425
426    #[test]
427    fn constructs_hours() {
428        let duration = Duration::hours(3);
429        assert_eq!(duration.value(), 10_800);
430        assert_eq!(duration.parts(), &[(DurationPart::Hours, 3)]);
431    }
432
433    #[test]
434    fn constructs_days() {
435        let duration = Duration::days(2);
436        assert_eq!(duration.value(), 172_800);
437        assert_eq!(duration.parts(), &[(DurationPart::Days, 2)]);
438    }
439
440    #[test]
441    fn constructs_weeks() {
442        let duration = Duration::weeks(2);
443        assert_eq!(duration.value(), 1_209_600);
444        assert_eq!(duration.parts(), &[(DurationPart::Weeks, 2)]);
445    }
446
447    #[test]
448    fn constructs_months() {
449        let duration = Duration::months(1);
450        assert_eq!(duration.value(), SECONDS_PER_MONTH);
451        assert_eq!(duration.parts(), &[(DurationPart::Months, 1)]);
452    }
453
454    #[test]
455    fn constructs_years() {
456        let duration = Duration::years(1);
457        assert_eq!(duration.value(), SECONDS_PER_YEAR);
458        assert_eq!(duration.parts(), &[(DurationPart::Years, 1)]);
459    }
460
461    #[test]
462    fn addition_combines_parts() {
463        let duration = Duration::hours(1) + Duration::minutes(30);
464        assert_eq!(duration.value(), 5_400);
465        assert_eq!(
466            duration.parts(),
467            &[(DurationPart::Hours, 1), (DurationPart::Minutes, 30)]
468        );
469    }
470
471    #[test]
472    fn subtraction_negates_rhs_parts() {
473        let duration = Duration::days(2) - Duration::hours(12);
474        assert_eq!(duration.value(), 129_600);
475        assert_eq!(
476            duration.parts(),
477            &[(DurationPart::Days, 2), (DurationPart::Hours, -12)]
478        );
479    }
480
481    #[test]
482    fn multiplication_scales_parts() {
483        let duration = Duration::hours(2) * 3;
484        assert_eq!(duration, Duration::hours(6));
485    }
486
487    #[test]
488    fn division_rebuilds_from_seconds() {
489        let duration = Duration::days(1) / 24;
490        assert_eq!(duration, Duration::hours(1));
491    }
492
493    #[test]
494    fn division_by_zero_returns_zero_seconds() {
495        let duration = Duration::hours(1) / 0;
496        assert_eq!(duration.value(), 0);
497        assert!(duration.parts().is_empty());
498    }
499
500    #[test]
501    fn negation_negates_parts() {
502        let duration = -Duration::minutes(5);
503        assert_eq!(duration.value(), -300);
504        assert_eq!(duration.parts(), &[(DurationPart::Minutes, -5)]);
505    }
506
507    #[test]
508    fn conversions_return_expected_values() {
509        let duration = Duration::days(1);
510        assert_eq!(duration.in_seconds(), 86_400);
511        assert_eq!(duration.in_minutes(), 1_440.0);
512        assert_eq!(duration.in_hours(), 24.0);
513        assert_eq!(duration.in_days(), 1.0);
514    }
515
516    #[test]
517    fn composite_duration_retains_ordered_parts() {
518        let duration = Duration::minutes(30) + Duration::hours(1) + Duration::seconds(15);
519        assert_eq!(
520            duration.parts(),
521            &[
522                (DurationPart::Hours, 1),
523                (DurationPart::Minutes, 30),
524                (DurationPart::Seconds, 15),
525            ]
526        );
527    }
528
529    #[test]
530    fn zero_duration_formats_as_zero_seconds() {
531        let duration = Duration::new(Vec::new());
532        assert_eq!(duration.value(), 0);
533        assert_eq!(duration.to_string(), "0 seconds");
534        assert_eq!(duration.iso8601(), "PT0S");
535    }
536
537    #[test]
538    fn ago_is_approximately_in_the_past() {
539        let now = Utc::now();
540        let ago = Duration::seconds(1).ago();
541        let delta = now.signed_duration_since(ago).num_seconds();
542        assert!((0..=2).contains(&delta));
543    }
544
545    #[test]
546    fn from_now_is_approximately_in_the_future() {
547        let now = Utc::now();
548        let future = Duration::seconds(1).from_now();
549        let delta = future.signed_duration_since(now).num_seconds();
550        assert!((0..=2).contains(&delta));
551    }
552
553    #[test]
554    fn since_adds_seconds_to_time() {
555        let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
556        let result = Duration::minutes(2).since(time);
557        assert_eq!(result, time + ChronoDuration::minutes(2));
558    }
559
560    #[test]
561    fn until_subtracts_seconds_from_time() {
562        let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
563        let result = Duration::minutes(2).until(time);
564        assert_eq!(result, time - ChronoDuration::minutes(2));
565    }
566
567    #[test]
568    fn iso8601_formats_date_and_time_parts() {
569        let duration =
570            Duration::days(1) + Duration::hours(2) + Duration::minutes(3) + Duration::seconds(4);
571        assert_eq!(duration.iso8601(), "P1DT2H3M4S");
572    }
573
574    #[test]
575    fn iso8601_uses_weeks_when_alone() {
576        assert_eq!(Duration::weeks(1).iso8601(), "P1W");
577    }
578
579    #[test]
580    fn iso8601_converts_weeks_when_mixed() {
581        let duration = Duration::years(1) + Duration::weeks(1);
582        assert_eq!(duration.iso8601(), "P1Y7D");
583    }
584
585    #[test]
586    fn iso8601_preserves_negative_parts() {
587        let duration = Duration::months(6) - Duration::days(2);
588        assert_eq!(duration.iso8601(), "P6M-2D");
589    }
590
591    #[test]
592    fn to_std_converts_positive_values() {
593        assert_eq!(
594            Duration::seconds(5).to_std(),
595            std::time::Duration::from_secs(5)
596        );
597    }
598
599    #[test]
600    fn to_std_clamps_negative_values_to_zero() {
601        assert_eq!(Duration::seconds(-5).to_std(), std::time::Duration::ZERO);
602    }
603
604    #[test]
605    fn display_is_human_readable() {
606        let duration = Duration::years(1) + Duration::months(2) + Duration::days(1);
607        assert_eq!(duration.to_string(), "1 year, 2 months, and 1 day");
608    }
609
610    #[test]
611    fn display_handles_negative_parts() {
612        let duration = Duration::months(6) - Duration::days(2);
613        assert_eq!(duration.to_string(), "6 months and -2 days");
614    }
615
616    #[test]
617    fn new_normalizes_and_orders_parts() {
618        let duration = Duration::new(vec![
619            (DurationPart::Minutes, 2),
620            (DurationPart::Hours, 1),
621            (DurationPart::Minutes, -1),
622            (DurationPart::Seconds, 0),
623        ]);
624
625        assert_eq!(duration.value(), 3_660);
626        assert_eq!(
627            duration.parts(),
628            &[(DurationPart::Hours, 1), (DurationPart::Minutes, 1)]
629        );
630        assert_eq!(duration, Duration::hours(1) + Duration::minutes(1));
631    }
632
633    #[test]
634    fn zero_amount_parts_normalize_to_zero_duration() {
635        let duration = Duration::days(0);
636
637        assert_eq!(duration.value(), 0);
638        assert!(duration.parts().is_empty());
639        assert_eq!(duration.to_string(), "0 seconds");
640        assert_eq!(duration.iso8601(), "PT0S");
641    }
642
643    #[test]
644    fn multiplication_by_negative_scalar_negates_parts() {
645        let duration = Duration::days(2) * -3;
646
647        assert_eq!(duration.value(), -518_400);
648        assert_eq!(duration.parts(), &[(DurationPart::Days, -6)]);
649    }
650
651    #[test]
652    fn division_of_negative_duration_rebuilds_negative_parts() {
653        let duration = Duration::hours(-1) / 2;
654
655        assert_eq!(duration.value(), -1_800);
656        assert_eq!(duration.parts(), &[(DurationPart::Minutes, -30)]);
657    }
658
659    #[test]
660    fn since_and_until_handle_zero_and_negative_durations() {
661        let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
662
663        assert_eq!(Duration::seconds(0).since(time), time);
664        assert_eq!(Duration::seconds(0).until(time), time);
665
666        let duration = Duration::minutes(-2);
667        assert_eq!(duration.since(time), time - ChronoDuration::minutes(2));
668        assert_eq!(duration.until(time), time + ChronoDuration::minutes(2));
669    }
670
671    #[test]
672    fn iso8601_formats_year_month_and_negative_time_parts() {
673        let duration =
674            Duration::years(1) + Duration::months(1) + Duration::days(1) + Duration::hours(1);
675        assert_eq!(duration.iso8601(), "P1Y1M1DT1H");
676
677        let negative = Duration::years(1) - Duration::days(1) - Duration::seconds(1);
678        assert_eq!(negative.iso8601(), "P1Y-1DT-1S");
679    }
680
681    #[test]
682    fn display_formats_single_and_two_part_durations() {
683        assert_eq!(Duration::weeks(1).to_string(), "1 week");
684        assert_eq!(
685            (Duration::months(1) + Duration::days(1)).to_string(),
686            "1 month and 1 day"
687        );
688    }
689
690    #[test]
691    fn new_orders_parts_from_largest_to_smallest_units() {
692        let duration = Duration::new(vec![
693            (DurationPart::Seconds, 5),
694            (DurationPart::Years, 1),
695            (DurationPart::Days, 2),
696        ]);
697
698        assert_eq!(
699            duration.parts(),
700            &[
701                (DurationPart::Years, 1),
702                (DurationPart::Days, 2),
703                (DurationPart::Seconds, 5),
704            ]
705        );
706    }
707
708    #[test]
709    fn new_cancels_matching_parts_that_sum_to_zero() {
710        let duration = Duration::new(vec![
711            (DurationPart::Minutes, 5),
712            (DurationPart::Minutes, -5),
713        ]);
714
715        assert_eq!(duration.value(), 0);
716        assert!(duration.parts().is_empty());
717    }
718
719    #[test]
720    fn addition_normalizes_matching_parts() {
721        let duration = Duration::minutes(15) + Duration::minutes(45);
722
723        assert_eq!(duration.value(), 3_600);
724        assert_eq!(duration.parts(), &[(DurationPart::Minutes, 60)]);
725    }
726
727    #[test]
728    fn subtraction_normalizes_matching_parts() {
729        let duration = Duration::days(2) - Duration::days(1);
730
731        assert_eq!(duration.value(), 86_400);
732        assert_eq!(duration.parts(), &[(DurationPart::Days, 1)]);
733    }
734
735    #[test]
736    fn subtraction_of_identical_parts_produces_zero_duration() {
737        let duration = Duration::hours(1) - Duration::hours(1);
738
739        assert_eq!(duration.value(), 0);
740        assert!(duration.parts().is_empty());
741    }
742
743    #[test]
744    fn negation_flips_each_part_in_composite_duration() {
745        let duration = -(Duration::days(1) + Duration::hours(2));
746
747        assert_eq!(
748            duration.parts(),
749            &[(DurationPart::Days, -1), (DurationPart::Hours, -2)]
750        );
751        assert_eq!(duration.value(), -93_600);
752    }
753
754    #[test]
755    #[allow(clippy::erasing_op)]
756    fn multiplication_by_zero_returns_zero_duration() {
757        let duration = (Duration::days(1) + Duration::hours(2)) * 0;
758
759        assert_eq!(duration.value(), 0);
760        assert!(duration.parts().is_empty());
761    }
762
763    #[test]
764    fn multiplication_scales_composite_parts() {
765        let duration = (Duration::days(1) + Duration::hours(2)) * 2;
766
767        assert_eq!(
768            duration.parts(),
769            &[(DurationPart::Days, 2), (DurationPart::Hours, 4)]
770        );
771        assert_eq!(duration.value(), 187_200);
772    }
773
774    #[test]
775    fn division_truncates_fractional_seconds() {
776        let duration = Duration::seconds(5) / 2;
777
778        assert_eq!(duration.value(), 2);
779        assert_eq!(duration.parts(), &[(DurationPart::Seconds, 2)]);
780    }
781
782    #[test]
783    fn division_of_weeks_decomposes_into_days_and_hours() {
784        let duration = Duration::weeks(1) / 2;
785
786        assert_eq!(duration.value(), 302_400);
787        assert_eq!(
788            duration.parts(),
789            &[(DurationPart::Days, 3), (DurationPart::Hours, 12)]
790        );
791    }
792
793    #[test]
794    fn in_minutes_handles_negative_durations() {
795        assert_eq!(Duration::seconds(-90).in_minutes(), -1.5);
796    }
797
798    #[test]
799    fn in_hours_handles_partial_days() {
800        assert_eq!(Duration::minutes(90).in_hours(), 1.5);
801    }
802
803    #[test]
804    fn in_days_handles_negative_hours() {
805        assert_eq!(Duration::hours(-12).in_days(), -0.5);
806    }
807
808    #[test]
809    fn since_adds_composite_duration_value_to_time() {
810        let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
811        let duration = Duration::days(1) + Duration::minutes(90);
812
813        assert_eq!(duration.since(time), time + ChronoDuration::seconds(91_800));
814    }
815
816    #[test]
817    fn until_subtracts_composite_duration_value_from_time() {
818        let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
819        let duration = Duration::days(1) + Duration::minutes(90);
820
821        assert_eq!(duration.until(time), time - ChronoDuration::seconds(91_800));
822    }
823
824    #[test]
825    fn iso8601_formats_minutes_only() {
826        assert_eq!(Duration::minutes(5).iso8601(), "PT5M");
827    }
828
829    #[test]
830    fn iso8601_formats_negative_seconds_only() {
831        assert_eq!(Duration::seconds(-5).iso8601(), "PT-5S");
832    }
833
834    #[test]
835    fn iso8601_formats_negative_weeks_only() {
836        assert_eq!(Duration::weeks(-2).iso8601(), "P-2W");
837    }
838
839    #[test]
840    fn iso8601_converts_mixed_weeks_to_days() {
841        let duration = Duration::weeks(1) + Duration::days(2) + Duration::hours(3);
842
843        assert_eq!(duration.iso8601(), "P9DT3H");
844    }
845
846    #[test]
847    fn to_std_preserves_composite_positive_values() {
848        assert_eq!(
849            (Duration::days(1) + Duration::seconds(2)).to_std(),
850            std::time::Duration::from_secs(86_402)
851        );
852    }
853
854    #[test]
855    fn display_handles_negative_single_part_singular() {
856        assert_eq!(Duration::seconds(-1).to_string(), "-1 second");
857    }
858
859    #[test]
860    fn display_formats_three_part_durations_with_commas() {
861        assert_eq!(
862            (Duration::weeks(1) + Duration::days(2) + Duration::hours(3)).to_string(),
863            "1 week, 2 days, and 3 hours"
864        );
865    }
866
867    #[test]
868    fn equality_is_sensitive_to_representation_even_when_values_match() {
869        assert_eq!(Duration::minutes(60).value(), Duration::hours(1).value());
870        assert_ne!(Duration::minutes(60), Duration::hours(1));
871    }
872
873    #[test]
874    fn zero_duration_ago_and_from_now_stay_close_to_now() {
875        let now = Utc::now();
876        let past = Duration::seconds(0).ago();
877        let future = Duration::seconds(0).from_now();
878
879        assert!(now.signed_duration_since(past).num_seconds().abs() <= 1);
880        assert!(future.signed_duration_since(now).num_seconds().abs() <= 1);
881    }
882}