use std::fmt;
use std::ops::Deref;
use std::str::FromStr;
use chrono::Duration;
use regex::Regex;
use std::sync::LazyLock;
use thiserror::Error;
#[cfg(feature = "serde")]
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[cfg(feature = "serde")]
use serde::de;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct NonNegativeDuration(Duration);
#[derive(Error, Debug)]
pub enum DurationError {
#[error("Negative values are not allowed for durations")]
NegativeDuration,
#[error("Input string couldn't be parsed into a NonNegativeDuration")]
InvalidInput,
}
#[allow(clippy::expect_used)]
static DURATION_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([0-9]+) h$").expect("hardcoded regex is valid"));
impl NonNegativeDuration {
pub fn parse_from_str(s: &str) -> Result<Self, DurationError> {
if let Some(caps) = DURATION_RE.captures(s) {
let hours: i64 = caps[1].parse().map_err(|_| DurationError::InvalidInput)?;
Ok(NonNegativeDuration(Duration::hours(hours)))
} else {
Err(DurationError::InvalidInput)
}
}
}
impl TryFrom<Duration> for NonNegativeDuration {
type Error = DurationError;
fn try_from(value: Duration) -> Result<Self, Self::Error> {
if value < Duration::milliseconds(0) {
Err(DurationError::NegativeDuration)
} else {
Ok(NonNegativeDuration(value))
}
}
}
impl Deref for NonNegativeDuration {
type Target = Duration;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl fmt::Display for NonNegativeDuration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} h", self.num_hours())
}
}
impl FromStr for NonNegativeDuration {
type Err = DurationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_from_str(s)
}
}
#[cfg(feature = "serde")]
impl Serialize for NonNegativeDuration {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for NonNegativeDuration {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(de::Error::custom)
}
}
#[cfg(test)]
pub mod test_utils {
use proptest::prelude::Strategy;
use super::NonNegativeDuration;
pub fn duration_string() -> impl Strategy<Value = String> {
r"[0-9]{1,12} h"
}
pub fn non_negative_duration() -> impl Strategy<Value = NonNegativeDuration> {
duration_string().prop_map(|s| NonNegativeDuration::parse_from_str(&s).unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::duration::test_utils::duration_string;
use proptest::prelude::*;
proptest! {
#[test]
fn parse_from_str_works(s in duration_string()) {
let hours = s.split(' ').next().unwrap().parse::<i64>().unwrap();
let duration = NonNegativeDuration::parse_from_str(&s).unwrap();
assert_eq!(duration.num_hours(), hours);
}
#[test]
fn parse_from_str_fails_with_invalid_input(s in "\\PC*") {
if !DURATION_RE.is_match(&s) {
assert!(NonNegativeDuration::parse_from_str(&s).is_err())
}
}
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use proptest::prelude::*;
use super::NonNegativeDuration;
proptest! {
#[test]
fn serde_roundtrip(d in crate::duration::test_utils::non_negative_duration()) {
let json = serde_json::to_string(&d).unwrap();
let deserialized: NonNegativeDuration = serde_json::from_str(&json).unwrap();
assert_eq!(d, deserialized);
}
}
}