duration_flex/
lib.rs

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