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//!
73//! Or using numbers (number version), which represent the amount of seconds:
74//! ```
75//! # #[cfg(feature = "validator")]
76//! # {
77//! use duration_flex::DurationFlex;
78//! use validator::Validate;
79//!
80//! #[derive(Validate)]
81//! struct Config {
82//! 	#[validate(range(min = 3600, max = 7200))]
83//! 	timeout: DurationFlex,
84//! }
85//! # }
86//! ```
87
88use std::fmt::{Display, Formatter};
89use std::ops::{Add, Sub};
90use std::str::FromStr;
91use std::time;
92
93use chrono::{DateTime, Duration, TimeZone};
94#[cfg(feature = "clap")]
95use clap::builder::OsStr;
96use once_cell::sync::Lazy;
97use regex::{Match, Regex};
98#[cfg(feature = "serde")]
99use serde::de::{Error, Unexpected, Visitor};
100#[cfg(feature = "serde")]
101use serde::{Deserialize, Deserializer, Serialize, Serializer};
102
103const SECS_PER_MINUTES: i64 = 60;
104const SECS_PER_HOUR: i64 = 60 * SECS_PER_MINUTES;
105const SECS_PER_DAY: i64 = 24 * SECS_PER_HOUR;
106const SECS_PER_WEEK: i64 = 7 * SECS_PER_DAY;
107
108/// Errors returned by the different methods.
109#[derive(Copy, Clone, Debug)]
110pub enum DurationFlexError {
111	/// String format is not valid, e.g. `1y` (`y` is not supported).
112	InvalidFormat,
113
114	/// Value is out of range.
115	OutOfRange,
116}
117
118/// Type to conveniently specify durations and interoperate with [`chrono::Duration`].
119///
120/// The correct way of building this, is through one of the `from` methods.
121///
122/// With the `clap` feature, can be used with [`clap`]:
123/// ```
124/// use clap::Args;
125/// use duration_flex::DurationFlex;
126///
127/// #[derive(Args)]
128/// pub struct Arguments {
129/// 	#[arg(long, default_value_t = Arguments::default().duration)]
130/// 	duration: DurationFlex,
131/// }
132///
133/// impl Default for Arguments {
134/// 	fn default() -> Self {
135/// 		Self { duration: DurationFlex::try_from("1w6d23h49m59s").unwrap() }
136/// 	}
137/// }
138/// ```
139#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
140pub struct DurationFlex {
141	secs: i64,
142	nanos: i32,
143}
144
145#[cfg(feature = "validator")]
146impl validator::ValidateRange<DurationFlex> for DurationFlex {
147	fn greater_than(&self, max: DurationFlex) -> Option<bool> {
148		Some(self > &max)
149	}
150
151	fn less_than(&self, min: DurationFlex) -> Option<bool> {
152		Some(self < &min)
153	}
154}
155
156#[cfg(feature = "validator")]
157impl validator::ValidateRange<&str> for DurationFlex {
158	fn greater_than(&self, max: &str) -> Option<bool> {
159		let max = DurationFlex::try_from(max).expect("invalid duration string in validator bounds");
160		Some(self > &max)
161	}
162
163	fn less_than(&self, min: &str) -> Option<bool> {
164		let min = DurationFlex::try_from(min).expect("invalid duration string in validator bounds");
165		Some(self < &min)
166	}
167}
168
169#[cfg(feature = "validator")]
170impl validator::ValidateRange<i64> for DurationFlex {
171	fn greater_than(&self, max: i64) -> Option<bool> {
172		Some(self.secs > max)
173	}
174
175	fn less_than(&self, min: i64) -> Option<bool> {
176		Some(self.secs < min)
177	}
178}
179
180static REGEX_STR: &str =
181	r"^((?P<weeks>\d+)w)?((?P<days>\d+)d)?((?P<hours>\d+)h)?((?P<minutes>\d+)m)?((?P<seconds>\d+)s)?$";
182
183static REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(REGEX_STR).unwrap());
184
185impl DurationFlex {
186	/// Whole seconds.
187	pub fn secs(&self) -> i64 {
188		self.secs
189	}
190
191	/// Nano-seconds.
192	pub fn nanos(&self) -> i32 {
193		self.nanos
194	}
195
196	fn de_component(r#match: Match) -> i64 {
197		r#match.as_str().parse().unwrap()
198	}
199
200	fn ser_component(secs: &mut i64, component: &str, component_secs: i64, f: &mut Formatter<'_>) -> std::fmt::Result {
201		let value = *secs / component_secs;
202		*secs -= value * component_secs;
203
204		if value == 0 {
205			Ok(())
206		} else {
207			write!(f, "{}{}", value, component)
208		}
209	}
210}
211
212impl Sub<Duration> for DurationFlex {
213	type Output = Duration;
214
215	fn sub(self, rhs: Duration) -> Self::Output {
216		Duration::from(self) - rhs
217	}
218}
219
220impl Add<Duration> for DurationFlex {
221	type Output = Duration;
222
223	fn add(self, rhs: Duration) -> Self::Output {
224		Duration::from(self) + rhs
225	}
226}
227
228impl<T> Add<DateTime<T>> for DurationFlex
229where
230	T: TimeZone,
231{
232	type Output = DateTime<T>;
233
234	fn add(self, rhs: DateTime<T>) -> Self::Output {
235		rhs + Duration::from(self)
236	}
237}
238
239impl<T> Add<DurationFlex> for DateTime<T>
240where
241	T: TimeZone,
242{
243	type Output = DateTime<T>;
244
245	fn add(self, rhs: DurationFlex) -> Self::Output {
246		self + Duration::from(rhs)
247	}
248}
249
250impl TryFrom<&str> for DurationFlex {
251	type Error = DurationFlexError;
252
253	fn try_from(value: &str) -> Result<Self, Self::Error> {
254		let captures = REGEX.captures(value).ok_or(DurationFlexError::InvalidFormat)?;
255
256		let weeks = Duration::try_weeks(captures.name("weeks").map_or(0i64, Self::de_component))
257			.ok_or(DurationFlexError::OutOfRange)?;
258		let days = Duration::try_days(captures.name("days").map_or(0i64, Self::de_component))
259			.ok_or(DurationFlexError::OutOfRange)?;
260		let hours = Duration::try_hours(captures.name("hours").map_or(0i64, Self::de_component))
261			.ok_or(DurationFlexError::OutOfRange)?;
262		let minutes = Duration::try_minutes(captures.name("minutes").map_or(0i64, Self::de_component))
263			.ok_or(DurationFlexError::OutOfRange)?;
264		let seconds = Duration::try_seconds(captures.name("seconds").map_or(0i64, Self::de_component))
265			.ok_or(DurationFlexError::OutOfRange)?;
266
267		let duration = weeks + days + hours + minutes + seconds;
268
269		Ok(DurationFlex { secs: duration.num_seconds(), nanos: 0i32 })
270	}
271}
272
273impl From<String> for DurationFlex {
274	fn from(value: String) -> Self {
275		DurationFlex::try_from(value.as_str()).unwrap()
276	}
277}
278
279impl From<Duration> for DurationFlex {
280	fn from(value: Duration) -> Self {
281		DurationFlex { secs: value.num_seconds(), nanos: 0i32 }
282	}
283}
284
285impl From<DurationFlex> for Duration {
286	fn from(value: DurationFlex) -> Self {
287		Duration::try_seconds(value.secs()).unwrap() + Duration::nanoseconds(value.nanos() as i64)
288	}
289}
290
291impl From<time::Duration> for DurationFlex {
292	fn from(value: time::Duration) -> Self {
293		DurationFlex { secs: value.as_secs() as i64, nanos: 0i32 }
294	}
295}
296
297impl From<DurationFlex> for time::Duration {
298	fn from(value: DurationFlex) -> Self {
299		time::Duration::from_secs(value.secs as u64).add(time::Duration::from_nanos(value.nanos as u64))
300	}
301}
302
303impl Display for DurationFlex {
304	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
305		let mut secs = self.secs;
306
307		Self::ser_component(&mut secs, "w", SECS_PER_WEEK, f)?;
308		Self::ser_component(&mut secs, "d", SECS_PER_DAY, f)?;
309		Self::ser_component(&mut secs, "h", SECS_PER_HOUR, f)?;
310		Self::ser_component(&mut secs, "m", SECS_PER_MINUTES, f)?;
311		Self::ser_component(&mut secs, "s", 1, f)
312	}
313}
314
315#[cfg(feature = "serde")]
316impl<'de> Deserialize<'de> for DurationFlex {
317	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
318	where
319		D: Deserializer<'de>,
320	{
321		static REGEX_MSG: &str =
322			"a String with the format weeks (w), days (d), hours (h), minutes (m) and/or seconds (s), in order";
323
324		struct DurationFlexVisitor;
325
326		impl<'de> Visitor<'de> for DurationFlexVisitor {
327			type Value = DurationFlex;
328
329			fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
330				formatter.write_str(REGEX_MSG)
331			}
332
333			fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
334			where
335				E: Error,
336			{
337				match DurationFlex::try_from(v) {
338					Ok(value) => Ok(value),
339					Err(DurationFlexError::InvalidFormat) => Err(Error::invalid_value(Unexpected::Str(v), &self)),
340					Err(DurationFlexError::OutOfRange) => Err(Error::invalid_value(Unexpected::Str(v), &self)),
341				}
342			}
343
344			fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
345			where
346				E: Error,
347			{
348				self.visit_str(v)
349			}
350
351			fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
352			where
353				E: Error,
354			{
355				match DurationFlex::try_from(v.as_str()) {
356					Ok(value) => Ok(value),
357					Err(DurationFlexError::InvalidFormat) => {
358						Err(Error::invalid_value(Unexpected::Str(v.as_str()), &self))
359					},
360					Err(DurationFlexError::OutOfRange) => Err(Error::invalid_value(Unexpected::Str(v.as_str()), &self)),
361				}
362			}
363		}
364
365		deserializer.deserialize_string(DurationFlexVisitor)
366	}
367}
368
369#[cfg(feature = "serde")]
370impl Serialize for DurationFlex {
371	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
372	where
373		S: Serializer,
374	{
375		serializer.serialize_str(format!("{}", self).as_str())
376	}
377}
378
379#[cfg(feature = "clap")]
380impl From<OsStr> for DurationFlex {
381	fn from(value: OsStr) -> Self {
382		DurationFlex::try_from(value.to_str().unwrap()).unwrap()
383	}
384}
385
386#[cfg(feature = "clap")]
387impl From<DurationFlex> for OsStr {
388	fn from(value: DurationFlex) -> Self {
389		format!("{}", value).into()
390	}
391}
392
393impl FromStr for DurationFlex {
394	type Err = DurationFlexError;
395
396	fn from_str(s: &str) -> Result<Self, Self::Err> {
397		DurationFlex::try_from(s)
398	}
399}
400
401#[cfg(test)]
402mod test {
403
404	use serde::{Deserialize, Serialize};
405	use serde_test::{assert_de_tokens, assert_ser_tokens, Token};
406
407	use super::*;
408
409	#[test]
410	fn de_string() {
411		let value = DurationFlex::try_from("1w2d").unwrap();
412		assert_eq!(value.secs(), 9 * SECS_PER_DAY);
413		assert_eq!(value.nanos(), 0);
414
415		let value = DurationFlex::try_from("1w2d3h4m5s").unwrap();
416		assert_eq!(value.secs(), 9 * SECS_PER_DAY + 3 * SECS_PER_HOUR + 4 * SECS_PER_MINUTES + 5);
417		assert_eq!(value.nanos(), 0);
418
419		let value = DurationFlex::try_from("5s").unwrap();
420		assert_eq!(value.secs(), 5);
421		assert_eq!(value.nanos(), 0);
422
423		let value = DurationFlex::try_from("5s5d");
424		assert!(value.is_err());
425	}
426
427	#[test]
428	fn ser_string() {
429		let value = DurationFlex::try_from("1w2d").unwrap().to_string();
430		assert_eq!(value, "1w2d");
431
432		let value = DurationFlex::try_from("1w2d3h4m5s").unwrap().to_string();
433		assert_eq!(value, "1w2d3h4m5s");
434
435		let value = DurationFlex::try_from("5s").unwrap().to_string();
436		assert_eq!(value, "5s");
437
438		let value = DurationFlex::try_from("1w8d3h4m5s").unwrap().to_string();
439		assert_eq!(value, "2w1d3h4m5s");
440
441		let value = DurationFlex::try_from("1w8d3h4m3605s").unwrap().to_string();
442		assert_eq!(value, "2w1d4h4m5s");
443	}
444
445	#[test]
446	fn deserialize_nums() {
447		let value = DurationFlex::try_from("1w2d").unwrap();
448		assert_de_tokens(&value, &[Token::Str("1w2d")]);
449
450		let value = DurationFlex::try_from("1w2d3h4m5s").unwrap();
451		assert_de_tokens(&value, &[Token::Str("1w2d3h4m5s")]);
452
453		let value = DurationFlex::try_from("5s").unwrap();
454		assert_de_tokens(&value, &[Token::Str("5s")]);
455
456		let value = DurationFlex::try_from("1w8d3h4m5s").unwrap();
457		assert_de_tokens(&value, &[Token::Str("2w1d3h4m5s")]);
458
459		let value = DurationFlex::try_from("1w8d3h4m3605s").unwrap();
460		assert_de_tokens(&value, &[Token::Str("2w1d4h4m5s")]);
461	}
462
463	#[test]
464	fn serialize() {
465		let value = DurationFlex::try_from("1w2d").unwrap();
466		assert_ser_tokens(&value, &[Token::Str("1w2d")]);
467
468		let value = DurationFlex::try_from("1w2d3h4m5s").unwrap();
469		assert_ser_tokens(&value, &[Token::Str("1w2d3h4m5s")]);
470
471		let value = DurationFlex::try_from("5s").unwrap();
472		assert_ser_tokens(&value, &[Token::Str("5s")]);
473
474		let value = DurationFlex::try_from("1w8d3h4m5s").unwrap();
475		assert_ser_tokens(&value, &[Token::Str("2w1d3h4m5s")]);
476
477		let value = DurationFlex::try_from("1w8d3h4m3605s").unwrap();
478		assert_ser_tokens(&value, &[Token::Str("2w1d4h4m5s")]);
479	}
480
481	#[test]
482	fn in_struct() {
483		#[derive(Serialize, Deserialize)]
484		struct SomeStruct {
485			duration: DurationFlex,
486		}
487
488		let value = SomeStruct { duration: Duration::try_weeks(1).unwrap().into() };
489
490		assert_ser_tokens(
491			&value,
492			&[Token::Struct { name: "SomeStruct", len: 1 }, Token::Str("duration"), Token::Str("1w"), Token::StructEnd],
493		);
494	}
495
496	#[cfg(feature = "validator")]
497	#[test]
498	fn validator() {
499		use validator::Validate;
500
501		#[derive(Validate)]
502		struct SomeStruct {
503			#[validate(range(
504				min = "DurationFlex::try_from(\"1h\").unwrap()",
505				max = "DurationFlex::try_from(\"2h\").unwrap()"
506			))]
507			duration: DurationFlex,
508		}
509
510		let value = SomeStruct { duration: DurationFlex::try_from("1h30m").unwrap() };
511		assert!(value.validate().is_ok());
512
513		let value = SomeStruct { duration: DurationFlex::try_from("30m").unwrap() };
514		assert!(value.validate().is_err());
515
516		let value = SomeStruct { duration: DurationFlex::try_from("2h30m").unwrap() };
517		assert!(value.validate().is_err());
518	}
519
520	#[cfg(feature = "validator")]
521	#[test]
522	fn validator_str() {
523		use validator::Validate;
524
525		#[derive(Validate)]
526		struct SomeStruct {
527			#[validate(range(min = "\"1h\"", max = "\"2h\""))]
528			duration: DurationFlex,
529		}
530
531		let value = SomeStruct { duration: DurationFlex::try_from("1h30m").unwrap() };
532		assert!(value.validate().is_ok());
533
534		let value = SomeStruct { duration: DurationFlex::try_from("30m").unwrap() };
535		assert!(value.validate().is_err());
536
537		let value = SomeStruct { duration: DurationFlex::try_from("2h30m").unwrap() };
538		assert!(value.validate().is_err());
539	}
540
541	#[cfg(feature = "validator")]
542	#[test]
543	fn validator_int() {
544		use validator::Validate;
545
546		#[derive(Validate)]
547		struct SomeStruct {
548			#[validate(range(min = 3600, max = 7200))]
549			duration: DurationFlex,
550		}
551
552		let value = SomeStruct { duration: DurationFlex::try_from("1h30m").unwrap() };
553		assert!(value.validate().is_ok());
554
555		let value = SomeStruct { duration: DurationFlex::try_from("30m").unwrap() };
556		assert!(value.validate().is_err());
557
558		let value = SomeStruct { duration: DurationFlex::try_from("2h30m").unwrap() };
559		assert!(value.validate().is_err());
560	}
561}