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/// A duration is a unit of time that represents the amount of time required to complete a task.
11#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub struct NonNegativeDuration(Duration);
13
14/// Represents an error that occurs when trying to parse a negative duration.
15#[derive(Error, Debug)]
16pub enum DurationError {
17    /// Used when the wanted duration would be negative.
18    #[error("Negative values are not allowed for durations")]
19    NegativeDuration,
20    /// Used when trying to parse an invalid string.
21    #[error("Input string couldn't be parsed into a NonNegativeDuration")]
22    InvalidInput,
23}
24
25#[allow(clippy::expect_used)]
26static DURATION_RE: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r"^([0-9]+) h$").expect("hardcoded regex is valid"));
28
29impl NonNegativeDuration {
30    /// Tries to parse a string and return the corresponding `[NonNegativeDuration]`
31    ///
32    /// # Arguments
33    /// * `s` - The string to parse. Currently, only hours are supported in the format "X h".
34    ///
35    /// # Returns
36    /// * `Ok(NonNegativeDuration)` - If the input string could be parsed into a `NonNegativeDuration`.
37    /// * `Err(DurationError)` - If the input string couldn't be parsed into a `NonNegativeDuration`.
38    ///
39    /// # Errors
40    /// * `DurationError::InvalidInput` - If the input string couldn't be parsed into a `NonNegativeDuration`.
41    ///
42    /// # Examples
43    ///
44    /// ```
45    /// use planter_core::duration::NonNegativeDuration;
46    ///
47    /// let duration = NonNegativeDuration::parse_from_str("8 h").unwrap();
48    /// assert_eq!(duration.num_hours(), 8);
49    /// ```
50    pub fn parse_from_str(s: &str) -> Result<Self, DurationError> {
51        if let Some(caps) = DURATION_RE.captures(s) {
52            let hours: i64 = caps[1].parse().map_err(|_| DurationError::InvalidInput)?;
53            Ok(NonNegativeDuration(Duration::hours(hours)))
54        } else {
55            Err(DurationError::InvalidInput)
56        }
57    }
58}
59
60impl TryFrom<Duration> for NonNegativeDuration {
61    type Error = DurationError;
62
63    fn try_from(value: Duration) -> Result<Self, Self::Error> {
64        if value < Duration::milliseconds(0) {
65            Err(DurationError::NegativeDuration)
66        } else {
67            Ok(NonNegativeDuration(value))
68        }
69    }
70}
71
72impl Deref for NonNegativeDuration {
73    type Target = Duration;
74
75    fn deref(&self) -> &Self::Target {
76        &self.0
77    }
78}
79
80impl fmt::Display for NonNegativeDuration {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(f, "{} h", self.num_hours())
83    }
84}
85
86impl FromStr for NonNegativeDuration {
87    type Err = DurationError;
88
89    fn from_str(s: &str) -> Result<Self, Self::Err> {
90        Self::parse_from_str(s)
91    }
92}
93
94#[cfg(test)]
95/// Utilities to test with duration.
96pub mod test_utils {
97    use proptest::prelude::Strategy;
98
99    /// Generate a random duration string.
100    pub fn duration_string() -> impl Strategy<Value = String> {
101        r"[0-9]{1,12} h"
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::duration::test_utils::duration_string;
109    use proptest::prelude::*;
110
111    proptest! {
112        #[test]
113        fn parse_from_str_works(s in duration_string()) {
114            let hours = s.split(' ').next().unwrap().parse::<i64>().unwrap();
115            let duration = NonNegativeDuration::parse_from_str(&s).unwrap();
116            assert_eq!(duration.num_hours(), hours);
117        }
118
119        #[test]
120        fn parse_from_str_fails_with_invalid_input(s in "\\PC*") {
121            if !DURATION_RE.is_match(&s) {
122                assert!(NonNegativeDuration::parse_from_str(&s).is_err())
123            }
124        }
125    }
126}