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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub struct NonNegativeDuration(Duration);
13
14#[derive(Error, Debug)]
16pub enum DurationError {
17 #[error("Negative values are not allowed for durations")]
19 NegativeDuration,
20 #[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 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)]
95pub mod test_utils {
97 use proptest::prelude::Strategy;
98
99 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}