From 97991db44f53bf9fd4a7de50d00251da7272b401 Mon Sep 17 00:00:00 2001 From: Simon Gurcke Date: Thu, 29 Oct 2026 20:08:22 +1000 Subject: [PATCH] Fix ISO 8601 duration parsing for months --- skills/apitally-cli/references/commands.md | 2 +- src/utils.rs | 49 +++++++++++++--------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/skills/apitally-cli/references/commands.md b/skills/apitally-cli/references/commands.md index fab0370..5870b77 100644 --- a/skills/apitally-cli/references/commands.md +++ b/skills/apitally-cli/references/commands.md @@ -4,7 +4,7 @@ All commands except `sql` and `reset-db` accept `--api-key ` for authentica Commands that accept a `--db` flag use `~/.apitally/data.duckdb` as the default database path if no other path is specified. If the database file doesn't exist, it will be created (except for the `sql` command). When writing to tables, existing records are updated (no duplicates are created). -Datetime flags (e.g. `--since`, `--until`, `--requests-since`) accept ISO 8601 strings or compact relative durations (e.g. `30m`, `24h`, `7d`, `2w`). +Datetime flags (e.g. `--since`, `--until`, `--requests-since`) accept ISO 8601 strings, compact relative durations (e.g. `30m`, `24h`, `7d`, `2w`), or ISO 8601 durations with a single component (e.g. `P1M`, `P14D`, `PT30M`; a month is 30 days). ## `auth` diff --git a/src/utils.rs b/src/utils.rs index 6699012..dff806e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -62,34 +62,38 @@ pub fn open_db(path: &Path) -> Result { } /// If `s` is a compact duration string (``, e.g. `24h` or `7d`) or a simple -/// ISO 8601-style duration (`P14D`, `-P14D`, `PT24H`), returns the UTC instant that far in the -/// past as RFC 3339. Otherwise returns `s` unchanged. +/// ISO 8601 duration with a single month, week, day, hour or minute component (`P1M`, `P14D`, +/// `-P2W`, `PT24H`, `PT30M`), returns the UTC instant that far in the past as RFC 3339. A month +/// counts as 30 days. Otherwise returns `s` unchanged. pub fn resolve_relative_datetime(s: &str) -> String { let normalized = s.to_ascii_lowercase(); - let normalized = normalized.trim_start_matches(['-', 'p', 't']); - let b = normalized.as_bytes(); - if b.len() < 2 { - return s.to_owned(); - } - let unit = b[b.len() - 1]; - if !matches!(unit, b'm' | b'h' | b'd' | b'w') { + let normalized = normalized.strip_prefix('-').unwrap_or(&normalized); + let (designator, amount) = if let Some(amount) = normalized.strip_prefix("pt") { + ("pt", amount) + } else if let Some(amount) = normalized.strip_prefix('p') { + ("p", amount) + } else { + ("", normalized) + }; + let Some(unit) = amount.bytes().last() else { return s.to_owned(); - } - let prefix = &normalized[..normalized.len() - 1]; + }; + let seconds_per_unit = match (designator, unit) { + ("" | "pt", b'm') => 60, + ("" | "pt", b'h') => 3600, + ("" | "p", b'd') => 86_400, + ("" | "p", b'w') => 604_800, + ("p", b'm') => 30 * 86_400, + _ => return s.to_owned(), + }; + let prefix = &amount[..amount.len() - 1]; if prefix.is_empty() || !prefix.bytes().all(|c| c.is_ascii_digit()) { return s.to_owned(); } let Ok(n) = prefix.parse::() else { return s.to_owned(); }; - let secs = match unit { - b'm' => n.checked_mul(60), - b'h' => n.checked_mul(3600), - b'd' => n.checked_mul(86_400), - b'w' => n.checked_mul(604_800), - _ => None, - }; - let Some(secs) = secs else { + let Some(secs) = n.checked_mul(seconds_per_unit) else { return s.to_owned(); }; let duration = chrono::Duration::seconds(secs); @@ -221,6 +225,13 @@ mod tests { assert_approximately_now_minus(&resolve_relative_datetime("PT24H"), 24 * 3600); assert_approximately_now_minus(&resolve_relative_datetime("PT30M"), 30 * 60); assert_approximately_now_minus(&resolve_relative_datetime("-PT30M"), 30 * 60); + + assert_approximately_now_minus(&resolve_relative_datetime("P1M"), 30 * 86_400); + assert_approximately_now_minus(&resolve_relative_datetime("-P3M"), 90 * 86_400); + + for unsupported in ["P1H", "PT1D", "TP1M", "T5M", "PP5M"] { + assert_eq!(resolve_relative_datetime(unsupported), unsupported); + } } #[test]