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
5use std::{borrow::Cow, fmt};
6
7use chrono::TimeDelta;
8use num_traits::ToPrimitive as _;
9use rust_decimal::Decimal;
10
11use crate::{
12    into_caveat_all, json,
13    number::FromDecimal as _,
14    warning::{self, IntoCaveat as _},
15    Cost, Money, SaturatingAdd, SaturatingSub, Verdict,
16};
17
18pub(crate) const SECS_IN_MIN: i64 = 60;
19pub(crate) const MINS_IN_HOUR: i64 = 60;
20pub(crate) const MILLIS_IN_SEC: i64 = 1000;
21
22/// The warnings possible when parsing or linting a duration.
23#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
24pub enum WarningKind {
25    /// Unable to parse the duration.
26    Invalid(String),
27
28    /// The JSON value given is not an int.
29    InvalidType,
30}
31
32impl fmt::Display for WarningKind {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            WarningKind::Invalid(err) => write!(f, "Unable to parse the duration: {err}"),
36            WarningKind::InvalidType => write!(f, "The value should be a string."),
37        }
38    }
39}
40
41impl warning::Kind for WarningKind {
42    fn id(&self) -> Cow<'static, str> {
43        match self {
44            WarningKind::Invalid(_) => "invalid".into(),
45            WarningKind::InvalidType => "invalid_type".into(),
46        }
47    }
48}
49
50/// Possible errors when pricing a charge session.
51#[derive(Debug)]
52pub enum Error {
53    /// A numeric overflow occurred while creating a duration.
54    Overflow,
55}
56
57impl From<rust_decimal::Error> for Error {
58    fn from(_: rust_decimal::Error) -> Self {
59        Self::Overflow
60    }
61}
62
63impl std::error::Error for Error {}
64
65impl fmt::Display for Error {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::Overflow => f.write_str("A numeric overflow occurred while creating a duration"),
69        }
70    }
71}
72
73into_caveat_all!(TimeDelta, Seconds);
74
75/// Convert a `TimeDelta` into a `Decimal` based amount of hours.
76pub trait ToHoursDecimal {
77    /// Return a `Decimal` based amount of hours.
78    fn to_hours_dec(&self) -> Decimal;
79}
80
81/// Convert a `Decimal` amount of hours to a `TimeDelta`.
82pub trait ToDuration {
83    /// Convert a `Decimal` amount of hours to a `TimeDelta`.
84    fn to_duration(&self) -> TimeDelta;
85}
86
87impl ToHoursDecimal for TimeDelta {
88    fn to_hours_dec(&self) -> Decimal {
89        let div = Decimal::from(MILLIS_IN_SEC * SECS_IN_MIN * MINS_IN_HOUR);
90        let num = Decimal::from(self.num_milliseconds());
91        num.checked_div(div).unwrap_or(Decimal::MAX)
92    }
93}
94
95impl ToDuration for Decimal {
96    fn to_duration(&self) -> TimeDelta {
97        let factor = Decimal::from(MILLIS_IN_SEC * SECS_IN_MIN * MINS_IN_HOUR);
98        let millis = self.saturating_mul(factor).to_i64().unwrap_or(i64::MAX);
99        TimeDelta::milliseconds(millis)
100    }
101}
102
103/// A `TimeDelta` can't be parsed from JSON directly, you must first decide which unit of time to
104/// parse it as. The `Seconds` type is used to parse the JSON Element as an integer amount of seconds.
105pub(crate) struct Seconds(TimeDelta);
106
107/// Once the `TimeDelta` has been parsed as seconds you can extract it from the newtype.
108impl From<Seconds> for TimeDelta {
109    fn from(value: Seconds) -> Self {
110        value.0
111    }
112}
113
114/// Parse a seconds `TimeDelta` from JSON.
115///
116/// Used to parse the `min_duration` and `max_duration` fields of the tariff Restriction.
117///
118/// * 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>)
119/// * 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>)
120impl json::FromJson<'_, '_> for Seconds {
121    type WarningKind = WarningKind;
122
123    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
124        let mut warnings = warning::Set::new();
125        let Some(s) = elem.as_number_str() else {
126            warnings.with_elem(WarningKind::InvalidType, elem);
127            return Err(warnings);
128        };
129
130        // We only support positive durations in an OCPI object.
131        let seconds = match s.parse::<u64>() {
132            Ok(n) => n,
133            Err(err) => {
134                warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
135                return Err(warnings);
136            }
137        };
138
139        // Then we convert the positive duration to an i64 as that is how `chrono::TimeDelta`
140        // represents seconds.
141        let Ok(seconds) = i64::try_from(seconds) else {
142            warnings.with_elem(
143                WarningKind::Invalid(
144                    "The duration value is larger than an i64 can represent.".into(),
145                ),
146                elem,
147            );
148            return Err(warnings);
149        };
150        let dt = TimeDelta::seconds(seconds);
151
152        Ok(Seconds(dt).into_caveat(warnings))
153    }
154}
155
156/// A duration of time has a cost.
157impl Cost for TimeDelta {
158    fn cost(&self, money: Money) -> Money {
159        let cost = self.to_hours_dec().saturating_mul(Decimal::from(money));
160        Money::from_decimal(cost)
161    }
162}
163
164impl SaturatingAdd for TimeDelta {
165    fn saturating_add(self, other: TimeDelta) -> TimeDelta {
166        self.checked_add(&other).unwrap_or(TimeDelta::MAX)
167    }
168}
169
170impl SaturatingSub for TimeDelta {
171    fn saturating_sub(self, other: TimeDelta) -> TimeDelta {
172        self.checked_sub(&other).unwrap_or_else(TimeDelta::zero)
173    }
174}
175
176/// A debug helper trait to display durations as HH:MM:SS.
177#[allow(dead_code, reason = "used during debug sessions")]
178pub(crate) trait AsHms {
179    /// Return a `Hms` formatter, that formats a `TimeDelta` into a `String` in `HH::MM::SS` format.
180    fn as_hms(&self) -> Hms;
181}
182
183impl AsHms for TimeDelta {
184    fn as_hms(&self) -> Hms {
185        Hms(*self)
186    }
187}
188
189impl AsHms for Decimal {
190    /// Return a `Hms` formatter, that formats a `TimeDelta` into a `String` in `HH::MM::SS` format.
191    fn as_hms(&self) -> Hms {
192        Hms(self.to_duration())
193    }
194}
195
196/// A debug utility for displaying durations in `HH::MM::SS` format.
197pub(crate) struct Hms(pub TimeDelta);
198
199/// The Debug and Display impls are the same for `Hms` as I never want to see the `TimeDelta` representation.
200impl fmt::Debug for Hms {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        fmt::Display::fmt(self, f)
203    }
204}
205
206impl fmt::Display for Hms {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        let duration = self.0;
209        let seconds = duration.num_seconds();
210
211        // If the duration is negative write a single minus sign.
212        if seconds.is_negative() {
213            f.write_str("-")?;
214        }
215
216        // Avoid minus signs in the output.
217        let seconds = seconds.abs();
218
219        let seconds = seconds % SECS_IN_MIN;
220        let minutes = (seconds / SECS_IN_MIN) % MINS_IN_HOUR;
221        let hours = seconds / (SECS_IN_MIN * MINS_IN_HOUR);
222
223        write!(f, "{hours:0>2}:{minutes:0>2}:{seconds:0>2}")
224    }
225}
226
227#[cfg(test)]
228mod test {
229    use chrono::TimeDelta;
230
231    use crate::test::ApproxEq;
232
233    use super::Error;
234
235    #[test]
236    const fn error_should_be_send_and_sync() {
237        const fn f<T: Send + Sync>() {}
238
239        f::<Error>();
240    }
241
242    impl ApproxEq for TimeDelta {
243        fn approx_eq(&self, other: &Self) -> bool {
244            const TOLERANCE: i64 = 3;
245            approx_eq_time_delta(*self, *other, TOLERANCE)
246        }
247    }
248
249    /// Approximately compare two `TimeDelta` values.
250    pub fn approx_eq_time_delta(a: TimeDelta, b: TimeDelta, tolerance_secs: i64) -> bool {
251        let diff = a.num_seconds() - b.num_seconds();
252        diff.abs() <= tolerance_secs
253    }
254}
255
256#[cfg(test)]
257mod hour_decimal_tests {
258    use chrono::TimeDelta;
259    use rust_decimal::Decimal;
260    use rust_decimal_macros::dec;
261
262    use crate::duration::ToHoursDecimal;
263
264    use super::MILLIS_IN_SEC;
265
266    #[test]
267    fn zero_minutes_should_be_zero_hours() {
268        assert_eq!(TimeDelta::minutes(0).to_hours_dec(), dec!(0.0));
269    }
270
271    #[test]
272    fn thirty_minutes_should_be_fraction_of_hour() {
273        assert_eq!(TimeDelta::minutes(30).to_hours_dec(), dec!(0.5));
274    }
275
276    #[test]
277    fn sixty_minutes_should_be_fraction_of_hour() {
278        assert_eq!(TimeDelta::minutes(60).to_hours_dec(), dec!(1.0));
279    }
280
281    #[test]
282    fn ninety_minutes_should_be_fraction_of_hour() {
283        assert_eq!(TimeDelta::minutes(90).to_hours_dec(), dec!(1.5));
284    }
285
286    #[test]
287    fn as_seconds_dec_should_not_overflow() {
288        let number = Decimal::from(i64::MAX).checked_div(Decimal::from(MILLIS_IN_SEC));
289        assert!(number.is_some(), "should not overflow");
290    }
291}