1use std::ops::Deref;
2
3use chrono::Duration;
4use once_cell::sync::Lazy;
5use regex::bytes::Regex;
6use thiserror::Error;
7
8#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub struct PositiveDuration(Duration);
11
12#[derive(Error, Debug)]
14pub enum DurationError {
15 #[error("Negative values are not allowed for durations")]
17 NegativeDuration,
18 #[error("Duration would exceed maximum allowed value")]
20 ExceedsMaximumDuration,
21 #[error("Input string couldn't be parsed into a PositiveDuration")]
23 InvalidInput,
24}
25
26impl PositiveDuration {
27 #[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
79pub const MAX_DURATION: i64 = 999_999_999_999;
81
82impl TryFrom<Duration> for PositiveDuration {
83 type Error = DurationError;
84
85 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)]
106pub mod test_utils {
108 use proptest::prelude::Strategy;
109
110 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}