parse_interval/
lib.rs

1use std::sync::OnceLock;
2
3use chrono::{DateTime, Duration, Months, Utc};
4
5pub use errors::ParseError;
6use parse_bytes::ParseBytes;
7
8mod errors;
9mod parse_bytes;
10mod time_units;
11
12/// Parse an interval like "4 weeks 12 hours". It can include weeks, days, hours, minutes and seconds. It can not include years or months.
13pub fn simple(interval: &str) -> Result<Duration, ParseError> {
14	parse_interval(interval, None)
15}
16
17/// Parse an interval like "1 year 6 months".
18///
19/// It can include years, months, weeks, days, hours, minutes and seconds.
20///
21/// Years and months will be evaluated as offset from the specified date.
22///
23/// If you don't already have a date, it may be more efficient to use [`parse_interval_with_lazy_date`], since it avoids constructing it if it doesn't end up needing it (because there were no years or months).
24pub fn with_date(interval: &str, date: DateTime<Utc>) -> Result<Duration, ParseError> {
25	parse_interval(interval, Some(Box::new(move || date)))
26}
27
28/// Parse an interval like "1 year 6 months".
29///
30/// It can include years, months, weeks, days, hours, minutes and seconds.
31///
32/// Years and months will be evaluated as offset from the date generated by the function.
33pub fn with_lazy_date<D>(interval: &str, get_date: D) -> Result<Duration, ParseError>
34where
35	D: FnOnce() -> DateTime<Utc> + 'static,
36{
37	parse_interval(interval, Some(Box::new(get_date)))
38}
39
40/// Parse an interval like "1 year 6 months".
41///
42/// It can include years, months, weeks, days, hours, minutes and seconds.
43///
44/// Years and months will be evaluated as offset from the present (current system time).
45pub fn with_now(interval: &str) -> Result<Duration, ParseError> {
46	with_lazy_date(interval, Utc::now)
47}
48
49/// Parse an interval like "1 year 5 days".
50///
51/// If a date constructor is provided, it can include years and months. Either way it can include weeks, days, hours, minutes and seconds.
52///
53/// The years and months are evaluated as offset from the generated date.
54fn parse_interval(
55	interval: &str,
56	mut get_date: Option<Box<dyn FnOnce() -> DateTime<Utc>>>,
57) -> Result<Duration, ParseError> {
58	static PATTERNS: OnceLock<[time_units::TimeUnit; 7]> = OnceLock::new();
59	let units = PATTERNS.get_or_init(|| time_units::UNITS.map(|unit| unit.compile()));
60
61	let allow_inconstant = get_date.is_some();
62
63	let mut date = None;
64	let mut bytes = ParseBytes::from_str(interval);
65	let mut duration = Duration::seconds(0);
66	let mut offset_date = None;
67	let mut is_subtracting = false;
68	let mut unit_cursor = if allow_inconstant {
69		0
70	} else {
71		2 // Skip years and months
72	};
73	bytes.skip_spaces();
74	if bytes.is_empty() {
75		return Err(ParseError::Empty);
76	}
77	'outer: while !bytes.is_empty() {
78		if bytes.parse_minus() {
79			is_subtracting = !is_subtracting;
80			bytes.skip_spaces();
81		}
82		let (number, fraction) = bytes.parse_number()?;
83		bytes.skip_spaces();
84		for (unit_index, unit) in units.iter().enumerate().skip(unit_cursor) {
85			unit_cursor += 1;
86			if bytes.parse_regex(&unit.regex) {
87				match unit_index {
88					// Years
89					0 => {
90						if fraction > 0.0 {
91							return Err(ParseError::InconstantUnitWithFraction);
92						}
93						let date =
94							date.get_or_insert_with(|| get_date.take().map(|f| f()).unwrap());
95						let offset_date = offset_date.get_or_insert(*date);
96						let months = Months::new(
97							number
98								.checked_mul(12)
99								.ok_or(ParseError::NumberOutOfRange)?
100								.try_into()?,
101						);
102						*offset_date = if is_subtracting {
103							offset_date.checked_sub_months(months)
104						} else {
105							offset_date.checked_add_months(months)
106						}
107						.ok_or(ParseError::DateOutOfRange)?;
108					}
109					// Months
110					1 => {
111						if fraction > 0.0 {
112							return Err(ParseError::InconstantUnitWithFraction);
113						}
114						let date =
115							date.get_or_insert_with(|| get_date.take().map(|f| f()).unwrap());
116						let offset_date = offset_date.get_or_insert(*date);
117						let months = Months::new(number.try_into()?);
118						*offset_date = if is_subtracting {
119							offset_date.checked_sub_months(months)
120						} else {
121							offset_date.checked_add_months(months)
122						}
123						.ok_or(ParseError::DateOutOfRange)?;
124					}
125					// Other
126					_ => {
127						let fraction_part =
128							Duration::seconds((fraction * unit.seconds as f32) as i64);
129						duration = number
130							.checked_mul(unit.seconds)
131							.map(Duration::seconds)
132							.and_then(|d| {
133								if is_subtracting {
134									duration
135										.checked_sub(&d)
136										.and_then(|d| d.checked_sub(&fraction_part))
137								} else {
138									duration
139										.checked_add(&d)
140										.and_then(|d| d.checked_add(&fraction_part))
141								}
142							})
143							.ok_or(ParseError::NumberOutOfRange)?;
144					}
145				}
146				bytes.skip_spaces();
147				continue 'outer;
148			}
149		}
150		return Err(ParseError::diagnose_unit_error(
151			&bytes,
152			units,
153			unit_cursor,
154			allow_inconstant,
155		));
156	}
157
158	if let (Some(date), Some(offset_date)) = (date, offset_date) {
159		duration = duration
160			.checked_add(&(offset_date - date))
161			.ok_or(ParseError::NumberOutOfRange)?;
162	}
163	Ok(duration)
164}
165
166const _PATTERN: &str = r"^(?:(?:(-) ?)?(\d+) ?y(?:ears?)?\s?)?(?:(?:(-) ?)?(\d+) ?mo(?:nths?)?\s?)?(?:(?:(-) ?)?(\d+(?:\.\d+)?|\.\d+) ?w(?:eeks?)?\s?)?(?:(?:(-) ?)?(\d+(?:\.\d+)?|\.\d+) ?d(?:ays?)?\s?)?(?:(?:(-) ?)?(\d+(?:\.\d+)?|\.\d+) ?h(?:(?:ou)?rs?)?\s?)?(?:(?:(-) ?)?(\d+(?:\.\d+)?|\.\d+) ?m(?:in(?:ute)?s?)?\s?)?(?:(?:(-) ?)?(\d+(?:\.\d+)?|\.\d+) ?s(?:ec(?:ond)?s?)?\s?)?$/i";
167
168#[cfg(test)]
169mod tests {
170	use chrono::{NaiveDate, NaiveTime};
171
172	use super::*;
173
174	/// Date subtractions never overflow.
175	#[test]
176	fn overflow_date() {
177		let _ = DateTime::<Utc>::MIN_UTC - DateTime::<Utc>::MAX_UTC;
178	}
179	#[test]
180	fn simple_case() {
181		assert_eq!(simple("5 weeks 3 days"), Ok(Duration::seconds(3283200)));
182	}
183	#[test]
184	fn short() {
185		assert_eq!(simple("5w3d1h30m30s"), Ok(Duration::seconds(3288630)));
186	}
187	#[test]
188	fn subtraction() {
189		assert_eq!(simple("5 weeks -3 days"), Ok(Duration::seconds(2764800)));
190	}
191	#[test]
192	fn negative_duration() {
193		assert_eq!(simple("-5 weeks 3 days"), Ok(Duration::seconds(-3283200)));
194	}
195	#[test]
196	fn double_subtraction() {
197		assert_eq!(simple("-5 weeks -3 days"), Ok(Duration::seconds(-2764800)));
198	}
199	#[test]
200	fn space_mess() {
201		assert_eq!(
202			simple("  -  5   weeks    -   3   days  "),
203			Ok(Duration::seconds(-2764800))
204		);
205	}
206	#[test]
207	fn ignore_case() {
208		assert_eq!(simple("5 WEEKS 3 days"), Ok(Duration::seconds(3283200)));
209	}
210	#[test]
211	fn fractions() {
212		assert_eq!(
213			simple("0.5 week 2.5 days 3.55 hours .5 minutes 1 second"),
214			Ok(Duration::seconds(531211))
215		);
216	}
217	/// I don't have any particular rounding behaviour in mind, but if it changes, I'd like to know.
218	#[test]
219	fn fraction_rounding() {
220		assert_eq!(simple("0.1s"), Ok(Duration::seconds(0)));
221		assert_eq!(simple("0.017m"), Ok(Duration::seconds(1)));
222	}
223	#[test]
224	fn invalid_fraction() {
225		assert_eq!(simple("0.5.0d"), Err(ParseError::NoUnit(3)));
226	}
227	#[test]
228	fn lone_period() {
229		assert_eq!(simple(".d"), Err(ParseError::NoNumber(0)));
230	}
231	#[test]
232	fn inconstant_fraction() {
233		assert_eq!(
234			with_date("0.5y", date_year_month_day(2020, 6, 20)),
235			Err(ParseError::InconstantUnitWithFraction)
236		);
237	}
238	#[test]
239	fn empty_input() {
240		assert_eq!(simple(""), Err(ParseError::Empty));
241	}
242	#[test]
243	fn spaces_input() {
244		assert_eq!(simple("  "), Err(ParseError::Empty));
245	}
246	#[test]
247	fn duplicate_units() {
248		assert_eq!(
249			simple("5 days 3 days"),
250			Err(ParseError::UnitOutOfSequence(9))
251		);
252	}
253	#[test]
254	fn out_of_order_units() {
255		assert_eq!(
256			simple("5 days 3 weeks"),
257			Err(ParseError::UnitOutOfSequence(9))
258		);
259	}
260	#[test]
261	fn non_units() {
262		assert_eq!(simple("5 days 3 apples"), Err(ParseError::NoUnit(9)));
263	}
264	#[test]
265	fn missing_number() {
266		assert_eq!(simple("5 days weeks"), Err(ParseError::NoNumber(7)));
267	}
268	#[test]
269	fn years_without_date() {
270		assert_eq!(
271			simple("5 years 3 days"),
272			Err(ParseError::InconstantUnitWithoutDate)
273		);
274	}
275	#[test]
276	fn out_of_range() {
277		assert_eq!(
278			with_date("-1 year - 12 months", DateTime::<Utc>::MIN_UTC),
279			Err(ParseError::DateOutOfRange)
280		);
281	}
282	fn date_year_month_day(year: i32, month: u32, day: u32) -> DateTime<Utc> {
283		NaiveDate::from_ymd_opt(year, month, day)
284			.unwrap()
285			.and_time(NaiveTime::default())
286			.and_utc()
287	}
288	#[test]
289	fn leap_year_forward() {
290		assert_eq!(
291			with_date("1 month", date_year_month_day(2000, 2, 1)),
292			Ok(Duration::days(29))
293		);
294	}
295	#[test]
296	fn leap_year_backward() {
297		assert_eq!(
298			with_date("-1 month", date_year_month_day(2000, 2, 1)),
299			Ok(Duration::days(-31))
300		);
301	}
302	#[test]
303	fn year_equals_twelve_months_forwards() {
304		assert_eq!(
305			with_date("1 year -12 months", date_year_month_day(2000, 2, 1)),
306			Ok(Duration::default())
307		);
308	}
309	#[test]
310	fn year_equals_twelve_months_backwards() {
311		assert_eq!(
312			with_date("-1 year -12 months", date_year_month_day(2000, 2, 1)),
313			Ok(Duration::default())
314		);
315	}
316	#[test]
317	fn lazy_eager_same_outcome() {
318		let date = date_year_month_day(2000, 2, 1);
319		let interval = "1 year 3 months 15 minutes";
320		assert_eq!(
321			with_date(interval, date),
322			with_lazy_date(interval, move || date)
323		);
324	}
325	#[test]
326	fn doc_examples() {
327		let duration = self::with_now("2 days 15 hours 15 mins");
328		assert_eq!(duration, Ok(chrono::Duration::seconds(227700)));
329
330		let duration = self::with_lazy_date("1 month", || {
331			NaiveDate::from_ymd_opt(2000, 2, 1)
332				.unwrap()
333				.and_time(NaiveTime::default())
334				.and_utc()
335		});
336		assert_eq!(duration, Ok(chrono::Duration::days(29)));
337	}
338}