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