use chrono::{DateTime, TimeDelta, Utc};
pub use expiration_date::ExpirationDate;
pub use expiration_date::error::ExpirationDateError;
use positive::Positive;
const FIXED_BASE_HOUR: u32 = 18;
const FIXED_BASE_MINUTE: u32 = 30;
#[cold]
#[inline(never)]
fn unrepresentable(days: Positive) -> ExpirationDateError {
ExpirationDateError::ArithmeticOverflow(format!(
"expiration of {days} days is outside the representable calendar range"
))
}
fn checked_offset_days(
base: DateTime<Utc>,
days: Positive,
) -> Result<DateTime<Utc>, ExpirationDateError> {
let whole_days = i64::try_from(days)?;
let span = TimeDelta::try_days(whole_days).ok_or_else(|| unrepresentable(days))?;
base.checked_add_signed(span)
.ok_or_else(|| unrepresentable(days))
}
fn relative_base() -> DateTime<Utc> {
ExpirationDate::get_reference_datetime().unwrap_or_else(Utc::now)
}
fn fixed_base() -> Option<DateTime<Utc>> {
Utc::now()
.date_naive()
.and_hms_opt(FIXED_BASE_HOUR, FIXED_BASE_MINUTE, 0)
.map(|naive| DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
}
#[must_use = "the resolved instant carries the failure the panicking form would have aborted on"]
pub fn resolve_expiration_date(
expiration_date: &ExpirationDate,
) -> Result<DateTime<Utc>, ExpirationDateError> {
match expiration_date {
ExpirationDate::Days(days) => checked_offset_days(relative_base(), *days),
ExpirationDate::DateTime(datetime) => Ok(*datetime),
}
}
pub fn reject_unrepresentable_expiration(
expiration_date: &ExpirationDate,
) -> Result<(), ExpirationDateError> {
let ExpirationDate::Days(days) = expiration_date else {
return Ok(());
};
checked_offset_days(relative_base(), *days)?;
let fixed = fixed_base().ok_or_else(|| unrepresentable(*days))?;
checked_offset_days(fixed, *days)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn pos(value: rust_decimal::Decimal) -> Positive {
Positive::new_decimal(value).unwrap_or(Positive::ZERO)
}
#[test]
fn test_resolve_expiration_date_days_matches_get_date() {
let expiration = ExpirationDate::Days(pos(dec!(30.0)));
let resolved = resolve_expiration_date(&expiration).expect("30 days is representable");
let reference = expiration.get_date().expect("30 days is representable");
assert_eq!(resolved.date_naive(), reference.date_naive());
}
#[test]
fn test_resolve_expiration_date_datetime_is_returned_unchanged() {
let instant = Utc::now();
let resolved = resolve_expiration_date(&ExpirationDate::DateTime(instant))
.expect("an explicit instant is always representable");
assert_eq!(resolved, instant);
}
#[test]
fn test_resolve_expiration_date_datetime_overflow_returns_error() {
let error = resolve_expiration_date(&ExpirationDate::Days(pos(dec!(1000000000.0))))
.expect_err("a billion days overflows the calendar");
assert!(matches!(error, ExpirationDateError::ArithmeticOverflow(_)));
}
#[test]
fn test_resolve_expiration_date_span_overflow_returns_error() {
let error = resolve_expiration_date(&ExpirationDate::Days(pos(dec!(1000000000000000.0))))
.expect_err("1e15 days overflows TimeDelta itself");
assert!(matches!(error, ExpirationDateError::ArithmeticOverflow(_)));
}
#[test]
fn test_resolve_expiration_date_beyond_i64_returns_error() {
let error = resolve_expiration_date(&ExpirationDate::Days(Positive::MAX))
.expect_err("Positive::MAX days does not fit an i64");
assert!(matches!(
error,
ExpirationDateError::ConversionError { .. } | ExpirationDateError::PositiveError(_)
));
}
#[test]
fn test_reject_unrepresentable_expiration_accepts_ordinary_horizons() {
for days in [dec!(0.0), dec!(1.0), dec!(30.0), dec!(3650.0)] {
reject_unrepresentable_expiration(&ExpirationDate::Days(pos(days)))
.expect("ordinary horizons are representable");
}
}
#[test]
fn test_reject_unrepresentable_expiration_rejects_billion_days() {
let error =
reject_unrepresentable_expiration(&ExpirationDate::Days(pos(dec!(1000000000.0))))
.expect_err("a billion days overflows the calendar");
assert!(matches!(error, ExpirationDateError::ArithmeticOverflow(_)));
}
#[test]
fn test_reject_unrepresentable_expiration_accepts_any_datetime() {
reject_unrepresentable_expiration(&ExpirationDate::DateTime(Utc::now()))
.expect("an explicit instant needs no calendar arithmetic");
}
}