Skip to main content

ferrobridge_fhirpath/temporal/
arithmetic.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! Adding a time-valued quantity to a date, a datetime or a time (N1 §6.7).
5//!
6//! Calendar semantics apply above the second and definite ones at and below
7//! it. A quantity finer than the value's precision is first converted to that
8//! precision by the factors of N1 §5.5.7 and truncated, so `@2014 + 23
9//! months` is `@2015`; above the second the decimal part of the quantity is
10//! dropped. A day that the resulting month lacks becomes the month's last.
11
12use rust_decimal::Decimal;
13use rust_decimal::prelude::ToPrimitive;
14
15use super::{Clock, Date, DateTime, Precision, Seconds, Time};
16use crate::quantity::Calendar;
17
18/// Why a date or time could not be moved by a quantity.
19#[derive(Debug, Clone, thiserror::Error)]
20pub enum ArithmeticError {
21    /// The unit does not apply to the value, as an hour does not to a date
22    /// (N1 §6.7.1).
23    #[error("a {value} cannot be moved by a quantity in {unit}")]
24    Unit {
25        /// The kind of value.
26        value: &'static str,
27        /// The unit's keyword.
28        unit: &'static str,
29    },
30    /// The result leaves the range 0001 to 9999 (N1 §6.7).
31    #[error("the result lies outside the years 0001 to 9999")]
32    Range,
33    /// The calendar arithmetic itself failed, as for a span past its limits.
34    #[error("the calendar arithmetic failed")]
35    Calendar {
36        /// The calendar library's failure.
37        #[source]
38        source: jiff::Error,
39    },
40}
41
42/// The calendar keyword matching a precision; a second with a fraction is a
43/// millisecond.
44const fn step(precision: Precision, fraction: bool) -> Calendar {
45    match precision {
46        Precision::Year => Calendar::Year,
47        Precision::Month => Calendar::Month,
48        Precision::Day => Calendar::Day,
49        Precision::Hour => Calendar::Hour,
50        Precision::Minute => Calendar::Minute,
51        Precision::Second if fraction => Calendar::Millisecond,
52        Precision::Second => Calendar::Second,
53    }
54}
55
56/// The whole number of `unit` steps to apply to a value whose finest
57/// precision is `finest`: the quantity converted to that precision when it is
58/// finer, then truncated (N1 §6.7.1).
59fn steps(
60    amount: Decimal,
61    unit: Calendar,
62    finest: Calendar,
63) -> Result<(i64, Calendar), ArithmeticError> {
64    let (amount, unit) = if unit == Calendar::Week {
65        (
66            amount
67                .checked_mul(Decimal::from(7))
68                .ok_or(ArithmeticError::Range)?,
69            Calendar::Day,
70        )
71    } else {
72        (amount, unit)
73    };
74    let (amount, unit) = if unit > finest {
75        (
76            unit.convert(amount, finest).ok_or(ArithmeticError::Range)?,
77            finest,
78        )
79    } else if unit == Calendar::Second && finest == Calendar::Millisecond {
80        (
81            amount
82                .checked_mul(Decimal::from(1000))
83                .ok_or(ArithmeticError::Range)?,
84            Calendar::Millisecond,
85        )
86    } else {
87        (amount, unit)
88    };
89    let whole = amount.trunc().to_i64().ok_or(ArithmeticError::Range)?;
90    Ok((whole, unit))
91}
92
93/// A span of `count` steps of `unit`.
94fn span(count: i64, unit: Calendar) -> Result<jiff::Span, ArithmeticError> {
95    let span = jiff::Span::new();
96    match unit {
97        Calendar::Year => span.try_years(count),
98        Calendar::Month => span.try_months(count),
99        Calendar::Week => span.try_weeks(count),
100        Calendar::Day => span.try_days(count),
101        Calendar::Hour => span.try_hours(count),
102        Calendar::Minute => span.try_minutes(count),
103        Calendar::Second => span.try_seconds(count),
104        Calendar::Millisecond => span.try_milliseconds(count),
105    }
106    .map_err(|source| ArithmeticError::Calendar { source })
107}
108
109/// The date moved by whole years, months or days.
110fn move_date(date: Date, count: i64, unit: Calendar) -> Result<Date, ArithmeticError> {
111    let civil = jiff::civil::Date::new(date.year, date.month.unwrap_or(1), date.day.unwrap_or(1))
112        .map_err(|source| ArithmeticError::Calendar { source })?;
113    let moved = civil
114        .checked_add(span(count, unit)?)
115        .map_err(|source| ArithmeticError::Calendar { source })?;
116    in_range(Date {
117        year: moved.year(),
118        month: date.month.map(|_| moved.month()),
119        day: date.day.map(|_| moved.day()),
120    })
121}
122
123/// The date when its year lies in 0001 to 9999.
124fn in_range(date: Date) -> Result<Date, ArithmeticError> {
125    if (1..=9999).contains(&date.year) {
126        Ok(date)
127    } else {
128        Err(ArithmeticError::Range)
129    }
130}
131
132/// The nanoseconds a clock's fraction carries.
133fn nanos(clock: &Clock) -> i32 {
134    clock
135        .second
136        .and_then(|second| second.fraction)
137        .map_or(0, |(nanos, _)| nanos)
138}
139
140/// The clock with the components it carries taken from `time`, a fraction
141/// widened to milliseconds when milliseconds were added.
142fn rewrite(clock: Clock, time: jiff::civil::Time, millis: bool) -> Clock {
143    Clock {
144        hour: time.hour(),
145        minute: clock.minute.map(|_| time.minute()),
146        second: clock.second.map(|second| Seconds {
147            whole: time.second(),
148            fraction: second.fraction.map(|(_, digits)| {
149                let digits = if millis { digits.max(3) } else { digits };
150                (time.subsec_nanosecond(), digits)
151            }),
152        }),
153    }
154}
155
156impl Date {
157    /// The date moved by `amount` of `unit` (N1 §6.7.1).
158    ///
159    /// # Errors
160    ///
161    /// Returns [`ArithmeticError::Unit`] for a unit below the day and
162    /// [`ArithmeticError::Range`] for a result outside 0001 to 9999.
163    pub fn add(self, amount: Decimal, unit: Calendar) -> Result<Self, ArithmeticError> {
164        if unit > Calendar::Day {
165            return Err(ArithmeticError::Unit {
166                value: "date",
167                unit: unit.keyword(),
168            });
169        }
170        let (count, unit) = steps(amount, unit, step(self.precision(), false))?;
171        move_date(self, count, unit)
172    }
173}
174
175impl DateTime {
176    /// The datetime moved by `amount` of `unit` (N1 §6.7.1), keeping its
177    /// precision and offset.
178    ///
179    /// # Errors
180    ///
181    /// Returns [`ArithmeticError::Range`] for a result outside 0001 to 9999.
182    pub fn add(&self, amount: Decimal, unit: Calendar) -> Result<Self, ArithmeticError> {
183        let fraction = self
184            .clock
185            .and_then(|clock| clock.second)
186            .is_some_and(|second| second.fraction.is_some());
187        let (count, unit) = steps(amount, unit, step(self.precision(), fraction))?;
188        let Some(clock) = self.clock else {
189            return Ok(Self {
190                date: move_date(self.date, count, unit)?,
191                ..*self
192            });
193        };
194        let civil = jiff::civil::DateTime::new(
195            self.date.year,
196            self.date.month.unwrap_or(1),
197            self.date.day.unwrap_or(1),
198            clock.hour,
199            clock.minute.unwrap_or(0),
200            clock.second.map_or(0, |second| second.whole),
201            nanos(&clock),
202        )
203        .map_err(|source| ArithmeticError::Calendar { source })?;
204        let moved = civil
205            .checked_add(span(count, unit)?)
206            .map_err(|source| ArithmeticError::Calendar { source })?;
207        Ok(Self {
208            date: in_range(Date {
209                year: moved.year(),
210                month: Some(moved.month()),
211                day: Some(moved.day()),
212            })?,
213            clock: Some(rewrite(clock, moved.time(), unit == Calendar::Millisecond)),
214            offset: self.offset,
215        })
216    }
217}
218
219impl Time {
220    /// The time moved by `amount` of `unit` (N1 §6.7.1).
221    ///
222    /// NOTE: N1 §6.7 does not say what happens past midnight; no specification
223    /// governs this: our own design, the time wraps around the day.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`ArithmeticError::Unit`] for a unit above the hour.
228    pub fn add(&self, amount: Decimal, unit: Calendar) -> Result<Self, ArithmeticError> {
229        if unit < Calendar::Hour {
230            return Err(ArithmeticError::Unit {
231                value: "time",
232                unit: unit.keyword(),
233            });
234        }
235        let fraction = self
236            .clock
237            .second
238            .is_some_and(|second| second.fraction.is_some());
239        let (count, unit) = steps(amount, unit, step(self.precision(), fraction))?;
240        let civil = jiff::civil::Time::new(
241            self.clock.hour,
242            self.clock.minute.unwrap_or(0),
243            self.clock.second.map_or(0, |second| second.whole),
244            nanos(&self.clock),
245        )
246        .map_err(|source| ArithmeticError::Calendar { source })?;
247        let moved = civil.wrapping_add(span(count, unit)?);
248        Ok(Self {
249            clock: rewrite(self.clock, moved, unit == Calendar::Millisecond),
250        })
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use rust_decimal::Decimal;
257
258    use super::super::{Date, DateTime};
259    use crate::quantity::Calendar;
260
261    fn date(text: &str) -> Date {
262        Date::parse(text).expect("the date should read")
263    }
264
265    #[test]
266    fn partial_dates_take_the_quantity_at_their_precision() {
267        let add = |text: &str, n: i64, unit| {
268            date(text)
269                .add(Decimal::from(n), unit)
270                .expect("the sum should exist")
271                .to_string()
272        };
273        assert_eq!(add("2014", 24, Calendar::Month), "2016");
274        assert_eq!(add("2014", 23, Calendar::Month), "2015");
275        assert_eq!(add("2016", 365, Calendar::Day), "2017");
276        assert_eq!(add("2019-03-01", 24, Calendar::Month), "2021-03-01");
277        assert_eq!(add("1973-12-25", 1, Calendar::Week), "1974-01-01");
278    }
279
280    #[test]
281    fn a_missing_day_becomes_the_last_of_the_month() {
282        let sum = date("2015-01-31").add(Decimal::ONE, Calendar::Month);
283        assert_eq!(
284            sum.ok().map(|d| d.to_string()),
285            Some(String::from("2015-02-28"))
286        );
287    }
288
289    #[test]
290    fn a_datetime_keeps_its_offset_and_fraction() {
291        let value = DateTime::parse("1973-12-25T00:00:00.000+10:00").expect("datetime");
292        let sum = value.add(Decimal::from(10), Calendar::Millisecond);
293        assert_eq!(
294            sum.ok().map(|d| d.to_string()),
295            Some(String::from("1973-12-25T00:00:00.010+10:00"))
296        );
297    }
298
299    #[test]
300    fn an_hour_does_not_move_a_date() {
301        assert!(
302            date("2015-01-01")
303                .add(Decimal::ONE, Calendar::Hour)
304                .is_err()
305        );
306    }
307}