1use crate::model::error::{ErrorCode, ObzError};
20
21pub fn parse_time(input: &str) -> Result<i64, ObzError> {
59 let trimmed = input.trim();
60 if trimmed.is_empty() {
61 return Err(ObzError::InvalidArgument {
62 code: ErrorCode::InvalidTimeRange,
63 message: "empty time expression".to_string(),
64 suggestion: None,
65 });
66 }
67
68 if let Some(digits) = trimmed.strip_prefix('@') {
70 return parse_unix_timestamp(digits, trimmed);
71 }
72
73 let normalized = normalize_shorthand(trimmed);
74 let zoned =
75 parse_datetime::parse_datetime(&normalized).map_err(|_| ObzError::InvalidArgument {
76 code: ErrorCode::InvalidTimeRange,
77 message: format!(
78 "unrecognized time format: '{trimmed}'. \
79 Supported: now-1h, -1h, RFC3339, @<unix_seconds>, 'yesterday', '1 hour ago'"
80 ),
81 suggestion: None,
82 })?;
83
84 Ok(zoned.timestamp().as_second())
85}
86
87fn parse_unix_timestamp(digits: &str, original: &str) -> Result<i64, ObzError> {
93 let ts: i64 = digits.parse().map_err(|_| ObzError::InvalidArgument {
94 code: ErrorCode::InvalidTimeRange,
95 message: format!(
96 "invalid Unix timestamp: '{original}'. Expected @<digits> (10-digit seconds or 13-digit milliseconds)"
97 ),
98 suggestion: None,
99 })?;
100
101 if digits.len() == 13 {
102 Ok(ts / 1000)
103 } else {
104 Ok(ts)
105 }
106}
107
108pub fn parse_step(input: &str) -> Result<u64, ObzError> {
128 let s = input.trim();
129 if s.is_empty() {
130 return Err(ObzError::InvalidArgument {
131 code: ErrorCode::InvalidTimeRange,
132 message: "step cannot be empty".to_string(),
133 suggestion: None,
134 });
135 }
136
137 if let Ok(n) = s.parse::<u64>() {
139 if n == 0 {
140 return Err(ObzError::InvalidArgument {
141 code: ErrorCode::InvalidTimeRange,
142 message: "step must be positive".to_string(),
143 suggestion: None,
144 });
145 }
146 return Ok(n);
147 }
148
149 let (num, multiplier) = parse_duration_parts(s)?;
151 let result = num * multiplier;
152 if result == 0 {
153 return Err(ObzError::InvalidArgument {
154 code: ErrorCode::InvalidTimeRange,
155 message: "step must be positive".to_string(),
156 suggestion: None,
157 });
158 }
159 Ok(result)
160}
161
162fn parse_duration_parts(s: &str) -> Result<(u64, u64), ObzError> {
166 let (num_str, unit) = s
167 .find(|c: char| !c.is_ascii_digit())
168 .map(|pos| (&s[..pos], &s[pos..]))
169 .ok_or_else(|| ObzError::InvalidArgument {
170 code: ErrorCode::InvalidTimeRange,
171 message: format!("missing unit in duration: '{s}'. Expected s, m, h, d, or w"),
172 suggestion: None,
173 })?;
174
175 let num: u64 = num_str.parse().map_err(|_| ObzError::InvalidArgument {
176 code: ErrorCode::InvalidTimeRange,
177 message: format!("invalid number in duration: '{s}'"),
178 suggestion: None,
179 })?;
180
181 let multiplier = match unit {
182 "s" => 1,
183 "m" | "min" => 60,
184 "h" => 3600,
185 "d" => 86400,
186 "w" => 604800,
187 _ => {
188 return Err(ObzError::InvalidArgument {
189 code: ErrorCode::InvalidTimeRange,
190 message: format!("invalid unit '{unit}' in '{s}'. Expected s, m, h, d, or w"),
191 suggestion: None,
192 });
193 }
194 };
195
196 Ok((num, multiplier))
197}
198
199pub fn validate_time_range(from: i64, to: i64) -> Result<(), ObzError> {
205 if from >= to {
206 return Err(ObzError::InvalidArgument {
207 code: ErrorCode::InvalidTimeRange,
208 message: format!("--from ({from}) must be before --to ({to})"),
209 suggestion: None,
210 });
211 }
212 Ok(())
213}
214
215pub fn resolve_time_range(
225 from_str: Option<&str>,
226 to_str: Option<&str>,
227 default_from: &str,
228) -> Result<(i64, i64), ObzError> {
229 let from = parse_time(from_str.unwrap_or(default_from))?;
230 let to = parse_time(to_str.unwrap_or("now"))?;
231 validate_time_range(from, to)?;
232 Ok((from, to))
233}
234
235fn normalize_shorthand(input: &str) -> String {
243 let s = input.trim();
244
245 if s == "now" {
247 return s.to_string();
248 }
249
250 if let Some(rest) = s.strip_prefix("now-") {
252 if let Some(expanded) = expand_duration(rest) {
253 return format!("-{expanded}");
254 }
255 }
256
257 if let Some(rest) = s.strip_prefix("now+") {
259 if let Some(expanded) = expand_duration(rest) {
260 return format!("+{expanded}");
261 }
262 }
263
264 if let Some(rest) = s.strip_prefix('-') {
266 if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) {
267 if let Some(expanded) = expand_duration(rest) {
268 return format!("-{expanded}");
269 }
270 }
271 }
272
273 s.to_string()
274}
275
276fn expand_duration(s: &str) -> Option<String> {
278 let unit_pos = s.find(|c: char| !c.is_ascii_digit())?;
279 let num = &s[..unit_pos];
280 if num.is_empty() {
281 return None;
282 }
283 let unit_name = match &s[unit_pos..] {
284 "s" => "second",
285 "m" | "min" => "minute",
286 "h" => "hour",
287 "d" => "day",
288 "w" => "week",
289 _ => return None,
290 };
291 Some(format!("{num} {unit_name}"))
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
301 fn test_normalize_now() {
302 assert_eq!(normalize_shorthand("now"), "now");
303 }
304
305 #[test]
306 fn test_normalize_relative() {
307 assert_eq!(normalize_shorthand("now-1h"), "-1 hour");
308 assert_eq!(normalize_shorthand("now-30m"), "-30 minute");
309 assert_eq!(normalize_shorthand("now-7d"), "-7 day");
310 assert_eq!(normalize_shorthand("now-2w"), "-2 week");
311 assert_eq!(normalize_shorthand("now-60s"), "-60 second");
312 }
313
314 #[test]
315 fn test_normalize_shortcut() {
316 assert_eq!(normalize_shorthand("-1h"), "-1 hour");
317 assert_eq!(normalize_shorthand("-30m"), "-30 minute");
318 }
319
320 #[test]
321 fn test_normalize_plus() {
322 assert_eq!(normalize_shorthand("now+2d"), "+2 day");
323 }
324
325 #[test]
326 fn test_normalize_passthrough() {
327 assert_eq!(normalize_shorthand("yesterday"), "yesterday");
328 assert_eq!(normalize_shorthand("@1740280800"), "@1740280800");
329 assert_eq!(
330 normalize_shorthand("2026-03-24T10:00:00Z"),
331 "2026-03-24T10:00:00Z"
332 );
333 }
334
335 #[test]
338 fn test_parse_time_now() {
339 let ts = parse_time("now").unwrap();
340 let actual_now = jiff::Zoned::now().timestamp().as_second();
341 assert!((ts - actual_now).abs() < 2);
342 }
343
344 #[test]
345 fn test_parse_time_relative() {
346 let now = jiff::Zoned::now().timestamp().as_second();
347 let ts = parse_time("now-1h").unwrap();
348 assert!((ts - (now - 3600)).abs() < 2);
349
350 let ts = parse_time("-30m").unwrap();
351 assert!((ts - (now - 1800)).abs() < 2);
352 }
353
354 #[test]
355 fn test_parse_time_unix() {
356 assert_eq!(parse_time("@1740280800").unwrap(), 1740280800);
358 }
359
360 #[test]
361 fn test_parse_time_unix_milliseconds() {
362 assert_eq!(parse_time("@1740280800000").unwrap(), 1740280800);
364 assert_eq!(parse_time("@1740280800123").unwrap(), 1740280800);
365 }
366
367 #[test]
368 fn test_parse_time_unix_invalid() {
369 assert!(parse_time("@not_a_number").is_err());
370 }
371
372 #[test]
373 fn test_parse_time_rfc3339() {
374 assert_eq!(parse_time("2026-03-24T10:00:00Z").unwrap(), 1774346400);
375 }
376
377 #[test]
378 fn test_parse_time_natural() {
379 let ts = parse_time("yesterday").unwrap();
381 let now = jiff::Zoned::now().timestamp().as_second();
382 assert!((ts - (now - 86400)).abs() < 2);
383 }
384
385 #[test]
386 fn test_parse_time_invalid() {
387 assert!(parse_time("").is_err());
388 assert!(parse_time("invalid-garbage").is_err());
389 }
390
391 #[test]
394 fn test_parse_step() {
395 assert_eq!(parse_step("15s").unwrap(), 15);
396 assert_eq!(parse_step("1m").unwrap(), 60);
397 assert_eq!(parse_step("5m").unwrap(), 300);
398 assert_eq!(parse_step("1h").unwrap(), 3600);
399 }
400
401 #[test]
402 fn test_parse_step_bare_number() {
403 assert_eq!(parse_step("30").unwrap(), 30);
404 assert_eq!(parse_step("60").unwrap(), 60);
405 }
406
407 #[test]
408 fn test_parse_step_day_week() {
409 assert_eq!(parse_step("1d").unwrap(), 86400);
410 assert_eq!(parse_step("2w").unwrap(), 1_209_600);
411 }
412
413 #[test]
414 fn test_parse_step_zero_rejected() {
415 assert!(parse_step("0").is_err());
416 assert!(parse_step("0s").is_err());
417 }
418
419 #[test]
420 fn test_parse_step_invalid() {
421 assert!(parse_step("").is_err());
422 assert!(parse_step("abc").is_err());
423 }
424
425 #[test]
428 fn test_validate_time_range_valid() {
429 assert!(validate_time_range(100, 200).is_ok());
430 }
431
432 #[test]
433 fn test_validate_time_range_invalid() {
434 assert!(validate_time_range(200, 100).is_err());
435 assert!(validate_time_range(100, 100).is_err());
436 }
437
438 #[test]
441 fn test_resolve_time_range_defaults() {
442 let (from, to) = resolve_time_range(None, None, "now-1h").unwrap();
444 let now = jiff::Zoned::now().timestamp().as_second();
445 assert!((from - (now - 3600)).abs() < 2);
446 assert!((to - now).abs() < 2);
447 }
448
449 #[test]
450 fn test_resolve_time_range_explicit_values() {
451 let (from, to) = resolve_time_range(Some("@1000"), Some("@2000"), "now-1h").unwrap();
453 assert_eq!(from, 1000);
454 assert_eq!(to, 2000);
455 }
456
457 #[test]
458 fn test_resolve_time_range_explicit_from_default_to() {
459 let (from, to) = resolve_time_range(Some("@1000"), None, "now-1h").unwrap();
461 let now = jiff::Zoned::now().timestamp().as_second();
462 assert_eq!(from, 1000);
463 assert!((to - now).abs() < 2);
464 }
465
466 #[test]
467 fn test_resolve_time_range_invalid_range() {
468 assert!(resolve_time_range(Some("@2000"), Some("@1000"), "now-1h").is_err());
470 }
471}