Skip to main content

nv_redfish_core/
edm_duration.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! `Edm.Duration` primitive wrapper
17//!
18//! Represents ISO 8601 durations as used by OData/Redfish via `Edm.Duration`.
19//! Internally uses `rust_decimal::Decimal` seconds to preserve precision, supports
20//! negative values and fractional seconds, and displays in canonical
21//! `[-]P[nD][T[nH][nM]nS]` form.
22//!
23//! References:
24//! - OASIS OData 4.01 CSDL, Primitive Types: Edm.Duration — see `Part 3: CSDL`
25//!   (`https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part3-csdl.html`).
26//! - DMTF Redfish Specification DSP0266 (`https://www.dmtf.org/standards/redfish`).
27//!
28//! Examples
29//! ```rust
30//! use nv_redfish_core::EdmDuration;
31//! use std::str::FromStr;
32//!
33//! let d = EdmDuration::from_str("PT1H2M3.5S").unwrap();
34//! assert_eq!(d.to_string(), "PT1H2M3.5S");
35//! assert!((d.as_f64_seconds() - 3723.5).abs() < f64::EPSILON);
36//! ```
37//!
38//! ```rust
39//! use nv_redfish_core::EdmDuration;
40//! use std::convert::TryFrom;
41//! use std::time::Duration as StdDuration;
42//! use std::str::FromStr;
43//!
44//! let one_day = EdmDuration::from_str("P1D").unwrap();
45//! let std = StdDuration::try_from(one_day).unwrap();
46//! assert_eq!(std.as_secs(), 86_400);
47//! // Negative durations cannot convert to StdDuration
48//! assert!(StdDuration::try_from(EdmDuration::from_str("-PT1S").unwrap()).is_err());
49//! ```
50
51use rust_decimal::prelude::ToPrimitive as _;
52use rust_decimal::Decimal;
53use serde::de::Error as DeError;
54use serde::de::Visitor;
55use serde::Deserialize;
56use serde::Deserializer;
57use serde::Serialize;
58use serde::Serializer;
59use std::convert::TryFrom;
60use std::error::Error as StdError;
61use std::fmt::Display;
62use std::fmt::Formatter;
63use std::fmt::Result as FmtResult;
64use std::str::Chars;
65use std::str::FromStr;
66use std::time::Duration as StdDuration;
67
68/// `EdmDuration` represented by Edm.EdmDuration type.
69///
70/// This type designed to prevent data loss during deserialization and
71/// provides conversion to specific data types. If you don't care
72/// about precision you can always use conversion to f64 seconds.
73#[derive(Debug, Clone, Copy)]
74#[repr(transparent)]
75pub struct EdmDuration(Decimal);
76
77impl EdmDuration {
78    /// Convert to seconds represented as f64. Note that this function
79    /// may return +Inf or -Inf if number outside of f64 range.
80    #[must_use]
81    pub fn as_f64_seconds(&self) -> f64 {
82        Self::decimal_to_f64_lossy(self.0)
83    }
84
85    /// Extract seconds represented be `Decimal` from `EdmDuration`.
86    #[must_use]
87    pub const fn as_decimal(&self) -> Decimal {
88        self.0
89    }
90
91    fn take_digits<'a>(chars: &Chars<'a>) -> (&'a str, Option<char>, Chars<'a>) {
92        let s = chars.as_str();
93        for (i, ch) in s.char_indices() {
94            if ch.is_ascii_digit() || ch == '.' {
95                continue;
96            }
97            let digits = &s[..i];
98            let rest = &s[i + ch.len_utf8()..];
99            return (digits, Some(ch), rest.chars());
100        }
101        (s, None, "".chars())
102    }
103
104    fn decimal_to_f64_lossy(d: Decimal) -> f64 {
105        d.to_f64().unwrap_or_else(|| {
106            if d.is_sign_negative() {
107                f64::NEG_INFINITY
108            } else {
109                f64::INFINITY
110            }
111        })
112    }
113
114    fn div_with_reminder(v: Decimal, d: Decimal) -> (Decimal, Decimal) {
115        let reminder = v % d;
116        ((v - reminder) / d, reminder)
117    }
118}
119
120/// Errors of `EdmDuration`.
121#[derive(Debug)]
122pub enum Error {
123    /// Invalid Edm.Duration string.
124    InvalidEdmDuration(String),
125    /// Data cannot be represented by internal type.
126    Overflow(String),
127    /// Cannot convert negative Edm.Duration to standard duration,
128    CannotConvertNegativeEdmDuration,
129    /// Value of Edm.Duration is too big to be represented by standard
130    /// duration.
131    ValueTooBig,
132}
133
134impl Display for Error {
135    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
136        match self {
137            Self::InvalidEdmDuration(v) => write!(f, "invalid duration: {v}"),
138            Self::Overflow(v) => write!(f, "invalid duration: number overflow: {v}"),
139            Self::CannotConvertNegativeEdmDuration => "cannot convert negative duration".fmt(f),
140            Self::ValueTooBig => "duration: value to big".fmt(f),
141        }
142    }
143}
144
145impl StdError for Error {}
146
147// Conversion duration to the standard duration. Can return error if
148// value cannot be represented by EdmDuration (Example: negative
149// durations). If EdmDuration fraction of seconds is less than
150// nanoseconds then duration will be rounded to the closes nanosecond.
151impl TryFrom<EdmDuration> for StdDuration {
152    type Error = Error;
153
154    fn try_from(v: EdmDuration) -> Result<Self, Error> {
155        if v.0.is_sign_negative() {
156            return Err(Error::CannotConvertNegativeEdmDuration);
157        }
158        if v.0.is_integer() {
159            let p = u64::try_from(v.0).map_err(|_| Error::ValueTooBig)?;
160            return Ok(Self::from_secs(p));
161        }
162        let v =
163            v.0.checked_mul(Decimal::ONE_THOUSAND)
164                .ok_or(Error::ValueTooBig)?;
165        if v.is_integer() {
166            let p = u64::try_from(v).map_err(|_| Error::ValueTooBig)?;
167            return Ok(Self::from_millis(p));
168        }
169        let v = v
170            .checked_mul(Decimal::ONE_THOUSAND)
171            .ok_or(Error::ValueTooBig)?;
172        if v.is_integer() {
173            let p = u64::try_from(v).map_err(|_| Error::ValueTooBig)?;
174            return Ok(Self::from_micros(p));
175        }
176        let v = v
177            .checked_mul(Decimal::ONE_THOUSAND)
178            .ok_or(Error::ValueTooBig)?
179            .round();
180        let p = u64::try_from(v).map_err(|_| Error::ValueTooBig)?;
181        Ok(Self::from_nanos(p))
182    }
183}
184
185impl FromStr for EdmDuration {
186    type Err = Error;
187    fn from_str(v: &str) -> Result<Self, Error> {
188        let mut chars = v.chars();
189        let make_err = || Error::InvalidEdmDuration(v.into());
190        let overflow_err = || Error::Overflow(v.into());
191        let maybe_sign = chars.next().ok_or_else(make_err)?;
192        let (neg, p) = if maybe_sign == '-' {
193            (Decimal::NEGATIVE_ONE, chars.next().ok_or_else(make_err)?)
194        } else {
195            (Decimal::ONE, maybe_sign)
196        };
197        (p == 'P').then_some(()).ok_or_else(make_err)?;
198
199        let to_decimal = |val: &str, mul| {
200            Decimal::from_str_exact(val)
201                .map(|d| d * Decimal::from(mul))
202                .map_err(|_| make_err())
203        };
204
205        let mut result = Decimal::ZERO;
206        let (val, maybe_next, mut chars) = Self::take_digits(&chars);
207        match maybe_next {
208            Some('T') => (),
209            Some('D') => match chars.next() {
210                Some('T') => {
211                    result = result
212                        .checked_add(to_decimal(val, 3600 * 24)?)
213                        .ok_or_else(overflow_err)?;
214                }
215                None => return to_decimal(val, 3600 * 24).map(|v| Self(v * neg)),
216                _ => Err(make_err())?,
217            },
218            _ => Err(make_err())?,
219        }
220
221        loop {
222            let (val, maybe_next, new_chars) = Self::take_digits(&chars);
223            chars = new_chars;
224            let mul = match maybe_next {
225                Some('H') => 3600,
226                Some('M') => 60,
227                Some('S') => 1,
228                Some(_) => Err(make_err())?,
229                None => break,
230            };
231            result = result
232                .checked_add(to_decimal(val, mul)?)
233                .ok_or_else(overflow_err)?;
234        }
235        Ok(Self(result * neg))
236    }
237}
238
239impl Display for EdmDuration {
240    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
241        if self.0 == Decimal::ZERO {
242            // Normalize zero to a canonical representation
243            return write!(f, "PT0S");
244        }
245        let value = if self.0.is_sign_negative() {
246            write!(f, "-P")?;
247            -self.0
248        } else {
249            write!(f, "P")?;
250            self.0
251        };
252        let (days, value) = Self::div_with_reminder(value, Decimal::from(24 * 3600));
253        if days != Decimal::ZERO {
254            write!(f, "{}D", days.normalize())?;
255        }
256        if value != Decimal::ZERO {
257            write!(f, "T")?;
258            let (hours, value) = Self::div_with_reminder(value, Decimal::from(3600));
259            if hours != Decimal::ZERO {
260                write!(f, "{}H", hours.normalize())?;
261            }
262            let (mins, value) = Self::div_with_reminder(value, Decimal::from(60));
263            if mins != Decimal::ZERO {
264                write!(f, "{}M", mins.normalize())?;
265            }
266            write!(f, "{}S", value.normalize())?;
267        }
268        Ok(())
269    }
270}
271
272impl<'de> Deserialize<'de> for EdmDuration {
273    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
274        struct ValVisitor {}
275        impl Visitor<'_> for ValVisitor {
276            type Value = EdmDuration;
277
278            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> FmtResult {
279                formatter.write_str("Edm.Duration string")
280            }
281            fn visit_str<E: DeError>(self, value: &str) -> Result<Self::Value, E> {
282                value.parse().map_err(DeError::custom)
283            }
284        }
285
286        de.deserialize_string(ValVisitor {})
287    }
288}
289
290impl Serialize for EdmDuration {
291    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
292        self.to_string().serialize(serializer)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use rust_decimal::Decimal;
300
301    fn dec(s: &str) -> Decimal {
302        Decimal::from_str_exact(s).unwrap()
303    }
304
305    #[test]
306    fn parses_time_only_hms() {
307        let d = EdmDuration::from_str("PT1H2M3S").unwrap();
308        assert_eq!(d.0, Decimal::from(3600 + 120 + 3));
309    }
310
311    #[test]
312    fn parses_day_only() {
313        let d = EdmDuration::from_str("P3D").unwrap();
314        assert_eq!(d.0, Decimal::from(3 * 86400));
315    }
316
317    #[test]
318    fn parses_day_and_time() {
319        let d = EdmDuration::from_str("P1DT1H").unwrap();
320        assert_eq!(d.0, Decimal::from(86400 + 3600));
321    }
322
323    #[test]
324    fn parses_fractional_seconds() {
325        let d = EdmDuration::from_str("PT0.25S").unwrap();
326        assert_eq!(d.0, dec("0.25"));
327    }
328
329    #[test]
330    fn parses_fractional_minutes_and_days() {
331        let d1 = EdmDuration::from_str("PT1.5M").unwrap();
332        assert_eq!(d1.0, Decimal::from(90));
333
334        let d2 = EdmDuration::from_str("P1.5D").unwrap();
335        assert_eq!(d2.0, Decimal::from(129600));
336    }
337
338    #[test]
339    fn parses_negative_durations() {
340        let d1 = EdmDuration::from_str("-PT2M").unwrap();
341        assert_eq!(d1.0, Decimal::from(-120));
342
343        let d2 = EdmDuration::from_str("-P1D").unwrap();
344        assert_eq!(d2.0, Decimal::from(-86400));
345    }
346
347    #[test]
348    fn parses_zero_variants() {
349        let d1 = EdmDuration::from_str("PT0S").unwrap();
350        assert_eq!(d1.0, Decimal::from(0));
351
352        let d2 = EdmDuration::from_str("PT").unwrap();
353        assert_eq!(d2.0, Decimal::from(0));
354    }
355
356    #[test]
357    fn rejects_malformed_inputs() {
358        assert!(EdmDuration::from_str("").is_err());
359        assert!(EdmDuration::from_str("P").is_err());
360        assert!(EdmDuration::from_str("T1H").is_err());
361        assert!(EdmDuration::from_str("PT1X").is_err());
362        assert!(EdmDuration::from_str("-P").is_err());
363    }
364
365    #[test]
366    fn formats_zero_duration() {
367        let d = EdmDuration::from_str("PT").unwrap();
368        assert_eq!(format!("{}", d), "PT0S");
369    }
370
371    #[test]
372    fn formats_seconds_only() {
373        let d = EdmDuration::from_str("PT3S").unwrap();
374        assert_eq!(format!("{}", d), "PT3S");
375    }
376
377    #[test]
378    fn formats_fractional_seconds() {
379        let d = EdmDuration::from_str("PT0.25S").unwrap();
380        assert_eq!(format!("{}", d), "PT0.25S");
381    }
382
383    #[test]
384    fn formats_minutes_and_hours_with_zero_seconds() {
385        let d1 = EdmDuration::from_str("PT2M").unwrap();
386        assert_eq!(format!("{}", d1), "PT2M0S");
387
388        let d2 = EdmDuration::from_str("PT1H").unwrap();
389        assert_eq!(format!("{}", d2), "PT1H0S");
390
391        let d3 = EdmDuration::from_str("PT1H2M").unwrap();
392        assert_eq!(format!("{}", d3), "PT1H2M0S");
393    }
394
395    #[test]
396    fn formats_days_only_and_day_time() {
397        let d1 = EdmDuration::from_str("P3D").unwrap();
398        assert_eq!(format!("{}", d1), "P3D");
399
400        let d2 = EdmDuration::from_str("P1DT1H").unwrap();
401        assert_eq!(format!("{}", d2), "P1DT1H0S");
402    }
403
404    #[test]
405    fn formats_negative_durations() {
406        let d1 = EdmDuration::from_str("-PT2M").unwrap();
407        assert_eq!(format!("{}", d1), "-PT2M0S");
408
409        let d2 = EdmDuration::from_str("-P1D").unwrap();
410        assert_eq!(format!("{}", d2), "-P1D");
411    }
412
413    #[test]
414    fn normalizes_fractional_minutes_on_display() {
415        let d = EdmDuration::from_str("PT1.5M").unwrap();
416        assert_eq!(format!("{}", d), "PT1M30S");
417    }
418
419    #[test]
420    fn formats_trims_trailing_zero_seconds() {
421        let d = EdmDuration::from_str("PT30.0S").unwrap();
422        // Desired: trailing .0 trimmed
423        assert_eq!(format!("{}", d), "PT30S");
424    }
425
426    #[test]
427    fn formats_leading_zero_inputs() {
428        let d = EdmDuration::from_str("PT01S").unwrap();
429        assert_eq!(format!("{}", d), "PT1S");
430    }
431
432    #[test]
433    fn formats_large_hours_breakdown() {
434        // 100_000 hours = 4166 days and 16 hours
435        let d = EdmDuration::from_str("PT100000H").unwrap();
436        assert_eq!(format!("{}", d), "P4166DT16H0S");
437    }
438
439    #[test]
440    fn formats_fractional_hours() {
441        let d = EdmDuration::from_str("PT1.75H").unwrap();
442        assert_eq!(format!("{}", d), "PT1H45M0S");
443    }
444
445    #[test]
446    fn formats_fractional_days() {
447        let d = EdmDuration::from_str("P1.25D").unwrap();
448        assert_eq!(format!("{}", d), "P1DT6H0S");
449    }
450
451    #[test]
452    fn formats_minute_and_hour_carry_from_seconds() {
453        let d1 = EdmDuration::from_str("PT60S").unwrap();
454        assert_eq!(format!("{}", d1), "PT1M0S");
455
456        let d2 = EdmDuration::from_str("PT3600S").unwrap();
457        assert_eq!(format!("{}", d2), "PT1H0S");
458    }
459
460    #[test]
461    fn formats_trims_excess_zero_fraction() {
462        let d = EdmDuration::from_str("PT1.2300S").unwrap();
463        assert_eq!(format!("{}", d), "PT1.23S");
464    }
465
466    #[test]
467    fn test_exact_division() {
468        let (q, r) = EdmDuration::div_with_reminder(Decimal::new(10, 0), Decimal::new(5, 0));
469        assert_eq!(q, Decimal::new(2, 0));
470        assert_eq!(r, Decimal::new(0, 0));
471    }
472
473    #[test]
474    fn test_positive_non_exact() {
475        let (q, r) = EdmDuration::div_with_reminder(Decimal::new(10, 0), Decimal::new(4, 0));
476        assert_eq!(q, Decimal::new(2, 0));
477        assert_eq!(r, Decimal::new(2, 0));
478    }
479
480    #[test]
481    fn test_non_integer_division() {
482        let v = Decimal::new(105, 1); // 10.5
483        let d = Decimal::new(4, 0); // 4
484        let (q, r) = EdmDuration::div_with_reminder(v, d);
485        assert_eq!(q, Decimal::new(2, 0));
486        assert_eq!(r, Decimal::new(25, 1)); // 2.5
487    }
488
489    #[test]
490    fn test_zero_dividend() {
491        let (q, r) = EdmDuration::div_with_reminder(Decimal::new(0, 0), Decimal::new(5, 0));
492        assert_eq!(q, Decimal::new(0, 0));
493        assert_eq!(r, Decimal::new(0, 0));
494    }
495}