egui_charts/ui/timeframe_toolbar/
date_range_parser.rs1use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc};
9use thiserror::Error;
10
11#[derive(Debug, Clone, Error, PartialEq)]
13pub enum DateRangeParseError {
14 #[error("Invalid date format: {0}")]
15 InvalidDateFormat(String),
17
18 #[error("Invalid range format: {0}")]
19 InvalidRangeFormat(String),
21
22 #[error("Invalid number: {0}")]
23 InvalidNumber(String),
25
26 #[error("Invalid time unit: {0}")]
27 InvalidTimeUnit(String),
29
30 #[error("End date must not be before start date")]
31 InvalidDateOrder,
33
34 #[error("Unsupported format: {0}")]
35 UnsupportedFormat(String),
37}
38
39type DateRangeResult = Result<Option<(DateTime<Utc>, DateTime<Utc>)>, DateRangeParseError>;
41
42pub fn parse_date_range(input: &str) -> Result<(i64, i64), DateRangeParseError> {
51 let trimmed = input.trim();
52
53 if let Some((start, end)) = try_parse_preset(trimmed)? {
55 return Ok((start.timestamp(), end.timestamp()));
56 }
57
58 if let Some((start, end)) = try_parse_relative(trimmed)? {
60 return Ok((start.timestamp(), end.timestamp()));
61 }
62
63 if let Some((start, end)) = try_parse_absolute(trimmed)? {
65 return Ok((start.timestamp(), end.timestamp()));
66 }
67
68 Err(DateRangeParseError::UnsupportedFormat(trimmed.to_string()))
69}
70
71fn try_parse_preset(s: &str) -> DateRangeResult {
73 let upper = s.to_uppercase();
74 let now = Utc::now();
75
76 let duration = match upper.as_str() {
77 "YTD" | "MTD" => {
78 let month = if upper == "YTD" { 1 } else { now.month() };
80 let period_start = NaiveDate::from_ymd_opt(now.year(), month, 1)
81 .and_then(|d| d.and_hms_opt(0, 0, 0))
82 .ok_or_else(|| DateRangeParseError::InvalidDateFormat(upper.clone()))?;
83 let start = Utc.from_utc_datetime(&period_start);
84 return Ok(Some((start, now)));
85 }
86 "1D" => Duration::days(1),
87 "5D" => Duration::days(5),
88 "1W" => Duration::weeks(1),
89 "1M" => Duration::days(30),
90 "3M" => Duration::days(90),
91 "6M" => Duration::days(180),
92 "1Y" => Duration::days(365),
93 "5Y" => Duration::days(365 * 5),
94 "10Y" => Duration::days(365 * 10),
95 "MAX" | "ALL" => Duration::days(365 * 20), _ => return Ok(None), };
98
99 let start = now - duration;
100 Ok(Some((start, now)))
101}
102
103fn try_parse_relative(s: &str) -> DateRangeResult {
105 let lower = s.to_lowercase();
106
107 if !lower.starts_with("last ") {
109 return Ok(None);
110 }
111
112 let parts: Vec<&str> = lower.split_whitespace().collect();
113 if parts.len() != 3 {
114 return Err(DateRangeParseError::InvalidRangeFormat(s.to_string()));
115 }
116
117 let invalid_number = || DateRangeParseError::InvalidNumber(parts[1].to_string());
118 let n: i64 = parts[1]
119 .parse()
120 .ok()
121 .filter(|&n| n > 0)
122 .ok_or_else(invalid_number)?;
123
124 let unit = parts[2];
125 let now = Utc::now();
126
127 let days_per_unit = match unit {
128 "day" | "days" => 1,
129 "week" | "weeks" => 7,
130 "month" | "months" => 30, "year" | "years" => 365, _ => return Err(DateRangeParseError::InvalidTimeUnit(unit.to_string())),
133 };
134
135 let start = n
136 .checked_mul(days_per_unit)
137 .and_then(Duration::try_days)
138 .and_then(|duration| now.checked_sub_signed(duration))
139 .ok_or_else(invalid_number)?;
140 Ok(Some((start, now)))
141}
142
143fn try_parse_absolute(s: &str) -> DateRangeResult {
148 let separator = if s.contains(" to ") {
150 " to "
151 } else if s.contains("..") {
152 ".."
153 } else if s.contains(" - ") {
154 " - "
155 } else {
156 return Ok(None); };
158
159 let parts: Vec<&str> = s.split(separator).collect();
160 if parts.len() != 2 {
161 return Err(DateRangeParseError::InvalidRangeFormat(s.to_string()));
162 }
163
164 let start = parse_date_string(parts[0].trim())?;
165 let end_date = parts[1].trim();
166 let end = parse_date_string(end_date)?
168 .checked_add_signed(Duration::days(1))
169 .ok_or_else(|| DateRangeParseError::InvalidDateFormat(end_date.to_string()))?;
170
171 if start >= end {
172 return Err(DateRangeParseError::InvalidDateOrder);
173 }
174
175 Ok(Some((start, end)))
176}
177
178fn parse_date_string(s: &str) -> Result<DateTime<Utc>, DateRangeParseError> {
180 let s_normalized = s.replace('/', "-");
182
183 let parts: Vec<&str> = s_normalized.split('-').collect();
184 if parts.len() != 3 {
185 return Err(DateRangeParseError::InvalidDateFormat(s.to_string()));
186 }
187
188 let year: i32 = parts[0]
189 .parse()
190 .map_err(|_| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
191 let month: u32 = parts[1]
192 .parse()
193 .map_err(|_| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
194 let day: u32 = parts[2]
195 .parse()
196 .map_err(|_| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
197
198 let naive_date = NaiveDate::from_ymd_opt(year, month, day)
199 .ok_or_else(|| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
200
201 let naive_datetime = naive_date
202 .and_hms_opt(0, 0, 0)
203 .ok_or_else(|| DateRangeParseError::InvalidDateFormat(s.to_string()))?;
204
205 Ok(Utc.from_utc_datetime(&naive_datetime))
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn test_preset_to_date() {
214 for preset in ["YTD", "MTD"] {
215 let (start, end) = parse_date_range(preset).unwrap();
216 let end_dt = Utc.timestamp_opt(end, 0).unwrap();
217 let month = if preset == "YTD" { 1 } else { end_dt.month() };
218 let expected = Utc
219 .with_ymd_and_hms(end_dt.year(), month, 1, 0, 0, 0)
220 .unwrap();
221 assert_eq!(start, expected.timestamp(), "{preset}");
222 }
223 }
224
225 #[test]
226 fn parses_relative_and_preset_ranges() {
227 for (input, days) in [
228 ("last 30 days", Some(30)),
229 ("last 6 months", Some(180)),
230 ("last 1 year", Some(365)),
231 ("ytd", None),
232 ("1M", Some(30)),
233 ("MAX", Some(7300)),
234 ] {
235 let (start, end) = parse_date_range(input).unwrap();
236 if let Some(days) = days {
237 assert_eq!(end - start, days * 86_400, "{input}");
238 } else {
239 let year = Utc.timestamp_opt(end, 0).unwrap().year();
240 let january = Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0).unwrap();
241 assert_eq!(start, january.timestamp(), "{input}");
242 }
243 }
244 }
245
246 #[test]
247 fn absolute_ranges_include_both_dates() {
248 let cases = [
249 ("2024-01-01 to 2024-12-31", (1_704_067_200, 1_735_689_600)),
250 ("2024-03-15 to 2024-03-15", (1_710_460_800, 1_710_547_200)),
251 ("2024-01-01..2024-06-30", (1_704_067_200, 1_719_792_000)),
252 ("2024/01/01 to 2024/06/30", (1_704_067_200, 1_719_792_000)),
253 ];
254 for (input, expected) in cases {
255 assert_eq!(parse_date_range(input), Ok(expected), "{input}");
256 }
257 }
258
259 #[test]
260 fn test_errors() {
261 use DateRangeParseError::*;
262 let cases = [
263 ("2024-12-31 to 2024-01-01", InvalidDateOrder),
264 ("invalid", UnsupportedFormat("invalid".into())),
265 (
266 "2024-13-01 to 2024-12-31",
267 InvalidDateFormat("2024-13-01".into()),
268 ),
269 ("last -5 days", InvalidNumber("-5".into())),
270 ("last 0 days", InvalidNumber("0".into())),
271 (
272 "last 9999999999999 days",
273 InvalidNumber("9999999999999".into()),
274 ),
275 ("last 100000000 days", InvalidNumber("100000000".into())),
276 (
277 "2024-01-01 to 262142-12-31",
278 InvalidDateFormat("262142-12-31".into()),
279 ),
280 ];
281 for (input, expected) in cases {
282 assert_eq!(parse_date_range(input), Err(expected), "{input}");
283 }
284 }
285}