1#![allow(clippy::missing_errors_doc)]
2#![allow(clippy::missing_panics_doc)]
3
4use 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 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}