use std::fmt::{self, Display, Formatter};
use std::ops::{Add, Div, Mul, Neg, Sub};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
const SECONDS_PER_MINUTE: i64 = 60;
const SECONDS_PER_HOUR: i64 = 60 * SECONDS_PER_MINUTE;
const SECONDS_PER_DAY: i64 = 24 * SECONDS_PER_HOUR;
const SECONDS_PER_WEEK: i64 = 7 * SECONDS_PER_DAY;
const SECONDS_PER_MONTH: i64 = 2_629_746;
const SECONDS_PER_YEAR: i64 = 31_556_952;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DurationPart {
Seconds,
Minutes,
Hours,
Days,
Weeks,
Months,
Years,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Duration {
parts: Vec<(DurationPart, i64)>,
value: i64,
}
impl Duration {
#[must_use]
pub fn new(parts: Vec<(DurationPart, i64)>) -> Self {
let parts = normalize_parts(parts);
let value = parts
.iter()
.map(|(part, amount)| part_seconds(*part).saturating_mul(*amount))
.sum();
Self { parts, value }
}
#[must_use]
pub fn seconds(n: i64) -> Self {
Self::new(vec![(DurationPart::Seconds, n)])
}
#[must_use]
pub fn minutes(n: i64) -> Self {
Self::new(vec![(DurationPart::Minutes, n)])
}
#[must_use]
pub fn hours(n: i64) -> Self {
Self::new(vec![(DurationPart::Hours, n)])
}
#[must_use]
pub fn days(n: i64) -> Self {
Self::new(vec![(DurationPart::Days, n)])
}
#[must_use]
pub fn weeks(n: i64) -> Self {
Self::new(vec![(DurationPart::Weeks, n)])
}
#[must_use]
pub fn months(n: i64) -> Self {
Self::new(vec![(DurationPart::Months, n)])
}
#[must_use]
pub fn years(n: i64) -> Self {
Self::new(vec![(DurationPart::Years, n)])
}
#[must_use]
pub fn value(&self) -> i64 {
self.value
}
#[must_use]
pub fn parts(&self) -> &[(DurationPart, i64)] {
&self.parts
}
#[must_use]
pub fn in_seconds(&self) -> i64 {
self.value
}
#[must_use]
pub fn in_minutes(&self) -> f64 {
self.value as f64 / SECONDS_PER_MINUTE as f64
}
#[must_use]
pub fn in_hours(&self) -> f64 {
self.value as f64 / SECONDS_PER_HOUR as f64
}
#[must_use]
pub fn in_days(&self) -> f64 {
self.value as f64 / SECONDS_PER_DAY as f64
}
#[must_use]
pub fn ago(&self) -> DateTime<Utc> {
self.until(Utc::now())
}
#[must_use]
pub fn from_now(&self) -> DateTime<Utc> {
self.since(Utc::now())
}
#[must_use]
pub fn since(&self, time: DateTime<Utc>) -> DateTime<Utc> {
match time.checked_add_signed(ChronoDuration::seconds(self.value)) {
Some(result) => result,
None => time,
}
}
#[must_use]
pub fn until(&self, time: DateTime<Utc>) -> DateTime<Utc> {
match time.checked_sub_signed(ChronoDuration::seconds(self.value)) {
Some(result) => result,
None => time,
}
}
#[must_use]
pub fn iso8601(&self) -> String {
if self.parts.is_empty() {
return String::from("PT0S");
}
let mut years = 0;
let mut months = 0;
let mut weeks = 0;
let mut days = 0;
let mut hours = 0;
let mut minutes = 0;
let mut seconds = 0;
for (part, amount) in &self.parts {
match part {
DurationPart::Years => years += amount,
DurationPart::Months => months += amount,
DurationPart::Weeks => weeks += amount,
DurationPart::Days => days += amount,
DurationPart::Hours => hours += amount,
DurationPart::Minutes => minutes += amount,
DurationPart::Seconds => seconds += amount,
}
}
let mut result = String::from("P");
let has_other_date_parts = years != 0 || months != 0 || days != 0;
if weeks != 0 && !has_other_date_parts && hours == 0 && minutes == 0 && seconds == 0 {
result.push_str(&format_component(weeks, 'W'));
return result;
}
if weeks != 0 {
days += weeks * 7;
}
result.push_str(&format_component(years, 'Y'));
result.push_str(&format_component(months, 'M'));
result.push_str(&format_component(days, 'D'));
if hours != 0 || minutes != 0 || seconds != 0 {
result.push('T');
result.push_str(&format_component(hours, 'H'));
result.push_str(&format_component(minutes, 'M'));
result.push_str(&format_component(seconds, 'S'));
}
if result == "P" {
String::from("PT0S")
} else if result.ends_with('T') {
format!("{result}0S")
} else {
result
}
}
#[must_use]
pub fn to_std(&self) -> std::time::Duration {
if self.value <= 0 {
return std::time::Duration::ZERO;
}
std::time::Duration::from_secs(self.value as u64)
}
}
impl Add for Duration {
type Output = Duration;
fn add(self, rhs: Self) -> Self::Output {
let mut parts = self.parts;
parts.extend(rhs.parts);
Duration::new(parts)
}
}
impl Sub for Duration {
type Output = Duration;
fn sub(self, rhs: Self) -> Self::Output {
let mut parts = self.parts;
parts.extend(rhs.parts.into_iter().map(|(part, amount)| (part, -amount)));
Duration::new(parts)
}
}
impl Mul<i64> for Duration {
type Output = Duration;
fn mul(self, rhs: i64) -> Self::Output {
Duration::new(
self.parts
.into_iter()
.map(|(part, amount)| (part, amount.saturating_mul(rhs)))
.collect(),
)
}
}
impl Div<i64> for Duration {
type Output = Duration;
fn div(self, rhs: i64) -> Self::Output {
if rhs == 0 {
return Duration::seconds(0);
}
decompose_seconds(self.value / rhs)
}
}
impl Neg for Duration {
type Output = Duration;
fn neg(self) -> Self::Output {
Duration::new(
self.parts
.into_iter()
.map(|(part, amount)| (part, -amount))
.collect(),
)
}
}
impl Display for Duration {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
if self.parts.is_empty() {
return write!(f, "0 seconds");
}
let segments: Vec<String> = self
.parts
.iter()
.map(|(part, amount)| {
let name = part_name(*part, amount.unsigned_abs() == 1);
format!("{amount} {name}")
})
.collect();
match segments.len() {
0 => write!(f, "0 seconds"),
1 => write!(f, "{}", segments[0]),
2 => write!(f, "{} and {}", segments[0], segments[1]),
_ => {
for segment in &segments[..segments.len() - 1] {
write!(f, "{segment}, ")?;
}
write!(f, "and {}", segments[segments.len() - 1])
}
}
}
}
fn normalize_parts(parts: Vec<(DurationPart, i64)>) -> Vec<(DurationPart, i64)> {
let mut normalized = Vec::new();
for part in ordered_parts() {
let total: i64 = parts
.iter()
.filter(|(candidate, _)| candidate == &part)
.map(|(_, amount)| *amount)
.sum();
if total != 0 {
normalized.push((part, total));
}
}
normalized
}
fn ordered_parts() -> [DurationPart; 7] {
[
DurationPart::Years,
DurationPart::Months,
DurationPart::Weeks,
DurationPart::Days,
DurationPart::Hours,
DurationPart::Minutes,
DurationPart::Seconds,
]
}
fn part_seconds(part: DurationPart) -> i64 {
match part {
DurationPart::Seconds => 1,
DurationPart::Minutes => SECONDS_PER_MINUTE,
DurationPart::Hours => SECONDS_PER_HOUR,
DurationPart::Days => SECONDS_PER_DAY,
DurationPart::Weeks => SECONDS_PER_WEEK,
DurationPart::Months => SECONDS_PER_MONTH,
DurationPart::Years => SECONDS_PER_YEAR,
}
}
fn part_name(part: DurationPart, singular: bool) -> &'static str {
match (part, singular) {
(DurationPart::Seconds, true) => "second",
(DurationPart::Seconds, false) => "seconds",
(DurationPart::Minutes, true) => "minute",
(DurationPart::Minutes, false) => "minutes",
(DurationPart::Hours, true) => "hour",
(DurationPart::Hours, false) => "hours",
(DurationPart::Days, true) => "day",
(DurationPart::Days, false) => "days",
(DurationPart::Weeks, true) => "week",
(DurationPart::Weeks, false) => "weeks",
(DurationPart::Months, true) => "month",
(DurationPart::Months, false) => "months",
(DurationPart::Years, true) => "year",
(DurationPart::Years, false) => "years",
}
}
fn format_component(amount: i64, suffix: char) -> String {
if amount == 0 {
String::new()
} else {
format!("{amount}{suffix}")
}
}
fn decompose_seconds(total_seconds: i64) -> Duration {
if total_seconds == 0 {
return Duration::seconds(0) - Duration::seconds(0);
}
let sign = if total_seconds < 0 { -1 } else { 1 };
let mut remaining = total_seconds.unsigned_abs() as i64;
let mut parts = Vec::new();
for part in ordered_parts() {
let unit = part_seconds(part);
if remaining >= unit {
let amount = remaining / unit;
remaining %= unit;
parts.push((part, amount * sign));
}
}
Duration::new(parts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructs_seconds() {
let duration = Duration::seconds(5);
assert_eq!(duration.value(), 5);
assert_eq!(duration.parts(), &[(DurationPart::Seconds, 5)]);
}
#[test]
fn constructs_minutes() {
let duration = Duration::minutes(2);
assert_eq!(duration.value(), 120);
assert_eq!(duration.parts(), &[(DurationPart::Minutes, 2)]);
}
#[test]
fn constructs_hours() {
let duration = Duration::hours(3);
assert_eq!(duration.value(), 10_800);
assert_eq!(duration.parts(), &[(DurationPart::Hours, 3)]);
}
#[test]
fn constructs_days() {
let duration = Duration::days(2);
assert_eq!(duration.value(), 172_800);
assert_eq!(duration.parts(), &[(DurationPart::Days, 2)]);
}
#[test]
fn constructs_weeks() {
let duration = Duration::weeks(2);
assert_eq!(duration.value(), 1_209_600);
assert_eq!(duration.parts(), &[(DurationPart::Weeks, 2)]);
}
#[test]
fn constructs_months() {
let duration = Duration::months(1);
assert_eq!(duration.value(), SECONDS_PER_MONTH);
assert_eq!(duration.parts(), &[(DurationPart::Months, 1)]);
}
#[test]
fn constructs_years() {
let duration = Duration::years(1);
assert_eq!(duration.value(), SECONDS_PER_YEAR);
assert_eq!(duration.parts(), &[(DurationPart::Years, 1)]);
}
#[test]
fn addition_combines_parts() {
let duration = Duration::hours(1) + Duration::minutes(30);
assert_eq!(duration.value(), 5_400);
assert_eq!(
duration.parts(),
&[(DurationPart::Hours, 1), (DurationPart::Minutes, 30)]
);
}
#[test]
fn subtraction_negates_rhs_parts() {
let duration = Duration::days(2) - Duration::hours(12);
assert_eq!(duration.value(), 129_600);
assert_eq!(
duration.parts(),
&[(DurationPart::Days, 2), (DurationPart::Hours, -12)]
);
}
#[test]
fn multiplication_scales_parts() {
let duration = Duration::hours(2) * 3;
assert_eq!(duration, Duration::hours(6));
}
#[test]
fn division_rebuilds_from_seconds() {
let duration = Duration::days(1) / 24;
assert_eq!(duration, Duration::hours(1));
}
#[test]
fn division_by_zero_returns_zero_seconds() {
let duration = Duration::hours(1) / 0;
assert_eq!(duration.value(), 0);
assert!(duration.parts().is_empty());
}
#[test]
fn negation_negates_parts() {
let duration = -Duration::minutes(5);
assert_eq!(duration.value(), -300);
assert_eq!(duration.parts(), &[(DurationPart::Minutes, -5)]);
}
#[test]
fn conversions_return_expected_values() {
let duration = Duration::days(1);
assert_eq!(duration.in_seconds(), 86_400);
assert_eq!(duration.in_minutes(), 1_440.0);
assert_eq!(duration.in_hours(), 24.0);
assert_eq!(duration.in_days(), 1.0);
}
#[test]
fn composite_duration_retains_ordered_parts() {
let duration = Duration::minutes(30) + Duration::hours(1) + Duration::seconds(15);
assert_eq!(
duration.parts(),
&[
(DurationPart::Hours, 1),
(DurationPart::Minutes, 30),
(DurationPart::Seconds, 15),
]
);
}
#[test]
fn zero_duration_formats_as_zero_seconds() {
let duration = Duration::new(Vec::new());
assert_eq!(duration.value(), 0);
assert_eq!(duration.to_string(), "0 seconds");
assert_eq!(duration.iso8601(), "PT0S");
}
#[test]
fn ago_is_approximately_in_the_past() {
let now = Utc::now();
let ago = Duration::seconds(1).ago();
let delta = now.signed_duration_since(ago).num_seconds();
assert!((0..=2).contains(&delta));
}
#[test]
fn from_now_is_approximately_in_the_future() {
let now = Utc::now();
let future = Duration::seconds(1).from_now();
let delta = future.signed_duration_since(now).num_seconds();
assert!((0..=2).contains(&delta));
}
#[test]
fn since_adds_seconds_to_time() {
let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
let result = Duration::minutes(2).since(time);
assert_eq!(result, time + ChronoDuration::minutes(2));
}
#[test]
fn until_subtracts_seconds_from_time() {
let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
let result = Duration::minutes(2).until(time);
assert_eq!(result, time - ChronoDuration::minutes(2));
}
#[test]
fn iso8601_formats_date_and_time_parts() {
let duration =
Duration::days(1) + Duration::hours(2) + Duration::minutes(3) + Duration::seconds(4);
assert_eq!(duration.iso8601(), "P1DT2H3M4S");
}
#[test]
fn iso8601_uses_weeks_when_alone() {
assert_eq!(Duration::weeks(1).iso8601(), "P1W");
}
#[test]
fn iso8601_converts_weeks_when_mixed() {
let duration = Duration::years(1) + Duration::weeks(1);
assert_eq!(duration.iso8601(), "P1Y7D");
}
#[test]
fn iso8601_preserves_negative_parts() {
let duration = Duration::months(6) - Duration::days(2);
assert_eq!(duration.iso8601(), "P6M-2D");
}
#[test]
fn to_std_converts_positive_values() {
assert_eq!(
Duration::seconds(5).to_std(),
std::time::Duration::from_secs(5)
);
}
#[test]
fn to_std_clamps_negative_values_to_zero() {
assert_eq!(Duration::seconds(-5).to_std(), std::time::Duration::ZERO);
}
#[test]
fn display_is_human_readable() {
let duration = Duration::years(1) + Duration::months(2) + Duration::days(1);
assert_eq!(duration.to_string(), "1 year, 2 months, and 1 day");
}
#[test]
fn display_handles_negative_parts() {
let duration = Duration::months(6) - Duration::days(2);
assert_eq!(duration.to_string(), "6 months and -2 days");
}
#[test]
fn new_normalizes_and_orders_parts() {
let duration = Duration::new(vec![
(DurationPart::Minutes, 2),
(DurationPart::Hours, 1),
(DurationPart::Minutes, -1),
(DurationPart::Seconds, 0),
]);
assert_eq!(duration.value(), 3_660);
assert_eq!(
duration.parts(),
&[(DurationPart::Hours, 1), (DurationPart::Minutes, 1)]
);
assert_eq!(duration, Duration::hours(1) + Duration::minutes(1));
}
#[test]
fn zero_amount_parts_normalize_to_zero_duration() {
let duration = Duration::days(0);
assert_eq!(duration.value(), 0);
assert!(duration.parts().is_empty());
assert_eq!(duration.to_string(), "0 seconds");
assert_eq!(duration.iso8601(), "PT0S");
}
#[test]
fn multiplication_by_negative_scalar_negates_parts() {
let duration = Duration::days(2) * -3;
assert_eq!(duration.value(), -518_400);
assert_eq!(duration.parts(), &[(DurationPart::Days, -6)]);
}
#[test]
fn division_of_negative_duration_rebuilds_negative_parts() {
let duration = Duration::hours(-1) / 2;
assert_eq!(duration.value(), -1_800);
assert_eq!(duration.parts(), &[(DurationPart::Minutes, -30)]);
}
#[test]
fn since_and_until_handle_zero_and_negative_durations() {
let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
assert_eq!(Duration::seconds(0).since(time), time);
assert_eq!(Duration::seconds(0).until(time), time);
let duration = Duration::minutes(-2);
assert_eq!(duration.since(time), time - ChronoDuration::minutes(2));
assert_eq!(duration.until(time), time + ChronoDuration::minutes(2));
}
#[test]
fn iso8601_formats_year_month_and_negative_time_parts() {
let duration =
Duration::years(1) + Duration::months(1) + Duration::days(1) + Duration::hours(1);
assert_eq!(duration.iso8601(), "P1Y1M1DT1H");
let negative = Duration::years(1) - Duration::days(1) - Duration::seconds(1);
assert_eq!(negative.iso8601(), "P1Y-1DT-1S");
}
#[test]
fn display_formats_single_and_two_part_durations() {
assert_eq!(Duration::weeks(1).to_string(), "1 week");
assert_eq!(
(Duration::months(1) + Duration::days(1)).to_string(),
"1 month and 1 day"
);
}
#[test]
fn new_orders_parts_from_largest_to_smallest_units() {
let duration = Duration::new(vec![
(DurationPart::Seconds, 5),
(DurationPart::Years, 1),
(DurationPart::Days, 2),
]);
assert_eq!(
duration.parts(),
&[
(DurationPart::Years, 1),
(DurationPart::Days, 2),
(DurationPart::Seconds, 5),
]
);
}
#[test]
fn new_cancels_matching_parts_that_sum_to_zero() {
let duration = Duration::new(vec![
(DurationPart::Minutes, 5),
(DurationPart::Minutes, -5),
]);
assert_eq!(duration.value(), 0);
assert!(duration.parts().is_empty());
}
#[test]
fn addition_normalizes_matching_parts() {
let duration = Duration::minutes(15) + Duration::minutes(45);
assert_eq!(duration.value(), 3_600);
assert_eq!(duration.parts(), &[(DurationPart::Minutes, 60)]);
}
#[test]
fn subtraction_normalizes_matching_parts() {
let duration = Duration::days(2) - Duration::days(1);
assert_eq!(duration.value(), 86_400);
assert_eq!(duration.parts(), &[(DurationPart::Days, 1)]);
}
#[test]
fn subtraction_of_identical_parts_produces_zero_duration() {
let duration = Duration::hours(1) - Duration::hours(1);
assert_eq!(duration.value(), 0);
assert!(duration.parts().is_empty());
}
#[test]
fn negation_flips_each_part_in_composite_duration() {
let duration = -(Duration::days(1) + Duration::hours(2));
assert_eq!(
duration.parts(),
&[(DurationPart::Days, -1), (DurationPart::Hours, -2)]
);
assert_eq!(duration.value(), -93_600);
}
#[test]
#[allow(clippy::erasing_op)]
fn multiplication_by_zero_returns_zero_duration() {
let duration = (Duration::days(1) + Duration::hours(2)) * 0;
assert_eq!(duration.value(), 0);
assert!(duration.parts().is_empty());
}
#[test]
fn multiplication_scales_composite_parts() {
let duration = (Duration::days(1) + Duration::hours(2)) * 2;
assert_eq!(
duration.parts(),
&[(DurationPart::Days, 2), (DurationPart::Hours, 4)]
);
assert_eq!(duration.value(), 187_200);
}
#[test]
fn division_truncates_fractional_seconds() {
let duration = Duration::seconds(5) / 2;
assert_eq!(duration.value(), 2);
assert_eq!(duration.parts(), &[(DurationPart::Seconds, 2)]);
}
#[test]
fn division_of_weeks_decomposes_into_days_and_hours() {
let duration = Duration::weeks(1) / 2;
assert_eq!(duration.value(), 302_400);
assert_eq!(
duration.parts(),
&[(DurationPart::Days, 3), (DurationPart::Hours, 12)]
);
}
#[test]
fn in_minutes_handles_negative_durations() {
assert_eq!(Duration::seconds(-90).in_minutes(), -1.5);
}
#[test]
fn in_hours_handles_partial_days() {
assert_eq!(Duration::minutes(90).in_hours(), 1.5);
}
#[test]
fn in_days_handles_negative_hours() {
assert_eq!(Duration::hours(-12).in_days(), -0.5);
}
#[test]
fn since_adds_composite_duration_value_to_time() {
let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
let duration = Duration::days(1) + Duration::minutes(90);
assert_eq!(duration.since(time), time + ChronoDuration::seconds(91_800));
}
#[test]
fn until_subtracts_composite_duration_value_from_time() {
let time = DateTime::from_timestamp(1_700_000_000, 0).expect("valid timestamp");
let duration = Duration::days(1) + Duration::minutes(90);
assert_eq!(duration.until(time), time - ChronoDuration::seconds(91_800));
}
#[test]
fn iso8601_formats_minutes_only() {
assert_eq!(Duration::minutes(5).iso8601(), "PT5M");
}
#[test]
fn iso8601_formats_negative_seconds_only() {
assert_eq!(Duration::seconds(-5).iso8601(), "PT-5S");
}
#[test]
fn iso8601_formats_negative_weeks_only() {
assert_eq!(Duration::weeks(-2).iso8601(), "P-2W");
}
#[test]
fn iso8601_converts_mixed_weeks_to_days() {
let duration = Duration::weeks(1) + Duration::days(2) + Duration::hours(3);
assert_eq!(duration.iso8601(), "P9DT3H");
}
#[test]
fn to_std_preserves_composite_positive_values() {
assert_eq!(
(Duration::days(1) + Duration::seconds(2)).to_std(),
std::time::Duration::from_secs(86_402)
);
}
#[test]
fn display_handles_negative_single_part_singular() {
assert_eq!(Duration::seconds(-1).to_string(), "-1 second");
}
#[test]
fn display_formats_three_part_durations_with_commas() {
assert_eq!(
(Duration::weeks(1) + Duration::days(2) + Duration::hours(3)).to_string(),
"1 week, 2 days, and 3 hours"
);
}
#[test]
fn equality_is_sensitive_to_representation_even_when_values_match() {
assert_eq!(Duration::minutes(60).value(), Duration::hours(1).value());
assert_ne!(Duration::minutes(60), Duration::hours(1));
}
#[test]
fn zero_duration_ago_and_from_now_stay_close_to_now() {
let now = Utc::now();
let past = Duration::seconds(0).ago();
let future = Duration::seconds(0).from_now();
assert!(now.signed_duration_since(past).num_seconds().abs() <= 1);
assert!(future.signed_duration_since(now).num_seconds().abs() <= 1);
}
}