Skip to main content

ocpi_tariffs/
duration.rs

1//! The OCPI spec represents some durations as fractional hours, where this crate represents all
2//! durations using [`TimeDelta`]. The [`ToDuration`] and [`ToHoursDecimal`] traits can be used to
3//! convert a [`TimeDelta`] into a [`Decimal`] and vice versa.
4
5#[cfg(test)]
6pub(crate) mod test;
7
8#[cfg(test)]
9mod test_from_schema;
10
11#[cfg(test)]
12mod test_hour_decimal;
13
14use std::fmt;
15
16use chrono::TimeDelta;
17use num_traits::ToPrimitive as _;
18use rust_decimal::Decimal;
19use rust_decimal_macros::dec;
20
21use crate::{
22    json,
23    number::{self, int_error_kind_as_str, FromDecimal as _, RoundDecimal as _},
24    schema,
25    warning::{self, IntoCaveat as _},
26    Cost, FromSchema, Money, SaturatingAdd, SaturatingSub, Verdict,
27};
28
29pub(crate) const SECS_IN_MIN: i64 = 60;
30pub(crate) const MINS_IN_HOUR: i64 = 60;
31pub(crate) const MILLIS_IN_SEC: i64 = 1000;
32const NANOS_IN_HOUR: Decimal = dec!(36e11);
33const SECONDS_IN_HOUR: Decimal = dec!(3600);
34
35/// The warnings possible when parsing or linting a duration.
36#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
37pub enum Warning {
38    /// Unable to parse the duration.
39    Invalid(&'static str),
40
41    /// The JSON value given is not an int.
42    InvalidType {
43        /// The JSON kind found where an int was expected.
44        type_found: json::ValueKind,
45    },
46
47    /// A numeric overflow occurred while creating a duration.
48    Overflow,
49}
50
51impl Warning {
52    fn invalid_type(elem: &json::Element<'_>) -> Self {
53        Self::InvalidType {
54            type_found: elem.value().kind(),
55        }
56    }
57}
58
59impl fmt::Display for Warning {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Invalid(err) => write!(f, "Unable to parse the duration: {err}"),
63            Self::InvalidType { type_found } => {
64                write!(f, "The value should be an int but is `{type_found}`")
65            }
66            Self::Overflow => f.write_str("A numeric overflow occurred while creating a duration"),
67        }
68    }
69}
70
71impl crate::Warning for Warning {
72    fn id(&self) -> warning::Id {
73        match self {
74            Self::Invalid(_) => warning::Id::from_static("invalid"),
75            Self::InvalidType { type_found } => {
76                warning::Id::from_string(format!("invalid_type({type_found})"))
77            }
78            Self::Overflow => warning::Id::from_static("overflow"),
79        }
80    }
81}
82
83impl From<rust_decimal::Error> for Warning {
84    fn from(_: rust_decimal::Error) -> Self {
85        Self::Overflow
86    }
87}
88
89/// Convert a `TimeDelta` into a `Decimal` based amount of hours.
90pub trait ToHoursDecimal {
91    /// Return a `Decimal` based amount of hours.
92    fn to_hours_dec(&self) -> Decimal;
93    /// Return a `Decimal` based amount of hours, in the precision specified
94    /// by OCPI.
95    ///
96    /// Note this should only be used for output, for intermediate results use
97    /// `to_hours_dec`, to avoid rounding.
98    fn to_hours_dec_in_ocpi_precision(&self) -> Decimal {
99        self.to_hours_dec().round_to_ocpi_scale()
100    }
101}
102
103impl ToHoursDecimal for TimeDelta {
104    fn to_hours_dec(&self) -> Decimal {
105        let num_sec = Decimal::from(self.num_seconds());
106        let num_nano = Decimal::from(self.subsec_nanos());
107        let sec_part = num_sec.checked_div(SECONDS_IN_HOUR).unwrap_or(Decimal::MAX);
108        let nano_part = num_nano.checked_div(NANOS_IN_HOUR).unwrap_or(Decimal::MAX);
109        sec_part.checked_add(nano_part).unwrap_or(Decimal::MAX)
110    }
111}
112
113/// Convert a `Decimal` amount of hours to a `TimeDelta`.
114pub trait ToDuration {
115    /// Convert a `Decimal` amount of hours to a `TimeDelta`.
116    fn to_duration(&self) -> TimeDelta;
117}
118
119impl ToDuration for Decimal {
120    /// Convert a `Decimal` amount of hours to a `TimeDelta`.
121    ///
122    /// Round to the maximal precision of `TimeDelta` which is nanoseconds.
123    fn to_duration(&self) -> TimeDelta {
124        let nanos = self
125            .saturating_mul(NANOS_IN_HOUR)
126            // Any sub-nanosecond fraction is preserved correctly.
127            // A fraction >= 0.5 nanosecond still rounds up, and a fraction < 0.5 nanosecond
128            // rounds down. Both of which represent rounding errors far smaller than any
129            // meaningful time unit.
130            .round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
131            .to_i64()
132            .unwrap_or(i64::MAX);
133        TimeDelta::nanoseconds(nanos)
134    }
135}
136
137/// A `TimeDelta` can't be parsed from JSON directly, you must first decide which unit of time to
138/// parse it as. The `Seconds` type is used to parse the JSON Element as an integer amount of seconds.
139pub(crate) struct Seconds(TimeDelta);
140
141impl number::IsZero for Seconds {
142    fn is_zero(&self) -> bool {
143        self.0.is_zero()
144    }
145}
146
147/// Once the `TimeDelta` has been parsed as seconds you can extract it from the newtype.
148impl From<Seconds> for TimeDelta {
149    fn from(value: Seconds) -> Self {
150        value.0
151    }
152}
153
154impl fmt::Debug for Seconds {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.debug_tuple("Seconds")
157            .field(&self.0.num_seconds())
158            .finish()
159    }
160}
161
162impl fmt::Display for Seconds {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        write!(f, "{}", self.0.num_seconds())
165    }
166}
167
168/// Parse a seconds `TimeDelta` from JSON.
169///
170/// Used to parse the `min_duration` and `max_duration` fields of the tariff Restriction.
171///
172/// * See: [OCPI spec 2.2.1: Tariff Restriction](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>).
173/// * See: [OCPI spec 2.1.1: Tariff Restriction](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#45-tariffrestrictions-class>).
174impl<'buf> FromSchema<'buf, schema::Number<'buf>> for Seconds {
175    type Warning = Warning;
176
177    fn from_schema(source: &schema::Number<'buf>) -> Verdict<Self, Self::Warning> {
178        let warnings = warning::Set::new();
179
180        // `Seconds` only accepts a JSON number, not a number encoded as a string. The
181        // schema already proved the literal form is a valid JSON number, so its `digits`
182        // are read directly; a string-encoded value is the wrong kind.
183        let (elem, digits) = match source {
184            schema::Number::Number { elem, digits } => (elem, *digits),
185            schema::Number::StringEncoded { elem, .. } => {
186                return warnings.bail(elem, Warning::invalid_type(elem));
187            }
188        };
189
190        // We only support positive durations in an OCPI object.
191        let seconds = match digits.parse::<u64>() {
192            Ok(n) => n,
193            Err(err) => {
194                return warnings.bail(elem, Warning::Invalid(int_error_kind_as_str(*err.kind())));
195            }
196        };
197
198        // Then we convert the positive duration to an i64 as that is how `chrono::TimeDelta`
199        // represents seconds.
200        let Ok(seconds) = i64::try_from(seconds) else {
201            return warnings.bail(
202                elem,
203                Warning::Invalid("The duration value is larger than an i64 can represent."),
204            );
205        };
206        let dt = TimeDelta::seconds(seconds);
207
208        Ok(Seconds(dt).into_caveat(warnings))
209    }
210}
211
212/// A duration of time has a cost.
213impl Cost for TimeDelta {
214    fn cost(&self, money: Money) -> Money {
215        let cost = self.to_hours_dec().saturating_mul(Decimal::from(money));
216        Money::from_decimal(cost)
217    }
218}
219
220impl SaturatingAdd for TimeDelta {
221    fn saturating_add(self, other: TimeDelta) -> TimeDelta {
222        self.checked_add(&other).unwrap_or(TimeDelta::MAX)
223    }
224}
225
226impl SaturatingSub for TimeDelta {
227    fn saturating_sub(self, other: TimeDelta) -> TimeDelta {
228        self.checked_sub(&other).unwrap_or_else(TimeDelta::zero)
229    }
230}
231
232/// A debug helper trait to display durations as `HH:MM:SS`.
233#[expect(clippy::allow_attributes, reason = "used during debug sessions")]
234#[allow(dead_code, reason = "used during debug sessions")]
235pub(crate) trait AsHms {
236    /// Return a `Hms` formatter, that formats a `TimeDelta` into a `String` in `HH::MM::SS` format.
237    fn as_hms(&self) -> Hms;
238}
239
240impl AsHms for TimeDelta {
241    fn as_hms(&self) -> Hms {
242        Hms(*self)
243    }
244}
245
246impl AsHms for Decimal {
247    /// Return a `Hms` formatter, that formats a `TimeDelta` into a `String` in `HH::MM::SS` format.
248    fn as_hms(&self) -> Hms {
249        Hms(self.to_duration())
250    }
251}
252
253/// A utility for deserializing and displaying durations in `HH::MM::SS` format.
254#[derive(Copy, Clone)]
255pub struct Hms(pub TimeDelta);
256
257/// The Debug and Display impls are the same for `Hms` as I never want to see the `TimeDelta` representation.
258impl fmt::Debug for Hms {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        fmt::Display::fmt(self, f)
261    }
262}
263
264impl fmt::Display for Hms {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        let duration = self.0;
267        let seconds = duration.num_seconds();
268
269        // If the duration is negative write a single minus sign.
270        if seconds.is_negative() {
271            f.write_str("-")?;
272        }
273
274        // Avoid minus signs in the output.
275        let seconds_total = seconds.abs();
276
277        let seconds = seconds_total % SECS_IN_MIN;
278        let minutes = (seconds_total / SECS_IN_MIN) % MINS_IN_HOUR;
279        let hours = seconds_total / (SECS_IN_MIN * MINS_IN_HOUR);
280
281        write!(f, "{hours:0>2}:{minutes:0>2}:{seconds:0>2}")
282    }
283}
284
285#[cfg(test)]
286mod test_hms {
287    use chrono::TimeDelta;
288
289    use super::Hms;
290
291    #[test]
292    fn should_display_seconds() {
293        assert_eq!(Hms(TimeDelta::seconds(0)).to_string(), "00:00:00");
294        assert_eq!(Hms(TimeDelta::seconds(59)).to_string(), "00:00:59");
295    }
296
297    #[test]
298    fn should_display_minutes() {
299        assert_eq!(Hms(TimeDelta::seconds(60)).to_string(), "00:01:00");
300        assert_eq!(Hms(TimeDelta::seconds(3600)).to_string(), "01:00:00");
301    }
302
303    #[test]
304    fn should_display_hours() {
305        assert_eq!(Hms(TimeDelta::minutes(60)).to_string(), "01:00:00");
306        assert_eq!(Hms(TimeDelta::minutes(3600)).to_string(), "60:00:00");
307    }
308
309    #[test]
310    fn should_display_hours_mins_secs() {
311        assert_eq!(Hms(TimeDelta::seconds(87030)).to_string(), "24:10:30");
312    }
313}
314
315#[cfg(test)]
316mod test_to_hours_decimal {
317    use chrono::TimeDelta;
318    use rust_decimal_macros::dec;
319
320    use crate::ToHoursDecimal as _;
321
322    #[test]
323    fn to_hours_dec_should_be_correct() {
324        let actual = TimeDelta::hours(1).to_hours_dec();
325        assert_eq!(actual, dec!(1.0));
326
327        let actual = TimeDelta::seconds(3960).to_hours_dec();
328        assert_eq!(actual, dec!(1.1));
329
330        let actual = TimeDelta::seconds(360).to_hours_dec();
331        assert_eq!(actual, dec!(0.1));
332
333        let actual = TimeDelta::seconds(36).to_hours_dec();
334        assert_eq!(actual, dec!(0.01));
335
336        let actual = TimeDelta::milliseconds(36).to_hours_dec();
337        assert_eq!(actual, dec!(0.00001));
338
339        let actual = TimeDelta::nanoseconds(1).to_hours_dec();
340        assert_eq!(actual, dec!(2.777777777777778e-13));
341    }
342}
343
344#[cfg(test)]
345mod test_to_duration {
346    use chrono::TimeDelta;
347    use rust_decimal_macros::dec;
348
349    use crate::ToDuration as _;
350
351    #[test]
352    fn to_duration_should_be_correct() {
353        let actual = dec!(1.0).to_duration();
354        assert_eq!(actual, TimeDelta::hours(1));
355
356        let actual = dec!(1.1).to_duration();
357        assert_eq!(actual, TimeDelta::seconds(3960));
358
359        let actual = dec!(0.1).to_duration();
360        assert_eq!(actual, TimeDelta::seconds(360));
361
362        let actual = dec!(1e-14).to_duration();
363        assert_eq!(actual, TimeDelta::zero());
364
365        let actual = dec!(2.777e-13).to_duration();
366        assert_eq!(actual, TimeDelta::nanoseconds(1));
367    }
368}