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