Skip to main content

xapi_data/
duration.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{DataError, Fingerprint, ValidationError, emit_error};
4use core::fmt;
5use serde::{Deserialize, Deserializer, Serialize, de};
6use serde_json::Value;
7use serde_with::{DisplayFromStr, serde_as};
8use speedate::Duration;
9use std::hash::Hasher;
10use std::str::FromStr;
11use tracing::error;
12
13/// Implementation of time duration that wraps [Duration] to better
14/// satisfy the requirements of the xAPI specifications.
15///
16/// Specifically, this implementation considers the patterns `[PnnW]` and
17/// `[PnnYnnMnnDTnnHnnMnnS]` as valid.
18#[serde_as]
19#[derive(Clone, Debug, PartialEq, Serialize)]
20pub struct MyDuration(#[serde_as(as = "DisplayFromStr")] Duration);
21
22impl MyDuration {
23    /// Construct a new instance from given parameters.
24    pub fn new(positive: bool, day: u32, second: u32, microsecond: u32) -> Result<Self, DataError> {
25        let x = Duration::new(positive, day, second, microsecond).map_err(|x| {
26            error!("{}", x);
27            DataError::Duration(x.to_string().into())
28        })?;
29        Ok(MyDuration(x))
30    }
31
32    fn from(duration: Duration) -> Self {
33        MyDuration(duration)
34    }
35
36    /// Return a clone of this **excluding precisions beyond 0.01 second.**
37    ///
38    /// Needed b/c [4.2.7 Additional Requirements for Data Types / Duration][1]
39    /// states:
40    /// > When making a comparison (e.g. as a part of the statement signing
41    /// > process) of Statements in regard to a Duration, any precision beyond
42    /// > 0.01 second precision shall not be included in the comparison.
43    ///
44    /// [1]: https://opensource.ieee.org/xapi/xapi-base-standard-documentation/-/blob/main/9274.1.1%20xAPI%20Base%20Standard%20for%20LRSs.md#duration
45    ///
46    pub fn truncate(&self) -> Self {
47        let inner = &self.0;
48        MyDuration::from(
49            Duration::new(
50                inner.positive,
51                inner.day,
52                inner.second,
53                (inner.microsecond / 10_000) * 10_000,
54            )
55            .expect("Failed truncating duration"),
56        )
57    }
58
59    /// Return the positive or negative sign of this.
60    pub fn positive(&self) -> bool {
61        self.0.positive
62    }
63
64    /// Return the number of days in this.
65    pub fn day(&self) -> u32 {
66        self.0.day
67    }
68
69    /// Return the number of seconds, range 0 to 86399, in this.
70    pub fn second(&self) -> u32 {
71        self.0.second
72    }
73
74    /// Return the number of microseconds, range 0 to 999999, in this.
75    pub fn microsecond(&self) -> u32 {
76        self.0.microsecond
77    }
78
79    /// Return this in ISO8601 format; i.e. "P9DT9H9M9.99S"
80    pub fn to_iso8601(&self) -> String {
81        let inner = &self.0;
82        let mut res = String::from("P");
83        if inner.day != 0 {
84            res.push_str(&inner.day.to_string());
85            res.push('D');
86        };
87        res.push('T');
88        let sec = inner.second;
89        // round to 0.01 sec...
90        let mu = inner.microsecond / 10_000;
91        // divide seconds into hours, minutes and (remaining) seconds...
92        let (h, rest) = (sec / 3600, sec % 3600);
93        res.push_str(&h.to_string());
94        res.push('H');
95        let (m, s) = (rest / 60, rest % 60);
96        res.push_str(&m.to_string());
97        res.push('M');
98        if mu == 0 {
99            res.push_str(&s.to_string());
100        } else {
101            let sec = s as f32 + (mu as f32 / 100.0);
102            res.push_str(&format!("{sec:.2}"));
103        }
104        res.push('S');
105        res
106    }
107}
108
109impl fmt::Display for MyDuration {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(f, "{}", self.to_iso8601())
112    }
113}
114
115impl Fingerprint for MyDuration {
116    fn fingerprint<H: Hasher>(&self, state: &mut H) {
117        let truncated = self.truncate().0;
118        state.write_i64(truncated.signed_total_seconds());
119        state.write_i32(truncated.signed_microseconds())
120    }
121}
122
123impl FromStr for MyDuration {
124    type Err = DataError;
125
126    fn from_str(s: &str) -> Result<Self, Self::Err> {
127        // IMPORTANT (rsn) 20241019 - to my understanding of [ISO-8601][1],
128        // only [PnnW] or [PnnYnnMnnDTnnHnnMnnS] patterns are valid.
129        //
130        // [1]: https://dotat.at/tmp/ISO_8601-2004_E.pdf
131        // [2]: https://adl.gitbooks.io/xapi-lrs-conformance-requirements/content/40_special_data_types_and_rules/46_iso_8601_durations.html
132        let s = s.trim();
133        if s.contains('W') && !s.ends_with('W') {
134            emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
135                "Only [PnnW] or [PnnYnnMnnDTnnHnnMnnS] patterns are allowed".into()
136            )))
137        } else {
138            let x = Duration::parse_str(s).map_err(|x| {
139                error!("{}", x);
140                DataError::Duration(x.to_string().into())
141            })?;
142            Ok(MyDuration::from(x))
143        }
144    }
145}
146
147impl<'de> Deserialize<'de> for MyDuration {
148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149    where
150        D: Deserializer<'de>,
151    {
152        let value: Value = Deserialize::deserialize(deserializer)?;
153        match value {
154            Value::String(s) => MyDuration::from_str(&s).map_err(de::Error::custom),
155            _ => Err(de::Error::custom("Expected string")),
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    #[should_panic]
166    fn test_iso8601_4433_p1() {
167        MyDuration::from_str("P4W1D").unwrap();
168    }
169
170    #[test]
171    fn test_iso8601_4433_p2() {
172        assert!(MyDuration::from_str("P4W").is_ok());
173        assert!(serde_json::from_str::<MyDuration>("\"P4W\"").is_ok());
174    }
175
176    #[test]
177    fn test_truncation() {
178        const D1: &str = "P1DT12H36M0.12567S";
179        const D2: &str = "P1DT12H36M0.12S";
180
181        let d1 = MyDuration::from_str(D1).unwrap();
182        let d2 = MyDuration::from_str(D2).unwrap();
183        assert_eq!(d1.day(), d2.day());
184        assert_eq!(d1.second(), d2.second());
185        assert_eq!(d1.microsecond() / 10_000, d2.microsecond() / 10_000);
186    }
187
188    #[test]
189    #[should_panic]
190    fn test_deserialization() {
191        serde_json::from_str::<MyDuration>("\"P4W1D\"").unwrap();
192    }
193}