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