Skip to main content

stmo_cli/commands/
dynamic_dates.rs

1#![allow(clippy::missing_errors_doc)]
2#![allow(clippy::missing_panics_doc)]
3
4//! Port of Redash's client-side dynamic-date resolution.
5//!
6//! Redash resolves no `d_*` tokens server-side: its frontend expands them in
7//! `getExecutionValue()` before sending, so the server (and both the local
8//! ad-hoc and `--remote` stored-query API paths) only ever sees concrete
9//! dates. The stored-query endpoint outright rejects raw `d_*` values. We must
10//! therefore resolve tokens here, ahead of either path.
11//!
12//! The token set and semantics mirror upstream Redash (verified against commit
13//! `d32d5ae4`):
14//! - single `date`/`datetime*`: `client/app/services/parameters/DateParameter.js`
15//!   (`DYNAMIC_DATES` — only `now` and `yesterday`)
16//! - `*-range`: `client/app/services/parameters/DateRangeParameter.js`
17//!   (`DYNAMIC_DATE_RANGES`)
18//!
19//! Matching details worth preserving on a Redash upgrade: `last_*` ranges end
20//! at the current instant (moment's `untilNow`), not end-of-day; the week
21//! starts Sunday (moment.js default `startOf("week")`); formats come from
22//! upstream `DATETIME_FORMATS`.
23
24use chrono::{Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime};
25use serde_json::{Value, json};
26
27const PREFIX: &str = "d_";
28
29fn single_date_format(param_type: &str) -> Option<&'static str> {
30    match param_type {
31        "date" => Some("%Y-%m-%d"),
32        "datetime-local" => Some("%Y-%m-%d %H:%M"),
33        "datetime-with-seconds" => Some("%Y-%m-%d %H:%M:%S"),
34        _ => None,
35    }
36}
37
38fn range_format(param_type: &str) -> Option<&'static str> {
39    match param_type {
40        "date-range" => Some("%Y-%m-%d"),
41        "datetime-range" => Some("%Y-%m-%d %H:%M"),
42        "datetime-range-with-seconds" => Some("%Y-%m-%d %H:%M:%S"),
43        _ => None,
44    }
45}
46
47#[must_use]
48pub fn resolve(value: &Value, param_type: &str) -> Option<Value> {
49    let Value::String(raw) = value else {
50        return None;
51    };
52    let token = raw.strip_prefix(PREFIX)?;
53    resolve_with_now(token, param_type, Local::now().naive_local())
54}
55
56fn resolve_with_now(token: &str, param_type: &str, now: NaiveDateTime) -> Option<Value> {
57    if let Some(format) = single_date_format(param_type) {
58        return resolve_single(token, now)
59            .map(|moment| Value::String(format_moment(moment, format)));
60    }
61    if let Some(format) = range_format(param_type) {
62        let (start, end) = resolve_range(token, now)?;
63        return Some(json!({
64            "start": format_moment(start, format),
65            "end": format_moment(end, format),
66        }));
67    }
68    None
69}
70
71fn format_moment(moment: NaiveDateTime, format: &str) -> String {
72    moment.format(format).to_string()
73}
74
75fn resolve_single(token: &str, now: NaiveDateTime) -> Option<NaiveDateTime> {
76    match token {
77        "now" => Some(now),
78        "yesterday" => Some(now - Duration::days(1)),
79        _ => None,
80    }
81}
82
83fn resolve_range(token: &str, now: NaiveDateTime) -> Option<(NaiveDateTime, NaiveDateTime)> {
84    let today = now.date();
85
86    let range = match token {
87        "today" => (start_of_day(today), end_of_day(today)),
88        "yesterday" => {
89            let day = today - Duration::days(1);
90            (start_of_day(day), end_of_day(day))
91        }
92        "this_week" => (
93            start_of_day(start_of_week(today)),
94            end_of_day(end_of_week(today)),
95        ),
96        "this_month" => (
97            start_of_day(start_of_month(today)),
98            end_of_day(end_of_month(today)),
99        ),
100        "this_year" => (
101            start_of_day(start_of_year(today)),
102            end_of_day(end_of_year(today)),
103        ),
104        "last_week" => {
105            let base = today - Duration::days(7);
106            (
107                start_of_day(start_of_week(base)),
108                end_of_day(end_of_week(base)),
109            )
110        }
111        "last_month" => {
112            let base = subtract_months(today, 1);
113            (
114                start_of_day(start_of_month(base)),
115                end_of_day(end_of_month(base)),
116            )
117        }
118        "last_year" => {
119            let base = subtract_months(today, 12);
120            (
121                start_of_day(start_of_year(base)),
122                end_of_day(end_of_year(base)),
123            )
124        }
125        "last_hour" => (now - Duration::hours(1), now),
126        "last_8_hours" => (now - Duration::hours(8), now),
127        "last_24_hours" => (now - Duration::hours(24), now),
128        "last_7_days" => (start_of_day(today - Duration::days(7)), now),
129        "last_14_days" => (start_of_day(today - Duration::days(14)), now),
130        "last_30_days" => (start_of_day(today - Duration::days(30)), now),
131        "last_60_days" => (start_of_day(today - Duration::days(60)), now),
132        "last_90_days" => (start_of_day(today - Duration::days(90)), now),
133        "last_12_months" => (start_of_day(subtract_months(today, 12)), now),
134        "last_2_years" => (start_of_day(subtract_months(today, 24)), now),
135        "last_3_years" => (start_of_day(subtract_months(today, 36)), now),
136        "last_10_years" => (start_of_day(subtract_months(today, 120)), now),
137        _ => return None,
138    };
139
140    Some(range)
141}
142
143fn start_of_day(date: NaiveDate) -> NaiveDateTime {
144    date.and_hms_opt(0, 0, 0).unwrap()
145}
146
147fn end_of_day(date: NaiveDate) -> NaiveDateTime {
148    date.and_hms_opt(23, 59, 59).unwrap()
149}
150
151fn start_of_week(date: NaiveDate) -> NaiveDate {
152    date - Duration::days(i64::from(date.weekday().num_days_from_sunday()))
153}
154
155fn end_of_week(date: NaiveDate) -> NaiveDate {
156    start_of_week(date) + Duration::days(6)
157}
158
159fn start_of_month(date: NaiveDate) -> NaiveDate {
160    date.with_day(1).unwrap()
161}
162
163fn end_of_month(date: NaiveDate) -> NaiveDate {
164    start_of_month(date + Months::new(1)) - Duration::days(1)
165}
166
167fn start_of_year(date: NaiveDate) -> NaiveDate {
168    NaiveDate::from_ymd_opt(date.year(), 1, 1).unwrap()
169}
170
171fn end_of_year(date: NaiveDate) -> NaiveDate {
172    NaiveDate::from_ymd_opt(date.year(), 12, 31).unwrap()
173}
174
175fn subtract_months(date: NaiveDate, months: u32) -> NaiveDate {
176    date - Months::new(months)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn at(date: &str, time: &str) -> NaiveDateTime {
184        NaiveDateTime::parse_from_str(&format!("{date} {time}"), "%Y-%m-%d %H:%M:%S").unwrap()
185    }
186
187    fn day(date: &str) -> NaiveDateTime {
188        at(date, "12:30:45")
189    }
190
191    #[test]
192    fn single_now_formats_by_type() {
193        let now = at("2026-06-25", "14:05:09");
194        assert_eq!(
195            resolve_with_now("now", "date", now),
196            Some(json!("2026-06-25"))
197        );
198        assert_eq!(
199            resolve_with_now("now", "datetime-local", now),
200            Some(json!("2026-06-25 14:05"))
201        );
202        assert_eq!(
203            resolve_with_now("now", "datetime-with-seconds", now),
204            Some(json!("2026-06-25 14:05:09"))
205        );
206    }
207
208    #[test]
209    fn single_yesterday() {
210        let now = day("2026-06-25");
211        assert_eq!(
212            resolve_with_now("yesterday", "date", now),
213            Some(json!("2026-06-24"))
214        );
215    }
216
217    #[test]
218    fn single_unknown_token_is_left_alone() {
219        let now = day("2026-06-25");
220        assert_eq!(resolve_with_now("last_7_days", "date", now), None);
221    }
222
223    #[test]
224    fn range_today() {
225        let now = at("2026-06-25", "14:05:09");
226        assert_eq!(
227            resolve_with_now("today", "date-range", now),
228            Some(json!({"start": "2026-06-25", "end": "2026-06-25"}))
229        );
230    }
231
232    #[test]
233    fn range_last_7_days_ends_at_now() {
234        let now = at("2026-06-25", "14:05:09");
235        assert_eq!(
236            resolve_with_now("last_7_days", "date-range", now),
237            Some(json!({"start": "2026-06-18", "end": "2026-06-25"}))
238        );
239        assert_eq!(
240            resolve_with_now("last_7_days", "datetime-range-with-seconds", now),
241            Some(json!({"start": "2026-06-18 00:00:00", "end": "2026-06-25 14:05:09"}))
242        );
243    }
244
245    #[test]
246    fn range_this_week_starts_sunday() {
247        // 2026-06-25 is a Thursday; the enclosing week runs Sun 21 .. Sat 27.
248        let now = day("2026-06-25");
249        assert_eq!(
250            resolve_with_now("this_week", "date-range", now),
251            Some(json!({"start": "2026-06-21", "end": "2026-06-27"}))
252        );
253    }
254
255    #[test]
256    fn range_last_week() {
257        let now = day("2026-06-25");
258        assert_eq!(
259            resolve_with_now("last_week", "date-range", now),
260            Some(json!({"start": "2026-06-14", "end": "2026-06-20"}))
261        );
262    }
263
264    #[test]
265    fn range_this_month() {
266        let now = day("2026-06-25");
267        assert_eq!(
268            resolve_with_now("this_month", "date-range", now),
269            Some(json!({"start": "2026-06-01", "end": "2026-06-30"}))
270        );
271    }
272
273    #[test]
274    fn range_last_month_clamps_into_prior_month() {
275        let now = day("2026-03-31");
276        assert_eq!(
277            resolve_with_now("last_month", "date-range", now),
278            Some(json!({"start": "2026-02-01", "end": "2026-02-28"}))
279        );
280    }
281
282    #[test]
283    fn range_this_year() {
284        let now = day("2026-06-25");
285        assert_eq!(
286            resolve_with_now("this_year", "date-range", now),
287            Some(json!({"start": "2026-01-01", "end": "2026-12-31"}))
288        );
289    }
290
291    #[test]
292    fn range_last_year() {
293        let now = day("2026-06-25");
294        assert_eq!(
295            resolve_with_now("last_year", "date-range", now),
296            Some(json!({"start": "2025-01-01", "end": "2025-12-31"}))
297        );
298    }
299
300    #[test]
301    fn range_last_12_months() {
302        let now = at("2026-06-25", "14:05:09");
303        assert_eq!(
304            resolve_with_now("last_12_months", "date-range", now),
305            Some(json!({"start": "2025-06-25", "end": "2026-06-25"}))
306        );
307    }
308
309    #[test]
310    fn range_token_on_single_type_is_left_alone() {
311        let now = day("2026-06-25");
312        assert_eq!(resolve_with_now("last_7_days", "date", now), None);
313    }
314
315    #[test]
316    fn non_date_type_is_left_alone() {
317        let now = day("2026-06-25");
318        assert_eq!(resolve_with_now("now", "text", now), None);
319    }
320
321    #[test]
322    fn resolve_ignores_non_string_and_non_prefixed() {
323        assert_eq!(resolve(&json!(5), "date"), None);
324        assert_eq!(resolve(&json!("2026-06-25"), "date"), None);
325    }
326}