use std::{fmt, str::FromStr, sync::LazyLock, time::Duration as stdDuration};
use kube::core::Duration as k8sDuration;
use regex::Regex;
#[derive(Copy, Clone, PartialEq, Eq)]
pub struct Duration(stdDuration);
const GEP2257_PATTERN: &str = r"^([0-9]{1,5}(h|m|s|ms)){1,4}$";
const MAX_DURATION_MS: u128 = (((99999 * 3600) + (59 * 60) + 59) * 1_000) + 999;
#[cfg(test)]
#[allow(clippy::cast_possible_truncation)]
const MAX_DURATION_MS_U64: u64 = MAX_DURATION_MS as u64;
pub fn is_valid(duration: stdDuration) -> Result<(), String> {
if !duration.subsec_nanos().is_multiple_of(1_000_000) {
return Err("Cannot express sub-millisecond precision in GEP-2257".to_string());
}
if duration.as_millis() > MAX_DURATION_MS {
return Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string());
}
Ok(())
}
impl TryFrom<stdDuration> for Duration {
type Error = String;
fn try_from(duration: stdDuration) -> Result<Self, Self::Error> {
is_valid(duration)?;
Ok(Duration(duration))
}
}
impl TryFrom<k8sDuration> for Duration {
type Error = String;
fn try_from(duration: k8sDuration) -> Result<Self, Self::Error> {
if duration.is_negative() {
return Err("Duration cannot be negative".to_string());
}
let stddur = stdDuration::from(duration);
is_valid(stddur)?;
Ok(Duration(stddur))
}
}
impl Duration {
pub fn new(secs: u64, nanos: u32) -> Result<Self, String> {
let stddur = stdDuration::new(secs, nanos);
is_valid(stddur)?;
Ok(Self(stddur))
}
pub fn from_secs(secs: u64) -> Result<Self, String> {
Self::new(secs, 0)
}
pub fn from_micros(micros: u64) -> Result<Self, String> {
let sec = micros / 1_000_000;
#[allow(clippy::cast_possible_truncation)]
let ns = ((micros % 1_000_000) * 1_000) as u32;
Self::new(sec, ns)
}
pub fn from_millis(millis: u64) -> Result<Self, String> {
let sec = millis / 1_000;
#[allow(clippy::cast_possible_truncation)]
let ns = ((millis % 1_000) * 1_000_000) as u32;
Self::new(sec, ns)
}
pub fn as_secs(&self) -> u64 {
self.0.as_secs()
}
pub fn as_millis(&self) -> u128 {
self.0.as_millis()
}
pub fn as_nanos(&self) -> u128 {
self.0.as_nanos()
}
pub fn subsec_nanos(&self) -> u32 {
self.0.subsec_nanos()
}
pub fn is_zero(&self) -> bool {
self.0.is_zero()
}
}
impl FromStr for Duration {
type Err = String;
fn from_str(duration_str: &str) -> Result<Self, Self::Err> {
static RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(GEP2257_PATTERN)
.unwrap_or_else(|_| panic!(r#"GEP2257 regex "{GEP2257_PATTERN}" did not compile (this is a bug!)"#))
});
if !RE.is_match(duration_str) {
return Err("Invalid duration format".to_string());
}
match k8sDuration::from_str(duration_str) {
Err(err) => Err(err.to_string()),
Ok(kd) => Duration::try_from(kd),
}
}
}
impl fmt::Display for Duration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_zero() {
return write!(f, "0s");
}
let ms = self.subsec_nanos() / 1_000_000;
let mut secs = self.as_secs();
let hours = secs / 3600;
if hours > 0 {
secs -= hours * 3600;
write!(f, "{hours}h")?;
}
let minutes = secs / 60;
if minutes > 0 {
secs -= minutes * 60;
write!(f, "{minutes}m")?;
}
if secs > 0 {
write!(f, "{secs}s")?;
}
if ms > 0 {
write!(f, "{ms}ms")?;
}
Ok(())
}
}
impl fmt::Debug for Duration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gep2257_from_valid_duration() {
let test_cases = vec![
Duration::from_secs(0), Duration::from_secs(3600), Duration::from_secs(1800), Duration::from_secs(10), Duration::from_millis(500), Duration::from_secs(9000), Duration::from_secs(5410), Duration::new(7200, 600_000_000), Duration::new(7200 + 1800, 600_000_000), Duration::new(7200 + 1800 + 10, 600_000_000), Duration::from_millis(MAX_DURATION_MS_U64), ];
for (idx, duration) in test_cases.iter().enumerate() {
assert!(duration.is_ok(), "{idx:?}: Duration {duration:?} should be OK");
}
}
#[test]
fn test_gep2257_from_invalid_duration() {
let test_cases = vec![
(
Duration::from_micros(100),
Err("Cannot express sub-millisecond precision in GEP-2257".to_string()),
),
(
Duration::from_secs(10000 * 86400),
Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
),
(
Duration::from_millis(MAX_DURATION_MS_U64 + 1),
Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
),
];
for (idx, (duration, expected)) in test_cases.into_iter().enumerate() {
assert_eq!(duration, expected, "{idx:?}: Duration {duration:?} should be an error");
}
}
#[test]
fn test_gep2257_from_valid_k8s_duration() {
let test_cases = vec![
(k8sDuration::from_str("0s").unwrap(), Duration::from_secs(0).unwrap()),
(k8sDuration::from_str("1h").unwrap(), Duration::from_secs(3600).unwrap()),
(
k8sDuration::from_str("500ms").unwrap(),
Duration::from_millis(500).unwrap(),
),
(
k8sDuration::from_str("2h600ms").unwrap(),
Duration::new(7200, 600_000_000).unwrap(),
),
];
for (idx, (k8s_duration, expected)) in test_cases.into_iter().enumerate() {
let duration = Duration::try_from(k8s_duration);
assert!(
duration.as_ref().is_ok_and(|d| *d == expected),
"{idx:?}: Duration {duration:?} should be {expected:?}",
);
}
}
#[test]
fn test_gep2257_from_invalid_k8s_duration() {
let test_cases: Vec<(k8sDuration, Result<Duration, String>)> = vec![
(
k8sDuration::from_str("100us").unwrap(),
Err("Cannot express sub-millisecond precision in GEP-2257".to_string()),
),
(
k8sDuration::from_str("100000h").unwrap(),
Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
),
(
k8sDuration::from(stdDuration::from_millis(MAX_DURATION_MS_U64 + 1)),
Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
),
(
k8sDuration::from_str("-5s").unwrap(),
Err("Duration cannot be negative".to_string()),
),
];
for (idx, (k8s_duration, expected)) in test_cases.into_iter().enumerate() {
assert_eq!(
Duration::try_from(k8s_duration),
expected,
"{idx:?}: k8sDuration {k8s_duration:?} should be error {expected:?}",
);
}
}
#[test]
fn test_gep2257_from_str() {
let test_cases = vec![
("0h", Duration::from_secs(0)),
("0s", Duration::from_secs(0)),
("0h0m0s", Duration::from_secs(0)),
("1h", Duration::from_secs(3600)),
("30m", Duration::from_secs(1800)),
("10s", Duration::from_secs(10)),
("500ms", Duration::from_millis(500)),
("2h30m", Duration::from_secs(9000)),
("150m", Duration::from_secs(9000)),
("7230s", Duration::from_secs(7230)),
("1h30m10s", Duration::from_secs(5410)),
("10s30m1h", Duration::from_secs(5410)),
("100ms200ms300ms", Duration::from_millis(600)),
("100ms200ms300ms", Duration::from_millis(600)),
("99999h59m59s999ms", Duration::from_millis(MAX_DURATION_MS_U64)),
("1d", Err("Invalid duration format".to_string())),
("1", Err("Invalid duration format".to_string())),
("1m1", Err("Invalid duration format".to_string())),
("1h30m10s20ms50h", Err("Invalid duration format".to_string())),
("999999h", Err("Invalid duration format".to_string())),
("1.5h", Err("Invalid duration format".to_string())),
("-15m", Err("Invalid duration format".to_string())),
(
"99999h59m59s1000ms",
Err("Duration exceeds GEP-2257 maximum 99999h59m59s999ms".to_string()),
),
];
for (idx, (duration_str, expected)) in test_cases.into_iter().enumerate() {
assert_eq!(
Duration::from_str(duration_str),
expected,
"{idx:?}: Duration {duration_str:?} should be {expected:?}",
);
}
}
#[test]
fn test_gep2257_format() {
let test_cases = vec![
(Duration::from_secs(0), "0s".to_string()),
(Duration::from_secs(3600), "1h".to_string()),
(Duration::from_secs(1800), "30m".to_string()),
(Duration::from_secs(10), "10s".to_string()),
(Duration::from_millis(500), "500ms".to_string()),
(Duration::from_secs(9000), "2h30m".to_string()),
(Duration::from_secs(5410), "1h30m10s".to_string()),
(Duration::from_millis(600), "600ms".to_string()),
(Duration::new(7200, 600_000_000), "2h600ms".to_string()),
(Duration::new(7200 + 1800, 600_000_000), "2h30m600ms".to_string()),
(
Duration::new(7200 + 1800 + 10, 600_000_000),
"2h30m10s600ms".to_string(),
),
];
for (idx, (duration, expected)) in test_cases.into_iter().enumerate() {
assert!(
duration.as_ref().is_ok_and(|d| format!("{d}") == expected),
"{idx:?}: Duration {duration:?} should be {expected:?}",
);
}
}
}