xapi_rs/data/
duration.rs

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