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