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;
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12use crate::{
13    into_caveat, json, number,
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!(TimeDelta);
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/// Parse a `chrono::TimeDelta` from JSON.
104///
105/// Used to parse the `min_duration` and `max_duration` fields of the tariff Restriction.
106///
107/// * 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>)
108/// * 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>)
109impl json::FromJson<'_, '_> for TimeDelta {
110    type WarningKind = WarningKind;
111
112    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
113        let mut warnings = warning::Set::new();
114        let Some(s) = elem.as_number_str() else {
115            warnings.with_elem(WarningKind::InvalidType, elem);
116            return Err(warnings);
117        };
118
119        // We only support positive durations in an OCPI object.
120        let seconds = match s.parse::<u64>() {
121            Ok(n) => n,
122            Err(err) => {
123                warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
124                return Err(warnings);
125            }
126        };
127
128        // Then we convert the positive duration to an i64 as that is how `chrono::TimeDelta`
129        // represents seconds.
130        let Ok(seconds) = i64::try_from(seconds) else {
131            warnings.with_elem(
132                WarningKind::Invalid(
133                    "The duration value is larger than an i64 can represent.".into(),
134                ),
135                elem,
136            );
137            return Err(warnings);
138        };
139        let duration = TimeDelta::seconds(seconds);
140
141        Ok(duration.into_caveat(warnings))
142    }
143}
144
145/// A `TimeDelta` wrapper used to serialize and deserialize to/from a `Decimal` representation of hours
146#[derive(Debug, Default)]
147pub(crate) struct HoursDecimal(Decimal);
148
149impl ToHoursDecimal for HoursDecimal {
150    fn to_hours_dec(&self) -> Decimal {
151        self.0
152    }
153}
154
155impl<'de> Deserialize<'de> for HoursDecimal {
156    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157    where
158        D: Deserializer<'de>,
159    {
160        number::deser_as_dec(deserializer).map(Self)
161    }
162}
163
164impl Serialize for HoursDecimal {
165    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
166        Serialize::serialize(&self.0, serializer)
167    }
168}
169
170impl From<HoursDecimal> for TimeDelta {
171    fn from(value: HoursDecimal) -> Self {
172        value.0.to_duration()
173    }
174}
175
176/// A duration of time has a cost.
177impl Cost for TimeDelta {
178    fn cost(&self, money: Money) -> Money {
179        let cost = self.to_hours_dec().saturating_mul(Decimal::from(money));
180        Money::from_decimal(cost)
181    }
182}
183
184/// Deserialize a number as a decimal and convert that into a `TimeDelta`.
185pub(crate) fn deser_from_hours<'de, D>(deserializer: D) -> Result<TimeDelta, D::Error>
186where
187    D: Deserializer<'de>,
188{
189    let hours = number::deser_as_dec(deserializer)?;
190    Ok(hours.to_duration())
191}
192
193/// Deserialize a number as a decimal and convert that into an optional `TimeDelta`.
194pub(crate) fn deser_from_opt_hours<'de, D>(deserializer: D) -> Result<Option<TimeDelta>, D::Error>
195where
196    D: Deserializer<'de>,
197{
198    let hours = number::deser_as_opt_dec(deserializer)?;
199    Ok(hours.map(|h| h.to_duration()))
200}
201
202impl SaturatingAdd for TimeDelta {
203    fn saturating_add(self, other: TimeDelta) -> TimeDelta {
204        self.checked_add(&other).unwrap_or(TimeDelta::MAX)
205    }
206}
207
208impl SaturatingSub for TimeDelta {
209    fn saturating_sub(self, other: TimeDelta) -> TimeDelta {
210        self.checked_sub(&other).unwrap_or_else(TimeDelta::zero)
211    }
212}
213
214/// Deserialize a seconds value into a `Duration`.
215///
216/// Usage: `#[serde(deserialize_with = "deser_from_opt_secs")]`
217pub(crate) fn deser_from_opt_secs<'de, D>(deserializer: D) -> Result<Option<TimeDelta>, D::Error>
218where
219    D: Deserializer<'de>,
220{
221    let Some(seconds) = Option::<u64>::deserialize(deserializer)? else {
222        return Ok(None);
223    };
224
225    let duration = from_u64(seconds).ok_or_else(|| serde::de::Error::custom(Error::Overflow))?;
226
227    Ok(Some(duration))
228}
229
230fn from_u64(seconds: u64) -> Option<TimeDelta> {
231    let seconds = i64::try_from(seconds).ok()?;
232    let duration = TimeDelta::try_seconds(seconds)?;
233    Some(duration)
234}
235
236/// A debug helper trait to display durations as HH:MM:SS.
237#[allow(dead_code, reason = "used during debug sessions")]
238pub(crate) trait AsHms {
239    /// Return a `Hms` formatter, that formats a `TimeDelta` into a `String` in `HH::MM::SS` format.
240    fn as_hms(&self) -> Hms;
241}
242
243impl AsHms for TimeDelta {
244    fn as_hms(&self) -> Hms {
245        Hms(*self)
246    }
247}
248
249impl AsHms for HoursDecimal {
250    fn as_hms(&self) -> Hms {
251        Hms(self.0.to_duration())
252    }
253}
254
255impl AsHms for Decimal {
256    /// Return a `Hms` formatter, that formats a `TimeDelta` into a `String` in `HH::MM::SS` format.
257    fn as_hms(&self) -> Hms {
258        Hms(self.to_duration())
259    }
260}
261
262/// A debug utility for displaying durations in `HH::MM::SS` format.
263pub(crate) struct Hms(TimeDelta);
264
265impl fmt::Debug for Hms {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        fmt::Display::fmt(self, f)
268    }
269}
270
271impl fmt::Display for Hms {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        let duration = self.0;
274        let seconds = duration.num_seconds() % SECS_IN_MIN;
275        let minutes = (duration.num_seconds() / SECS_IN_MIN) % MINS_IN_HOUR;
276        let hours = duration.num_seconds() / (SECS_IN_MIN * MINS_IN_HOUR);
277
278        write!(f, "{hours:0>2}:{minutes:0>2}:{seconds:0>2}")
279    }
280}
281
282#[cfg(test)]
283mod test {
284    use chrono::TimeDelta;
285    use rust_decimal::Decimal;
286
287    use crate::test::{approx_eq_dec, ApproxEq};
288
289    use super::{Error, HoursDecimal};
290
291    #[test]
292    const fn error_should_be_send_and_sync() {
293        const fn f<T: Send + Sync>() {}
294
295        f::<Error>();
296    }
297
298    impl ApproxEq for TimeDelta {
299        fn approx_eq(&self, other: &Self) -> bool {
300            const TOLERANCE: i64 = 3;
301            approx_eq_time_delta(*self, *other, TOLERANCE)
302        }
303    }
304
305    impl ApproxEq for HoursDecimal {
306        fn approx_eq(&self, other: &Self) -> bool {
307            const TOLERANCE: Decimal = rust_decimal_macros::dec!(0.1);
308            const PRECISION: u32 = 2;
309
310            approx_eq_dec(self.0, other.0, TOLERANCE, PRECISION)
311        }
312    }
313
314    /// Approximately compare two `TimeDelta` values.
315    pub fn approx_eq_time_delta(a: TimeDelta, b: TimeDelta, tolerance_secs: i64) -> bool {
316        let diff = a.num_seconds() - b.num_seconds();
317        diff.abs() <= tolerance_secs
318    }
319}
320
321#[cfg(test)]
322mod hour_decimal_tests {
323    use chrono::TimeDelta;
324    use rust_decimal::Decimal;
325    use rust_decimal_macros::dec;
326
327    use crate::duration::ToHoursDecimal;
328
329    use super::MILLIS_IN_SEC;
330
331    #[test]
332    fn zero_minutes_should_be_zero_hours() {
333        assert_eq!(TimeDelta::minutes(0).to_hours_dec(), dec!(0.0));
334    }
335
336    #[test]
337    fn thirty_minutes_should_be_fraction_of_hour() {
338        assert_eq!(TimeDelta::minutes(30).to_hours_dec(), dec!(0.5));
339    }
340
341    #[test]
342    fn sixty_minutes_should_be_fraction_of_hour() {
343        assert_eq!(TimeDelta::minutes(60).to_hours_dec(), dec!(1.0));
344    }
345
346    #[test]
347    fn ninety_minutes_should_be_fraction_of_hour() {
348        assert_eq!(TimeDelta::minutes(90).to_hours_dec(), dec!(1.5));
349    }
350
351    #[test]
352    fn as_seconds_dec_should_not_overflow() {
353        let number = Decimal::from(i64::MAX).checked_div(Decimal::from(MILLIS_IN_SEC));
354        assert!(number.is_some(), "should not overflow");
355    }
356}