Skip to main content

egui_charts/ui/timeframe_toolbar/
date_range_parser.rs

1//! Date range string parser
2//!
3//! Parses various date range formats into timestamp pairs:
4//! - Absolute: "2024-01-01 to 2024-12-31" (both dates included)
5//! - Relative: "last 30 days", "last 6 months"
6//! - Presets: "YTD", "MTD", "1M", "3M", "6M", "1Y", "5Y", "MAX"
7
8use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc};
9use thiserror::Error;
10
11/// Date range parsing errors
12#[derive(Debug, Clone, Error, PartialEq)]
13pub enum DateRangeParseError {
14    #[error("Invalid date format: {0}")]
15    /// A date could not be parsed; carries the invalid text.
16    InvalidDateFormat(String),
17
18    #[error("Invalid range format: {0}")]
19    /// The range syntax is invalid; carries the invalid text.
20    InvalidRangeFormat(String),
21
22    #[error("Invalid number: {0}")]
23    /// A relative duration has an invalid number.
24    InvalidNumber(String),
25
26    #[error("Invalid time unit: {0}")]
27    /// A relative duration has an unsupported unit.
28    InvalidTimeUnit(String),
29
30    #[error("End date must not be before start date")]
31    /// The end date precedes the start date.
32    InvalidDateOrder,
33
34    #[error("Unsupported format: {0}")]
35    /// The input does not match a supported range format.
36    UnsupportedFormat(String),
37}
38
39/// Result type for internal date range parsing helpers.
40type DateRangeResult = Result<Option<(DateTime<Utc>, DateTime<Utc>)>, DateRangeParseError>;
41
42/// Parse a date range string into start/end timestamps
43///
44/// Supported formats:
45/// - Absolute: "2024-01-01 to 2024-12-31", "2024-01-01..2024-12-31"
46/// - Relative: "last 30 days", "last 6 months", "last 1 year"
47/// - Presets: "YTD", "MTD", "1M", "3M", "6M", "1Y", "5Y", "MAX"
48///
49/// Returns (start_timestamp, end_timestamp) in Unix seconds
50pub fn parse_date_range(input: &str) -> Result<(i64, i64), DateRangeParseError> {
51    let trimmed = input.trim();
52
53    // Try preset formats first (most common)
54    if let Some((start, end)) = try_parse_preset(trimmed)? {
55        return Ok((start.timestamp(), end.timestamp()));
56    }
57
58    // Try relative formats ("last N days/weeks/months/years")
59    if let Some((start, end)) = try_parse_relative(trimmed)? {
60        return Ok((start.timestamp(), end.timestamp()));
61    }
62
63    // Try absolute range formats ("2024-01-01 to 2024-12-31")
64    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
71/// Try to parse preset formats: YTD, MTD, 1M, 3M, 6M, 1Y, 5Y, MAX
72fn 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            // Year or month to date: from 00:00 UTC on the first day of the year or month
79            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), // 20 years max
96        _ => return Ok(None),                      // Not a preset
97    };
98
99    let start = now - duration;
100    Ok(Some((start, now)))
101}
102
103/// Try to parse relative formats: "last N days/weeks/months/years"
104fn try_parse_relative(s: &str) -> DateRangeResult {
105    let lower = s.to_lowercase();
106
107    // Match "last N days/weeks/months/years"
108    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, // Approximate
131        "year" | "years" => 365,  // Approximate
132        _ => 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
143/// Try to parse absolute formats, which include both dates (the range ends at the midnight after the end date):
144/// - "2024-01-01 to 2024-12-31"
145/// - "2024-01-01..2024-12-31"
146/// - "2024-01-01 - 2024-12-31"
147fn try_parse_absolute(s: &str) -> DateRangeResult {
148    // Try different separators
149    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); // Not an absolute range
157    };
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    // Both dates are included, so the range ends at the midnight after the end date.
167    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
178/// Parse a single date string in YYYY-MM-DD format
179fn parse_date_string(s: &str) -> Result<DateTime<Utc>, DateRangeParseError> {
180    // Support YYYY-MM-DD and YYYY/MM/DD
181    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}