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