Skip to main content

coding_agent_search/ui/
time_parser.rs

1use chrono::{Duration, Local, LocalResult, NaiveDate, TimeZone, Utc};
2
3/// Parses human-readable time input into a UTC timestamp (milliseconds).
4///
5/// Supported formats:
6/// - Relative: "-7d", "-24h", "-30m", "-1w"
7/// - Keywords: "now", "today", "yesterday"
8/// - ISO dates: "2024-11-25", "2024-11-25T14:30:00Z"
9/// - Date formats: "YYYY-MM-DD", "YYYY/MM/DD", "MM/DD/YYYY", "MM-DD-YYYY"
10/// - Unix timestamp: seconds (if < 10^11) or milliseconds
11pub fn parse_time_input(input: &str) -> Option<i64> {
12    let input = input.trim().to_lowercase();
13    if input.is_empty() {
14        return None;
15    }
16
17    let now_utc = Utc::now();
18    let now_ms = now_utc.timestamp_millis();
19
20    // Relative: -7d, -24h, -1w, -30m
21    if let Some(stripped) = input.strip_prefix('-') {
22        let val_str: String = stripped.chars().take_while(|c| c.is_numeric()).collect();
23        if let Ok(val) = val_str.parse::<i64>() {
24            let unit = stripped.trim_start_matches(&val_str).trim();
25            let duration = relative_duration(unit, val)?;
26            return subtract_duration_ms(now_utc, duration);
27        }
28    }
29
30    // Relative: 7d, 24h, 1w, 30m (no leading '-')
31    {
32        let val_str: String = input.chars().take_while(|c| c.is_numeric()).collect();
33        if !val_str.is_empty() {
34            let unit = input.trim_start_matches(&val_str).trim();
35            if !unit.is_empty()
36                && let Ok(val) = val_str.parse::<i64>()
37            {
38                let duration = relative_duration(unit, val);
39                if let Some(duration) = duration {
40                    return subtract_duration_ms(now_utc, duration);
41                }
42            }
43        }
44    }
45
46    // Relative: "30 days ago", "2 weeks ago", "1 hour ago"
47    {
48        let parts: Vec<&str> = input.split_whitespace().collect();
49        if parts.len() == 3
50            && parts[2] == "ago"
51            && let Ok(val) = parts[0].parse::<i64>()
52        {
53            let duration = relative_duration(parts[1], val);
54            if let Some(duration) = duration {
55                return subtract_duration_ms(now_utc, duration);
56            }
57        }
58        if parts.len() == 2 && parts[1] == "ago" {
59            let val_str: String = parts[0].chars().take_while(|c| c.is_numeric()).collect();
60            if let Ok(val) = val_str.parse::<i64>() {
61                let unit = parts[0].trim_start_matches(&val_str);
62                let duration = relative_duration(unit, val);
63                if let Some(duration) = duration {
64                    return subtract_duration_ms(now_utc, duration);
65                }
66            }
67        }
68    }
69
70    // Keywords
71    match input.as_str() {
72        "now" => return Some(now_ms),
73        "today" => {
74            let today = Local::now().date_naive();
75            return local_midnight_to_utc(today);
76        }
77        "yesterday" => {
78            let yesterday = Local::now()
79                .date_naive()
80                .checked_sub_signed(Duration::try_days(1)?)?;
81            return local_midnight_to_utc(yesterday);
82        }
83        _ => {}
84    }
85
86    // ISO date formats (RFC3339)
87    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&input) {
88        return Some(dt.timestamp_millis());
89    }
90
91    // YYYY-MM-DD or YYYY/MM/DD (Local midnight)
92    if let Ok(date) = NaiveDate::parse_from_str(&input, "%Y-%m-%d")
93        .or_else(|_| NaiveDate::parse_from_str(&input, "%Y/%m/%d"))
94    {
95        return local_midnight_to_utc(date);
96    }
97
98    // US Formats: MM/DD/YYYY or MM-DD-YYYY
99    if let Ok(date) = NaiveDate::parse_from_str(&input, "%m/%d/%Y")
100        .or_else(|_| NaiveDate::parse_from_str(&input, "%m-%d-%Y"))
101    {
102        return local_midnight_to_utc(date);
103    }
104    // Numeric fallback (ms or seconds)
105    if let Ok(n) = input.parse::<i64>() {
106        // Heuristic: timestamps < 10^11 (year 5138) are likely seconds.
107        if n < 100_000_000_000 {
108            return n.checked_mul(1000);
109        }
110        return Some(n);
111    }
112
113    None
114}
115
116fn local_midnight_to_utc(date: NaiveDate) -> Option<i64> {
117    let dt = date.and_hms_opt(0, 0, 0)?;
118    let local = match Local.from_local_datetime(&dt) {
119        LocalResult::Single(value) => value,
120        LocalResult::Ambiguous(earliest, _) => earliest,
121        LocalResult::None => {
122            // Fall back to treating the naive datetime as UTC for DST gaps.
123            return Some(Utc.from_utc_datetime(&dt).timestamp_millis());
124        }
125    };
126    Some(local.with_timezone(&Utc).timestamp_millis())
127}
128
129fn relative_duration(unit: &str, val: i64) -> Option<Duration> {
130    match unit {
131        "d" | "day" | "days" => Duration::try_days(val),
132        "h" | "hr" | "hrs" | "hour" | "hours" => Duration::try_hours(val),
133        "m" | "min" | "mins" | "minute" | "minutes" => Duration::try_minutes(val),
134        "w" | "wk" | "wks" | "week" | "weeks" => Duration::try_weeks(val),
135        _ => None,
136    }
137}
138
139fn subtract_duration_ms(now_utc: chrono::DateTime<Utc>, duration: Duration) -> Option<i64> {
140    now_utc
141        .checked_sub_signed(duration)
142        .map(|value| value.timestamp_millis())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_relative_time() {
151        let now = Utc::now().timestamp_millis();
152        let tolerance = 60 * 1000; // 1 minute
153
154        // -1h
155        let t1 = parse_time_input("-1h").unwrap();
156        let diff = now - t1;
157        assert!((diff - 3600 * 1000).abs() < tolerance);
158
159        // -1d
160        let t2 = parse_time_input("-1d").unwrap();
161        let diff = now - t2;
162        assert!((diff - 86400 * 1000).abs() < tolerance);
163
164        // 7d (no leading '-')
165        let t3 = parse_time_input("7d").unwrap();
166        let diff = now - t3;
167        assert!((diff - 7 * 86400 * 1000).abs() < tolerance);
168
169        // 30 days ago
170        let t4 = parse_time_input("30 days ago").unwrap();
171        let diff = now - t4;
172        assert!((diff - 30 * 86400 * 1000).abs() < tolerance);
173
174        // 2 weeks ago
175        let t5 = parse_time_input("2 weeks ago").unwrap();
176        let diff = now - t5;
177        assert!((diff - 14 * 86400 * 1000).abs() < tolerance);
178    }
179
180    #[test]
181    fn test_relative_time_overflow_returns_none() {
182        let max = i64::MAX;
183        let inputs = [
184            format!("{max}d"),
185            format!("{max}h"),
186            format!("{max}m"),
187            format!("{max}w"),
188            format!("-{max}d"),
189            format!("{max} days ago"),
190            format!("{max}h ago"),
191        ];
192
193        for input in inputs {
194            assert_eq!(parse_time_input(&input), None, "{input}");
195        }
196
197        let duration = Duration::try_milliseconds(i64::MAX).unwrap();
198        assert_eq!(
199            subtract_duration_ms(chrono::DateTime::<Utc>::MIN_UTC, duration),
200            None
201        );
202    }
203
204    #[test]
205    fn test_keywords() {
206        assert!(parse_time_input("now").is_some());
207        let today = parse_time_input("today").unwrap();
208        let yesterday = parse_time_input("yesterday").unwrap();
209        assert!(today > yesterday);
210        let diff = today - yesterday;
211        let min = 23 * 60 * 60 * 1000;
212        let max = 25 * 60 * 60 * 1000;
213        assert!(
214            diff >= min && diff <= max,
215            "expected 23-25h difference due to DST, got {} ms",
216            diff
217        );
218    }
219
220    #[test]
221    fn test_date_formats() {
222        // Just check they parse
223        assert!(parse_time_input("2023-01-01").is_some());
224        assert!(parse_time_input("2023/01/01").is_some());
225        assert!(parse_time_input("01/01/2023").is_some());
226        assert!(parse_time_input("01-01-2023").is_some());
227    }
228
229    #[test]
230    fn test_numeric() {
231        let _sec = 1700000000;
232        let ms = 1700000000000;
233        assert_eq!(parse_time_input("1700000000").unwrap(), ms);
234        assert_eq!(parse_time_input("1700000000000").unwrap(), ms);
235        assert_eq!(parse_time_input(&i64::MIN.to_string()), None);
236    }
237}