Skip to main content

duration_flex/
lib.rs

1#![allow(clippy::tabs_in_doc_comments)]
2//! # Duration Flex
3//!
4//! Helper to make it easier to specify duration files. Specially useful in configuration files.
5//! - Basic interoperability with [`chrono::DateTime`], allowing it to be added/subbed from it.
6//! - Can be built from [`chrono::Duration`].
7//! - Can be built from [`std::time::Duration`].
8//!
9//! **Example:**
10//! - 1 hour and 23 minutes: `1h23m`
11//! - 1 week, 6 days, 23 hours, 49 minutes andd 50 seconds: `1w6d23h49m59s`
12//!
13//! **Supported Time Units**
14//! - Weeks: `2w` (2 weeks).
15//! - Days: `3d` (3 days).
16//! - Hours: `15h` (15 hours).
17//! - Minutes: `5m` (5 minutes).
18//! - Seconds: `30s` (30 seconds).
19//!
20//! ## Usage
21//!
22//! Simply call one of the `from` methods to create an instance:
23//! ```
24//! use duration_flex::DurationFlex;
25//!
26//! # pub fn main() {
27//! let df = DurationFlex::try_from("1w6d23h49m59s").unwrap();
28//! println!("{df}");
29//! # }
30//! ```
31//!
32//! ## Features
33//! - `clap`: enable clap support, so it can be used as application arguments.
34//! - `serde`: enable serde support.
35//! - `validator`: enable support for the [`validator`] crate, allowing it to be used with the `range` validator.
36//!
37//! ### Validator Example:
38//!
39//! You can specify the range using the fully qualified type (extended version):
40//! ```
41//! # #[cfg(feature = "validator")]
42//! # {
43//! use duration_flex::DurationFlex;
44//! use validator::Validate;
45//!
46//! #[derive(Validate)]
47//! struct Config {
48//! 	#[validate(range(
49//! 		min = "DurationFlex::try_from(\"1h\").unwrap()",
50//! 		max = "DurationFlex::try_from(\"2h\").unwrap()"
51//! 	))]
52//! 	timeout: DurationFlex,
53//! }
54//! # }
55//! ```
56//!
57//! Or using string literals (string version). Note the escaped inner quotes, which are required
58//! because the macro parses the arguments as Rust expressions:
59//! ```
60//! # #[cfg(feature = "validator")]
61//! # {
62//! use duration_flex::DurationFlex;
63//! use validator::Validate;
64//!
65//! #[derive(Validate)]
66//! struct Config {
67//! 	#[validate(range(min = "\"1h\"", max = "\"2h\""))]
68//! 	timeout: DurationFlex,
69//! }
70//! # }
71//! ```
72
73use std::fmt::{Display, Formatter};
74use std::ops::{Add, Sub};
75use std::str::FromStr;
76use std::time;
77
78use chrono::{DateTime, Duration, TimeZone};
79#[cfg(feature = "clap")]
80use clap::builder::OsStr;
81use once_cell::sync::Lazy;
82use regex::{Match, Regex};
83#[cfg(feature = "serde")]
84use serde::de::{Error, Unexpected, Visitor};
85#[cfg(feature = "serde")]
86use serde::{Deserialize, Deserializer, Serialize, Serializer};
87
88const SECS_PER_MINUTES: i64 = 60;
89const SECS_PER_HOUR: i64 = 60 * SECS_PER_MINUTES;
90const SECS_PER_DAY: i64 = 24 * SECS_PER_HOUR;
91const SECS_PER_WEEK: i64 = 7 * SECS_PER_DAY;
92
93/// Errors returned by the different methods.
94#[derive(Copy, Clone, Debug)]
95pub enum DurationFlexError {
96	/// String format is not valid, e.g. `1y` (`y` is not supported).
97	InvalidFormat,
98
99	/// Value is out of range.
100	OutOfRange,
101}
102
103/// Type to conveniently specify durations and interoperate with [`chrono::Duration`].
104///
105/// The correct way of building this, is through one of the `from` methods.
106///
107/// With the `clap` feature, can be used with [`clap`]:
108/// ```
109/// use clap::Args;
110/// use duration_flex::DurationFlex;
111///
112/// #[derive(Args)]
113/// pub struct Arguments {
114/// 	#[arg(long, default_value_t = Arguments::default().duration)]
115/// 	duration: DurationFlex,
116/// }
117///
118/// impl Default for Arguments {
119/// 	fn default() -> Self {
120/// 		Self { duration: DurationFlex::try_from("1w6d23h49m59s").unwrap() }
121/// 	}
122/// }
123/// ```
124#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
125pub struct DurationFlex {
126	secs: i64,
127	nanos: i32,
128}
129
130#[cfg(feature = "validator")]
131impl validator::ValidateRange<DurationFlex> for DurationFlex {
132	fn greater_than(&self, max: DurationFlex) -> Option<bool> {
133		Some(self > &max)
134	}
135
136	fn less_than(&self, min: DurationFlex) -> Option<bool> {
137		Some(self < &min)
138	}
139}
140
141#[cfg(feature = "validator")]
142impl validator::ValidateRange<&str> for DurationFlex {
143	fn greater_than(&self, max: &str) -> Option<bool> {
144		let max = DurationFlex::try_from(max).expect("invalid duration string in validator bounds");
145		Some(self > &max)
146	}
147
148	fn less_than(&self, min: &str) -> Option<bool> {
149		let min = DurationFlex::try_from(min).expect("invalid duration string in validator bounds");
150		Some(self < &min)
151	}
152}
153
154static REGEX_STR: &str =
155	r"^((?P<weeks>\d+)w)?((?P<days>\d+)d)?((?P<hours>\d+)h)?((?P<minutes>\d+)m)?((?P<seconds>\d+)s)?$";
156
157static REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(REGEX_STR).unwrap());
158
159impl DurationFlex {
160	/// Whole seconds.
161	pub fn secs(&self) -> i64 {
162		self.secs
163	}
164
165	/// Nano-seconds.
166	pub fn nanos(&self) -> i32 {
167		self.nanos
168	}
169
170	fn de_component(r#match: Match) -> i64 {
171		r#match.as_str().parse().unwrap()
172	}
173
174	fn ser_component(secs: &mut i64, component: &str, component_secs: i64, f: &mut Formatter<'_>) -> std::fmt::Result {
175		let value = *secs / component_secs;
176		*secs -= value * component_secs;
177
178		if value == 0 {
179			Ok(())
180		} else {
181			write!(f, "{}{}", value, component)
182		}
183	}
184}
185
186impl Sub<Duration> for DurationFlex {
187	type Output = Duration;
188
189	fn sub(self, rhs: Duration) -> Self::Output {
190		Duration::from(self) - rhs
191	}
192}
193
194impl Add<Duration> for DurationFlex {
195	type Output = Duration;
196
197	fn add(self, rhs: Duration) -> Self::Output {
198		Duration::from(self) + rhs
199	}
200}
201
202impl<T> Add<DateTime<T>> for DurationFlex
203where
204	T: TimeZone,
205{
206	type Output = DateTime<T>;
207
208	fn add(self, rhs: DateTime<T>) -> Self::Output {
209		rhs + Duration::from(self)
210	}
211}
212
213impl<T> Add<DurationFlex> for DateTime<T>
214where
215	T: TimeZone,
216{
217	type Output = DateTime<T>;
218
219	fn add(self, rhs: DurationFlex) -> Self::Output {
220		self + Duration::from(rhs)
221	}
222}
223
224impl TryFrom<&str> for DurationFlex {
225	type Error = DurationFlexError;
226
227	fn try_from(value: &str) -> Result<Self, Self::Error> {
228		let captures = REGEX.captures(value).ok_or(DurationFlexError::InvalidFormat)?;
229
230		let weeks = Duration::try_weeks(captures.name("weeks").map_or(0i64, Self::de_component))
231			.ok_or(DurationFlexError::OutOfRange)?;
232		let days = Duration::try_days(captures.name("days").map_or(0i64, Self::de_component))
233			.ok_or(DurationFlexError::OutOfRange)?;
234		let hours = Duration::try_hours(captures.name("hours").map_or(0i64, Self::de_component))
235			.ok_or(DurationFlexError::OutOfRange)?;
236		let minutes = Duration::try_minutes(captures.name("minutes").map_or(0i64, Self::de_component))
237			.ok_or(DurationFlexError::OutOfRange)?;
238		let seconds = Duration::try_seconds(captures.name("seconds").map_or(0i64, Self::de_component))
239			.ok_or(DurationFlexError::OutOfRange)?;
240
241		let duration = weeks + days + hours + minutes + seconds;
242
243		Ok(DurationFlex { secs: duration.num_seconds(), nanos: 0i32 })
244	}
245}
246
247impl From<String> for DurationFlex {
248	fn from(value: String) -> Self {
249		DurationFlex::try_from(value.as_str()).unwrap()
250	}
251}
252
253impl From<Duration> for DurationFlex {
254	fn from(value: Duration) -> Self {
255		DurationFlex { secs: value.num_seconds(), nanos: 0i32 }
256	}
257}
258
259impl From<DurationFlex> for Duration {
260	fn from(value: DurationFlex) -> Self {
261		Duration::try_seconds(value.secs()).unwrap() + Duration::nanoseconds(value.nanos() as i64)
262	}
263}
264
265impl From<time::Duration> for DurationFlex {
266	fn from(value: time::Duration) -> Self {
267		DurationFlex { secs: value.as_secs() as i64, nanos: 0i32 }
268	}
269}
270
271impl From<DurationFlex> for time::Duration {
272	fn from(value: DurationFlex) -> Self {
273		time::Duration::from_secs(value.secs as u64).add(time::Duration::from_nanos(value.nanos as u64))
274	}
275}
276
277impl Display for DurationFlex {
278	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
279		let mut secs = self.secs;
280
281		Self::ser_component(&mut secs, "w", SECS_PER_WEEK, f)?;
282		Self::ser_component(&mut secs, "d", SECS_PER_DAY, f)?;
283		Self::ser_component(&mut secs, "h", SECS_PER_HOUR, f)?;
284		Self::ser_component(&mut secs, "m", SECS_PER_MINUTES, f)?;
285		Self::ser_component(&mut secs, "s", 1, f)
286	}
287}
288
289#[cfg(feature = "serde")]
290impl<'de> Deserialize<'de> for DurationFlex {
291	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
292	where
293		D: Deserializer<'de>,
294	{
295		static REGEX_MSG: &str =
296			"a String with the format weeks (w), days (d), hours (h), minutes (m) and/or seconds (s), in order";
297
298		struct DurationFlexVisitor;
299
300		impl<'de> Visitor<'de> for DurationFlexVisitor {
301			type Value = DurationFlex;
302
303			fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
304				formatter.write_str(REGEX_MSG)
305			}
306
307			fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
308			where
309				E: Error,
310			{
311				match DurationFlex::try_from(v) {
312					Ok(value) => Ok(value),
313					Err(DurationFlexError::InvalidFormat) => Err(Error::invalid_value(Unexpected::Str(v), &self)),
314					Err(DurationFlexError::OutOfRange) => Err(Error::invalid_value(Unexpected::Str(v), &self)),
315				}
316			}
317
318			fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
319			where
320				E: Error,
321			{
322				self.visit_str(v)
323			}
324
325			fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
326			where
327				E: Error,
328			{
329				match DurationFlex::try_from(v.as_str()) {
330					Ok(value) => Ok(value),
331					Err(DurationFlexError::InvalidFormat) => {
332						Err(Error::invalid_value(Unexpected::Str(v.as_str()), &self))
333					},
334					Err(DurationFlexError::OutOfRange) => Err(Error::invalid_value(Unexpected::Str(v.as_str()), &self)),
335				}
336			}
337		}
338
339		deserializer.deserialize_string(DurationFlexVisitor)
340	}
341}
342
343#[cfg(feature = "serde")]
344impl Serialize for DurationFlex {
345	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
346	where
347		S: Serializer,
348	{
349		serializer.serialize_str(format!("{}", self).as_str())
350	}
351}
352
353#[cfg(feature = "clap")]
354impl From<OsStr> for DurationFlex {
355	fn from(value: OsStr) -> Self {
356		DurationFlex::try_from(value.to_str().unwrap()).unwrap()
357	}
358}
359
360#[cfg(feature = "clap")]
361impl From<DurationFlex> for OsStr {
362	fn from(value: DurationFlex) -> Self {
363		format!("{}", value).into()
364	}
365}
366
367impl FromStr for DurationFlex {
368	type Err = DurationFlexError;
369
370	fn from_str(s: &str) -> Result<Self, Self::Err> {
371		DurationFlex::try_from(s)
372	}
373}
374
375#[cfg(test)]
376mod test {
377
378	use serde::{Deserialize, Serialize};
379	use serde_test::{assert_de_tokens, assert_ser_tokens, Token};
380
381	use super::*;
382
383	#[test]
384	fn de_string() {
385		let value = DurationFlex::try_from("1w2d").unwrap();
386		assert_eq!(value.secs(), 9 * SECS_PER_DAY);
387		assert_eq!(value.nanos(), 0);
388
389		let value = DurationFlex::try_from("1w2d3h4m5s").unwrap();
390		assert_eq!(value.secs(), 9 * SECS_PER_DAY + 3 * SECS_PER_HOUR + 4 * SECS_PER_MINUTES + 5);
391		assert_eq!(value.nanos(), 0);
392
393		let value = DurationFlex::try_from("5s").unwrap();
394		assert_eq!(value.secs(), 5);
395		assert_eq!(value.nanos(), 0);
396
397		let value = DurationFlex::try_from("5s5d");
398		assert!(value.is_err());
399	}
400
401	#[test]
402	fn ser_string() {
403		let value = DurationFlex::try_from("1w2d").unwrap().to_string();
404		assert_eq!(value, "1w2d");
405
406		let value = DurationFlex::try_from("1w2d3h4m5s").unwrap().to_string();
407		assert_eq!(value, "1w2d3h4m5s");
408
409		let value = DurationFlex::try_from("5s").unwrap().to_string();
410		assert_eq!(value, "5s");
411
412		let value = DurationFlex::try_from("1w8d3h4m5s").unwrap().to_string();
413		assert_eq!(value, "2w1d3h4m5s");
414
415		let value = DurationFlex::try_from("1w8d3h4m3605s").unwrap().to_string();
416		assert_eq!(value, "2w1d4h4m5s");
417	}
418
419	#[test]
420	fn deserialize_nums() {
421		let value = DurationFlex::try_from("1w2d").unwrap();
422		assert_de_tokens(&value, &[Token::Str("1w2d")]);
423
424		let value = DurationFlex::try_from("1w2d3h4m5s").unwrap();
425		assert_de_tokens(&value, &[Token::Str("1w2d3h4m5s")]);
426
427		let value = DurationFlex::try_from("5s").unwrap();
428		assert_de_tokens(&value, &[Token::Str("5s")]);
429
430		let value = DurationFlex::try_from("1w8d3h4m5s").unwrap();
431		assert_de_tokens(&value, &[Token::Str("2w1d3h4m5s")]);
432
433		let value = DurationFlex::try_from("1w8d3h4m3605s").unwrap();
434		assert_de_tokens(&value, &[Token::Str("2w1d4h4m5s")]);
435	}
436
437	#[test]
438	fn serialize() {
439		let value = DurationFlex::try_from("1w2d").unwrap();
440		assert_ser_tokens(&value, &[Token::Str("1w2d")]);
441
442		let value = DurationFlex::try_from("1w2d3h4m5s").unwrap();
443		assert_ser_tokens(&value, &[Token::Str("1w2d3h4m5s")]);
444
445		let value = DurationFlex::try_from("5s").unwrap();
446		assert_ser_tokens(&value, &[Token::Str("5s")]);
447
448		let value = DurationFlex::try_from("1w8d3h4m5s").unwrap();
449		assert_ser_tokens(&value, &[Token::Str("2w1d3h4m5s")]);
450
451		let value = DurationFlex::try_from("1w8d3h4m3605s").unwrap();
452		assert_ser_tokens(&value, &[Token::Str("2w1d4h4m5s")]);
453	}
454
455	#[test]
456	fn in_struct() {
457		#[derive(Serialize, Deserialize)]
458		struct SomeStruct {
459			duration: DurationFlex,
460		}
461
462		let value = SomeStruct { duration: Duration::try_weeks(1).unwrap().into() };
463
464		assert_ser_tokens(
465			&value,
466			&[Token::Struct { name: "SomeStruct", len: 1 }, Token::Str("duration"), Token::Str("1w"), Token::StructEnd],
467		);
468	}
469
470	#[cfg(feature = "validator")]
471	#[test]
472	fn validator() {
473		use validator::Validate;
474
475		#[derive(Validate)]
476		struct SomeStruct {
477			#[validate(range(
478				min = "DurationFlex::try_from(\"1h\").unwrap()",
479				max = "DurationFlex::try_from(\"2h\").unwrap()"
480			))]
481			duration: DurationFlex,
482		}
483
484		let value = SomeStruct { duration: DurationFlex::try_from("1h30m").unwrap() };
485		assert!(value.validate().is_ok());
486
487		let value = SomeStruct { duration: DurationFlex::try_from("30m").unwrap() };
488		assert!(value.validate().is_err());
489
490		let value = SomeStruct { duration: DurationFlex::try_from("2h30m").unwrap() };
491		assert!(value.validate().is_err());
492	}
493
494	#[cfg(feature = "validator")]
495	#[test]
496	fn validator_str() {
497		use validator::Validate;
498
499		#[derive(Validate)]
500		struct SomeStruct {
501			#[validate(range(min = "\"1h\"", max = "\"2h\""))]
502			duration: DurationFlex,
503		}
504
505		let value = SomeStruct { duration: DurationFlex::try_from("1h30m").unwrap() };
506		assert!(value.validate().is_ok());
507
508		let value = SomeStruct { duration: DurationFlex::try_from("30m").unwrap() };
509		assert!(value.validate().is_err());
510
511		let value = SomeStruct { duration: DurationFlex::try_from("2h30m").unwrap() };
512		assert!(value.validate().is_err());
513	}
514}