Skip to main content

planter_core/
duration.rs

1use std::fmt;
2use std::ops::Deref;
3use std::str::FromStr;
4
5use chrono::Duration;
6use regex::Regex;
7use std::sync::LazyLock;
8use thiserror::Error;
9
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13#[cfg(feature = "serde")]
14use serde::de;
15
16/// A duration is a unit of time that represents the amount of time required to complete a task.
17#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub struct NonNegativeDuration(Duration);
19
20/// Represents an error that occurs when trying to parse a negative duration.
21#[derive(Error, Debug)]
22pub enum DurationError {
23    /// Used when the wanted duration would be negative.
24    #[error("Negative values are not allowed for durations")]
25    NegativeDuration,
26    /// Used when trying to parse an invalid string.
27    #[error("Input string couldn't be parsed into a NonNegativeDuration")]
28    InvalidInput,
29}
30
31#[allow(clippy::expect_used)]
32static DURATION_RE: LazyLock<Regex> =
33    LazyLock::new(|| Regex::new(r"^([0-9]+) h$").expect("hardcoded regex is valid"));
34
35impl NonNegativeDuration {
36    /// Tries to parse a string and return the corresponding `[NonNegativeDuration]`
37    ///
38    /// # Arguments
39    /// * `s` - The string to parse. Currently, only hours are supported in the format "X h".
40    ///
41    /// # Returns
42    /// * `Ok(NonNegativeDuration)` - If the input string could be parsed into a `NonNegativeDuration`.
43    /// * `Err(DurationError)` - If the input string couldn't be parsed into a `NonNegativeDuration`.
44    ///
45    /// # Errors
46    /// * `DurationError::InvalidInput` - If the input string couldn't be parsed into a `NonNegativeDuration`.
47    ///
48    /// # Examples
49    ///
50    /// ```
51    /// use planter_core::duration::NonNegativeDuration;
52    ///
53    /// let duration = NonNegativeDuration::parse_from_str("8 h").unwrap();
54    /// assert_eq!(duration.num_hours(), 8);
55    /// ```
56    pub fn parse_from_str(s: &str) -> Result<Self, DurationError> {
57        if let Some(caps) = DURATION_RE.captures(s) {
58            let hours: i64 = caps[1].parse().map_err(|_| DurationError::InvalidInput)?;
59            Ok(NonNegativeDuration(Duration::hours(hours)))
60        } else {
61            Err(DurationError::InvalidInput)
62        }
63    }
64}
65
66impl TryFrom<Duration> for NonNegativeDuration {
67    type Error = DurationError;
68
69    fn try_from(value: Duration) -> Result<Self, Self::Error> {
70        if value < Duration::milliseconds(0) {
71            Err(DurationError::NegativeDuration)
72        } else {
73            Ok(NonNegativeDuration(value))
74        }
75    }
76}
77
78impl Deref for NonNegativeDuration {
79    type Target = Duration;
80
81    fn deref(&self) -> &Self::Target {
82        &self.0
83    }
84}
85
86impl fmt::Display for NonNegativeDuration {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        write!(f, "{} h", self.num_hours())
89    }
90}
91
92impl FromStr for NonNegativeDuration {
93    type Err = DurationError;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        Self::parse_from_str(s)
97    }
98}
99
100#[cfg(feature = "serde")]
101impl Serialize for NonNegativeDuration {
102    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
103        serializer.collect_str(self)
104    }
105}
106
107#[cfg(feature = "serde")]
108impl<'de> Deserialize<'de> for NonNegativeDuration {
109    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
110        let s = String::deserialize(deserializer)?;
111        s.parse().map_err(de::Error::custom)
112    }
113}
114
115#[cfg(test)]
116/// Utilities to test with duration.
117pub mod test_utils {
118    use proptest::prelude::Strategy;
119
120    use super::NonNegativeDuration;
121
122    /// Generate a random duration string.
123    pub fn duration_string() -> impl Strategy<Value = String> {
124        r"[0-9]{1,12} h"
125    }
126
127    /// Generate a random `NonNegativeDuration`.
128    pub fn non_negative_duration() -> impl Strategy<Value = NonNegativeDuration> {
129        duration_string().prop_map(|s| NonNegativeDuration::parse_from_str(&s).unwrap())
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::duration::test_utils::duration_string;
137    use proptest::prelude::*;
138
139    proptest! {
140        #[test]
141        fn parse_from_str_works(s in duration_string()) {
142            let hours = s.split(' ').next().unwrap().parse::<i64>().unwrap();
143            let duration = NonNegativeDuration::parse_from_str(&s).unwrap();
144            assert_eq!(duration.num_hours(), hours);
145        }
146
147        #[test]
148        fn parse_from_str_fails_with_invalid_input(s in "\\PC*") {
149            if !DURATION_RE.is_match(&s) {
150                assert!(NonNegativeDuration::parse_from_str(&s).is_err())
151            }
152        }
153    }
154}
155
156#[cfg(all(test, feature = "serde"))]
157mod serde_tests {
158    use proptest::prelude::*;
159
160    use super::NonNegativeDuration;
161
162    proptest! {
163        #[test]
164        fn serde_roundtrip(d in crate::duration::test_utils::non_negative_duration()) {
165            let json = serde_json::to_string(&d).unwrap();
166            let deserialized: NonNegativeDuration = serde_json::from_str(&json).unwrap();
167            assert_eq!(d, deserialized);
168        }
169    }
170}