Skip to main content

intent_engine/
time_utils.rs

1//! Time utility functions
2//!
3//! Provides common time-related operations used across the codebase.
4
5use crate::error::{IntentError, Result};
6use chrono::{DateTime, Duration, Utc};
7
8/// Parse a duration string (e.g., "7d", "24h", "30m") into a DateTime
9///
10/// Supported units:
11/// - `d`: days
12/// - `h`: hours
13/// - `m`: minutes
14/// - `s`: seconds
15/// - `w`: weeks
16///
17/// # Arguments
18/// * `duration` - Duration string in format like "7d", "24h", "30m", "5w"
19///
20/// # Returns
21/// A DateTime representing the current time minus the specified duration
22///
23/// # Errors
24/// Returns InvalidInput error if:
25/// - Duration string is empty or too short
26/// - Number part is not a valid integer
27/// - Unit is not one of d/h/m/s/w
28///
29/// # Examples
30/// ```ignore
31/// use crate::time_utils::parse_duration;
32///
33/// let seven_days_ago = parse_duration("7d").unwrap();
34/// let one_week_ago = parse_duration("1w").unwrap();
35/// ```
36pub fn parse_duration(duration: &str) -> Result<DateTime<Utc>> {
37    let duration = duration.trim();
38
39    if duration.len() < 2 {
40        return Err(IntentError::InvalidInput(
41            "Duration must be in format like '7d', '24h', '30m', '5w', or '10s'".to_string(),
42        ));
43    }
44
45    let (num_str, unit) = duration.split_at(duration.len() - 1);
46    let num: i64 = num_str.parse().map_err(|_| {
47        IntentError::InvalidInput(format!("Invalid number in duration: '{}'", num_str))
48    })?;
49
50    let offset = match unit {
51        "d" => Duration::days(num),
52        "h" => Duration::hours(num),
53        "m" => Duration::minutes(num),
54        "s" => Duration::seconds(num),
55        "w" => Duration::weeks(num),
56        _ => {
57            return Err(IntentError::InvalidInput(format!(
58                "Invalid duration unit '{}'. Use 'd' (days), 'h' (hours), 'm' (minutes), 's' (seconds), or 'w' (weeks)",
59                unit
60            )))
61        }
62    };
63
64    Ok(Utc::now() - offset)
65}
66
67/// Parse a date filter string — either a duration (e.g. "7d", "1w") or a date (e.g. "2025-01-01").
68///
69/// Tries duration format first via `parse_duration`, then falls back to YYYY-MM-DD date parsing.
70pub fn parse_date_filter(input: &str) -> std::result::Result<DateTime<Utc>, String> {
71    let input = input.trim();
72
73    // Try duration format first (e.g., "7d", "1w")
74    if let Ok(dt) = parse_duration(input) {
75        return Ok(dt);
76    }
77
78    // Try date format (YYYY-MM-DD)
79    if let Ok(date) = chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d") {
80        let dt = chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(0, 0, 0).unwrap());
81        return Ok(dt);
82    }
83
84    Err(format!(
85        "Invalid date format '{}'. Use duration (7d, 1w) or date (2025-01-01)",
86        input
87    ))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn test_parse_duration_days() {
96        let result = parse_duration("7d").unwrap();
97        let expected_diff = Duration::days(7);
98        let actual_diff = Utc::now() - result;
99
100        // Allow 1 second tolerance for test execution time
101        assert!((actual_diff - expected_diff).num_seconds().abs() <= 1);
102    }
103
104    #[test]
105    fn test_parse_duration_hours() {
106        let result = parse_duration("24h").unwrap();
107        let expected_diff = Duration::hours(24);
108        let actual_diff = Utc::now() - result;
109
110        assert!((actual_diff - expected_diff).num_seconds().abs() <= 1);
111    }
112
113    #[test]
114    fn test_parse_duration_minutes() {
115        let result = parse_duration("30m").unwrap();
116        let expected_diff = Duration::minutes(30);
117        let actual_diff = Utc::now() - result;
118
119        assert!((actual_diff - expected_diff).num_seconds().abs() <= 1);
120    }
121
122    #[test]
123    fn test_parse_duration_seconds() {
124        let result = parse_duration("10s").unwrap();
125        let expected_diff = Duration::seconds(10);
126        let actual_diff = Utc::now() - result;
127
128        assert!((actual_diff - expected_diff).num_seconds().abs() <= 1);
129    }
130
131    #[test]
132    fn test_parse_duration_weeks() {
133        let result = parse_duration("2w").unwrap();
134        let expected_diff = Duration::weeks(2);
135        let actual_diff = Utc::now() - result;
136
137        assert!((actual_diff - expected_diff).num_seconds().abs() <= 1);
138    }
139
140    #[test]
141    fn test_parse_duration_with_whitespace() {
142        let result = parse_duration("  7d  ").unwrap();
143        let expected_diff = Duration::days(7);
144        let actual_diff = Utc::now() - result;
145
146        assert!((actual_diff - expected_diff).num_seconds().abs() <= 1);
147    }
148
149    #[test]
150    fn test_parse_duration_invalid_number() {
151        let result = parse_duration("abc d");
152        assert!(matches!(result, Err(IntentError::InvalidInput(_))));
153    }
154
155    #[test]
156    fn test_parse_duration_invalid_unit() {
157        let result = parse_duration("7x");
158        assert!(matches!(result, Err(IntentError::InvalidInput(_))));
159
160        if let Err(IntentError::InvalidInput(msg)) = result {
161            assert!(msg.contains("Invalid duration unit"));
162        }
163    }
164
165    #[test]
166    fn test_parse_duration_too_short() {
167        let result = parse_duration("7");
168        assert!(matches!(result, Err(IntentError::InvalidInput(_))));
169    }
170
171    #[test]
172    fn test_parse_duration_empty() {
173        let result = parse_duration("");
174        assert!(matches!(result, Err(IntentError::InvalidInput(_))));
175    }
176
177    #[test]
178    fn test_parse_date_filter_duration() {
179        let result = parse_date_filter("7d");
180        assert!(result.is_ok());
181        let dt = result.unwrap();
182        let diff = Utc::now() - dt;
183        assert!((diff - Duration::days(7)).num_seconds().abs() <= 1);
184    }
185
186    #[test]
187    fn test_parse_date_filter_date() {
188        let result = parse_date_filter("2025-06-15");
189        assert!(result.is_ok());
190        let dt = result.unwrap();
191        assert_eq!(dt.format("%Y-%m-%d").to_string(), "2025-06-15");
192    }
193
194    #[test]
195    fn test_parse_date_filter_invalid() {
196        let result = parse_date_filter("not-a-date");
197        assert!(result.is_err());
198        assert!(result.unwrap_err().contains("Invalid date format"));
199    }
200}