planter_core/
duration.rs

1use std::ops::Deref;
2
3use chrono::Duration;
4use once_cell::sync::Lazy;
5use regex::bytes::Regex;
6use thiserror::Error;
7
8/// A duration is a unit of time that represents the amount of time required to complete a task.
9#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub struct PositiveDuration(Duration);
11
12/// Represents an error that occurs when trying to parse a negative duration.
13#[derive(Error, Debug)]
14pub enum DurationError {
15    /// Used when the wanted duration would be negative.
16    #[error("Negative values are not allowed for durations")]
17    NegativeDuration,
18    /// Used when the wanted duration would exceed the maximum allowed value.
19    #[error("Duration would exceed maximum allowed value")]
20    ExceedsMaximumDuration,
21    /// Used when trying to parse an invalid string.
22    #[error("Input string couldn't be parsed into a PositiveDuration")]
23    InvalidInput,
24}
25
26impl PositiveDuration {
27    /// Tries to parse a string and return the corresponding `[PositiveDuration]`
28    ///
29    /// # Arguments
30    /// * `s` - The string to parse. Currently, only hours are supported in the format "X h".
31    ///
32    /// # Returns
33    /// * `Ok(PositiveDuration)` - If the input string could be parsed into a `PositiveDuration`.
34    /// * `Err(DurationError)` - If the input string couldn't be parsed into a `PositiveDuration`.
35    ///
36    /// # Errors
37    /// * `DurationError::InvalidInput` - If the input string couldn't be parsed into a `PositiveDuration`.
38    /// * `DurationError::ExceedsMaximumDuration` - If the parsed duration exceeds the maximum allowed value.
39    ///
40    /// # Panics
41    /// This function uses `expect`, but it should only panic in case of a bug.
42    ///
43    /// # Examples
44    ///
45    /// ```
46    /// use planter_core::duration::PositiveDuration;
47    ///
48    /// let duration = PositiveDuration::parse_from_str("8 h").unwrap();
49    /// assert_eq!(duration.num_hours(), 8);
50    /// ```
51    ///
52    /// ```should_panic
53    /// use planter_core::duration::PositiveDuration;
54    ///
55    /// /// Passing invalid input will result in an `[DurationError::InvalidInput]`
56    /// let duration = PositiveDuration::parse_from_str("random garbage").unwrap();
57    /// ```
58    #[allow(clippy::expect_used)]
59    #[allow(clippy::unwrap_in_result)]
60    pub fn parse_from_str(s: &str) -> Result<Self, DurationError> {
61        let bytes = s.as_bytes();
62        static RE: Lazy<Regex> = Lazy::new(|| {
63            Regex::new(r"^[0-9]{1,12} h$")
64                .expect("It wasn't possible to compile a hardcoded regex. This is a bug.")
65        });
66        if RE.is_match(bytes) {
67            let hours = s.split(' ').next().expect("Expecting to retrieve the hours from the string after matching the regex. This is a bug.").parse::<i64>().expect("Expecting to convert the hours to an i64. This is a bug.");
68            if hours > MAX_DURATION {
69                Err(DurationError::ExceedsMaximumDuration)
70            } else {
71                Ok(PositiveDuration(Duration::hours(hours)))
72            }
73        } else {
74            Err(DurationError::InvalidInput)
75        }
76    }
77}
78
79/// Maximum duration allowed is ~31.68809 years.
80pub const MAX_DURATION: i64 = 999_999_999_999;
81
82impl TryFrom<Duration> for PositiveDuration {
83    type Error = DurationError;
84
85    /// Creates a new `PositiveDuration` from a `chrono::Duration`.
86    fn try_from(value: Duration) -> Result<Self, Self::Error> {
87        if value < Duration::milliseconds(0) {
88            Err(DurationError::NegativeDuration)
89        } else if value > Duration::milliseconds(MAX_DURATION) {
90            Err(DurationError::ExceedsMaximumDuration)
91        } else {
92            Ok(PositiveDuration(value))
93        }
94    }
95}
96
97impl Deref for PositiveDuration {
98    type Target = Duration;
99
100    fn deref(&self) -> &Self::Target {
101        &self.0
102    }
103}
104
105#[cfg(test)]
106/// Utilities to run tests with duration.
107pub mod test_utils {
108    use proptest::prelude::Strategy;
109
110    /// Generate a random duration string.
111    pub fn duration_string() -> impl Strategy<Value = String> {
112        r"[0-9]{1,12} h".prop_map(|s: String| s.to_owned())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::duration::test_utils::duration_string;
120    use proptest::prelude::*;
121
122    proptest! {
123        #[test]
124        fn parse_from_str_works(s in duration_string()) {
125            let hours = s.split(' ').next().unwrap().parse::<i64>().unwrap();
126            if !(0..=MAX_DURATION).contains(&hours) {
127                assert!(PositiveDuration::parse_from_str(&s).is_err());
128            } else {
129                let duration = PositiveDuration::parse_from_str(&s).unwrap();
130                assert_eq!(duration.num_hours(), hours);
131            }
132        }
133
134        #[test]
135        fn parse_from_str_fails_with_invalid_input(s in "\\PC*") {
136            let bytes = s.as_bytes();
137            static RE: Lazy<Regex> = Lazy::new(|| {
138                Regex::new(r"^[0-9]{1,12} h$")
139                    .expect("It wasn't possible to compile a hardcoded regex. This is a bug.")
140            });
141            if !RE.is_match(bytes) {
142                assert!(PositiveDuration::parse_from_str(&s).is_err())
143            }
144        }
145    }
146}