1use crate::error::{CliError, CliResult};
6use chrono::{DateTime, Duration, FixedOffset, TimeZone, Utc};
7use serde_json::Value;
8
9pub const MAX_UNITS: usize = 10_000;
12pub const WARN_UNITS: usize = 1_000;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct BackfillUnit {
18 pub id: String,
21 pub start: DateTime<FixedOffset>,
24 pub end: DateTime<FixedOffset>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum WindowStep {
38 Absolute(Duration),
40 Days(i64),
42 Weeks(i64),
44}
45
46impl std::fmt::Display for WindowStep {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 Self::Absolute(d) => write!(f, "{}", d.num_seconds()),
58 Self::Days(n) => write!(f, "{n}d"),
59 Self::Weeks(n) => write!(f, "{n}w"),
60 }
61 }
62}
63
64impl WindowStep {
65 fn nominal(self) -> Duration {
67 match self {
68 Self::Absolute(d) => d,
69 Self::Days(n) => Duration::days(n),
70 Self::Weeks(n) => Duration::weeks(n),
71 }
72 }
73}
74
75pub fn parse_window(s: &str) -> CliResult<WindowStep> {
81 let s = s.trim();
82 let err = || {
83 CliError::Config(format!(
84 "'{s}' is not a valid window — use e.g. 45s, 30m, 6h, 1d, 1w"
85 ))
86 };
87 let (num, unit) = match s.chars().last() {
88 Some(c) if c.is_ascii_digit() => (s, "s"),
89 Some(c) => (&s[..s.len() - c.len_utf8()], &s[s.len() - c.len_utf8()..]),
90 None => return Err(err()),
91 };
92 let n: i64 = num.parse().map_err(|_| err())?;
93 if n <= 0 {
94 return Err(CliError::Config(format!(
95 "window '{s}' must be a positive duration"
96 )));
97 }
98 let step = match unit {
99 "s" => WindowStep::Absolute(Duration::seconds(n)),
100 "m" => WindowStep::Absolute(Duration::minutes(n)),
101 "h" => WindowStep::Absolute(Duration::hours(n)),
102 "d" => WindowStep::Days(n),
103 "w" => WindowStep::Weeks(n),
104 _ => return Err(err()),
105 };
106 Ok(step)
107}
108
109fn advance_calendar(cursor: DateTime<Utc>, tz: chrono_tz::Tz, days: i64) -> Option<DateTime<Utc>> {
119 let naive = cursor
120 .with_timezone(&tz)
121 .naive_local()
122 .checked_add_signed(Duration::days(days))?;
123 for extra_hours in 0..=3 {
124 let candidate = naive.checked_add_signed(Duration::hours(extra_hours))?;
125 if let Some(local) = tz.from_local_datetime(&candidate).earliest() {
126 return Some(local.with_timezone(&Utc));
127 }
128 }
129 None
130}
131
132pub fn parse_boundary(s: &str, tz: chrono_tz::Tz) -> CliResult<DateTime<FixedOffset>> {
136 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
137 return Ok(dt.with_timezone(&tz).fixed_offset());
138 }
139 if let Ok(date) = s.parse::<chrono::NaiveDate>() {
140 let midnight = date
141 .and_hms_opt(0, 0, 0)
142 .ok_or_else(|| CliError::Config(format!("'{s}' has no valid midnight in {tz}")))?;
143 let local = tz
144 .from_local_datetime(&midnight)
145 .earliest()
146 .ok_or_else(|| {
147 CliError::Config(format!("'{s}' midnight does not exist in {tz} (DST gap)"))
148 })?;
149 return Ok(local.fixed_offset());
150 }
151 Err(CliError::Config(format!(
152 "'{s}' is not RFC3339 (2026-06-01T00:00:00Z) or a date (2026-06-01)"
153 )))
154}
155
156pub fn plan_windows(
162 from: DateTime<FixedOffset>,
163 to: DateTime<FixedOffset>,
164 window: Option<WindowStep>,
165 tz: chrono_tz::Tz,
166) -> CliResult<Vec<BackfillUnit>> {
167 if from >= to {
168 return Err(CliError::Config(format!(
169 "--from ({from}) must be before --to ({to})"
170 )));
171 }
172 let mut units = Vec::new();
173 let mut cursor = from.with_timezone(&Utc);
174 let end = to.with_timezone(&Utc);
175 let step = window.unwrap_or(WindowStep::Absolute(end - cursor));
176 while cursor < end {
177 if units.len() >= MAX_UNITS {
178 return Err(CliError::Config(format!(
179 "the range would produce more than {MAX_UNITS} units with this --window — \
180 use a larger window"
181 )));
182 }
183 let next = match step {
188 WindowStep::Absolute(d) => cursor + d,
189 WindowStep::Days(n) => {
190 advance_calendar(cursor, tz, n).unwrap_or(cursor + step.nominal())
191 }
192 WindowStep::Weeks(n) => {
193 advance_calendar(cursor, tz, n * 7).unwrap_or(cursor + step.nominal())
194 }
195 };
196 let next = if next > cursor {
197 next
198 } else {
199 cursor + step.nominal()
200 };
201 let unit_end = next.min(end);
202 units.push(BackfillUnit {
203 id: cursor.format("%Y%m%dT%H%M%SZ").to_string(),
204 start: cursor.with_timezone(&tz).fixed_offset(),
205 end: unit_end.with_timezone(&tz).fixed_offset(),
206 });
207 cursor = unit_end;
208 }
209 Ok(units)
210}
211
212pub fn substitute_unit_tokens(value: &mut Value, unit: &BackfillUnit) -> CliResult<()> {
217 match value {
218 Value::String(s) => {
219 *s = substitute_in_str(s, unit)?;
220 Ok(())
221 }
222 Value::Array(a) => a
223 .iter_mut()
224 .try_for_each(|v| substitute_unit_tokens(v, unit)),
225 Value::Object(m) => m
226 .values_mut()
227 .try_for_each(|v| substitute_unit_tokens(v, unit)),
228 _ => Ok(()),
229 }
230}
231
232fn substitute_in_str(input: &str, unit: &BackfillUnit) -> CliResult<String> {
233 const PREFIX: &str = "${backfill.";
234 let mut out = String::with_capacity(input.len());
235 let mut rest = input;
236 while let Some(pos) = rest.find(PREFIX) {
237 out.push_str(&rest[..pos]);
238 let after = &rest[pos + PREFIX.len()..];
239 let close = after.find('}').ok_or_else(|| {
240 CliError::Config(format!("unterminated ${{backfill.…}} token in '{input}'"))
241 })?;
242 let token = &after[..close];
243 let rendered = match token {
244 "start" => unit.start.to_rfc3339(),
245 "end" => unit.end.to_rfc3339(),
246 "start_date" => unit.start.format("%Y-%m-%d").to_string(),
247 "end_date" => unit.end.format("%Y-%m-%d").to_string(),
248 "start_unix" => unit.start.timestamp().to_string(),
249 "end_unix" => unit.end.timestamp().to_string(),
250 "unit" => unit.id.clone(),
251 other => {
252 return Err(CliError::Config(format!(
253 "unknown token ${{backfill.{other}}} — supported: start, end, start_date, \
254 end_date, start_unix, end_unix, unit"
255 )));
256 }
257 };
258 out.push_str(&rendered);
259 rest = &after[close + 1..];
260 }
261 out.push_str(rest);
262 Ok(out)
263}
264
265pub fn range_hash(descriptor: &str) -> String {
269 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
270 const PRIME: u64 = 0x0000_0100_0000_01b3;
271 let mut hash = OFFSET;
272 for b in descriptor.as_bytes() {
273 hash ^= u64::from(*b);
274 hash = hash.wrapping_mul(PRIME);
275 }
276 format!("{hash:016x}")
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use chrono::Timelike;
283 use serde_json::json;
284
285 fn tz(name: &str) -> chrono_tz::Tz {
286 name.parse().unwrap()
287 }
288
289 #[test]
290 fn window_durations_parse() {
291 assert_eq!(
293 parse_window("45s").unwrap(),
294 WindowStep::Absolute(Duration::seconds(45))
295 );
296 assert_eq!(
297 parse_window("30m").unwrap(),
298 WindowStep::Absolute(Duration::minutes(30))
299 );
300 assert_eq!(
301 parse_window("6h").unwrap(),
302 WindowStep::Absolute(Duration::hours(6))
303 );
304 assert_eq!(
305 parse_window("3600").unwrap(),
306 WindowStep::Absolute(Duration::seconds(3600))
307 );
308 assert_eq!(parse_window("1d").unwrap(), WindowStep::Days(1));
310 assert_eq!(parse_window("2w").unwrap(), WindowStep::Weeks(2));
311 assert!(parse_window("0d").is_err());
312 assert!(parse_window("-1h").is_err());
313 assert!(parse_window("soon").is_err());
314 assert!(parse_window("1y").is_err());
315 }
316
317 #[test]
318 fn boundaries_parse_rfc3339_and_dates() {
319 let utc = tz("UTC");
320 let dt = parse_boundary("2026-06-01T12:30:00Z", utc).unwrap();
321 assert_eq!(dt.to_rfc3339(), "2026-06-01T12:30:00+00:00");
322 let ny = tz("America/New_York");
324 let dt = parse_boundary("2026-06-01", ny).unwrap();
325 assert_eq!(dt.to_rfc3339(), "2026-06-01T00:00:00-04:00");
326 assert!(parse_boundary("yesterday", utc).is_err());
327 }
328
329 #[test]
330 fn thirty_one_days_one_day_window_is_31_units() {
331 let utc = tz("UTC");
334 let from = parse_boundary("2026-06-01", utc).unwrap();
335 let to = parse_boundary("2026-07-02", utc).unwrap();
336 let units = plan_windows(from, to, Some(WindowStep::Days(1)), utc).unwrap();
337 assert_eq!(units.len(), 31);
338 assert_eq!(units[0].id, "20260601T000000Z");
339 assert_eq!(units[0].start.to_rfc3339(), "2026-06-01T00:00:00+00:00");
340 assert_eq!(units[0].end.to_rfc3339(), "2026-06-02T00:00:00+00:00");
341 for w in units.windows(2) {
343 assert_eq!(w[0].end, w[1].start);
344 }
345 assert_eq!(units[30].end.to_rfc3339(), "2026-07-02T00:00:00+00:00");
346 }
347
348 #[test]
349 fn last_window_truncates_at_to() {
350 let utc = tz("UTC");
351 let from = parse_boundary("2026-06-01T00:00:00Z", utc).unwrap();
352 let to = parse_boundary("2026-06-01T05:30:00Z", utc).unwrap();
353 let units = plan_windows(
354 from,
355 to,
356 Some(WindowStep::Absolute(Duration::hours(2))),
357 utc,
358 )
359 .unwrap();
360 assert_eq!(units.len(), 3);
361 assert_eq!(units[2].start.to_rfc3339(), "2026-06-01T04:00:00+00:00");
362 assert_eq!(units[2].end.to_rfc3339(), "2026-06-01T05:30:00+00:00");
363 }
364
365 #[test]
366 fn no_window_is_a_single_unit() {
367 let utc = tz("UTC");
368 let from = parse_boundary("2026-06-01", utc).unwrap();
369 let to = parse_boundary("2026-07-01", utc).unwrap();
370 let units = plan_windows(from, to, None, utc).unwrap();
371 assert_eq!(units.len(), 1);
372 assert_eq!(units[0].start, from);
373 assert_eq!(units[0].end, to);
374 }
375
376 #[test]
382 fn calendar_day_windows_stay_on_local_midnight_across_dst() {
383 let ny = tz("America/New_York");
384 let from = parse_boundary("2026-03-07", ny).unwrap();
385 let to = parse_boundary("2026-03-11", ny).unwrap();
386 let units = plan_windows(from, to, Some(WindowStep::Days(1)), ny).unwrap();
387
388 assert_eq!(units.len(), 4, "four calendar days");
389 for u in &units {
390 assert_eq!(
391 (u.start.hour(), u.start.minute()),
392 (0, 0),
393 "unit {} must start at local midnight, got {}",
394 u.id,
395 u.start
396 );
397 }
398 for w in units.windows(2) {
400 assert_eq!(w[0].end, w[1].start, "no gap/overlap");
401 }
402 let dates: Vec<String> = units
403 .iter()
404 .map(|u| u.start.format("%Y-%m-%d").to_string())
405 .collect();
406 assert_eq!(
407 dates,
408 ["2026-03-07", "2026-03-08", "2026-03-09", "2026-03-10"]
409 );
410 let spring_forward = &units[1];
412 assert_eq!(
413 (spring_forward.end - spring_forward.start).num_hours(),
414 23,
415 "2026-03-08 loses an hour"
416 );
417 }
418
419 #[test]
421 fn calendar_day_windows_handle_fall_back() {
422 let ny = tz("America/New_York");
423 let from = parse_boundary("2026-10-31", ny).unwrap();
424 let to = parse_boundary("2026-11-03", ny).unwrap();
425 let units = plan_windows(from, to, Some(WindowStep::Days(1)), ny).unwrap();
426 for u in &units {
427 assert_eq!((u.start.hour(), u.start.minute()), (0, 0), "{}", u.id);
428 }
429 let long_day = units
431 .iter()
432 .find(|u| u.start.format("%Y-%m-%d").to_string() == "2026-11-01")
433 .expect("the fall-back day is planned");
434 assert_eq!((long_day.end - long_day.start).num_hours(), 25);
435 }
436
437 #[test]
440 fn calendar_and_absolute_windows_differ_across_dst() {
441 let ny = tz("America/New_York");
442 let from = parse_boundary("2026-03-07", ny).unwrap();
443 let to = parse_boundary("2026-03-10", ny).unwrap();
444 let cal = plan_windows(from, to, Some(parse_window("1d").unwrap()), ny).unwrap();
445 let abs = plan_windows(from, to, Some(parse_window("24h").unwrap()), ny).unwrap();
446 assert_eq!(cal[2].start.hour(), 0, "calendar stays on midnight");
447 assert_eq!(abs[2].start.hour(), 1, "absolute drifts by the DST delta");
448 assert_ne!(cal[2].start, abs[2].start);
449 }
450
451 #[test]
454 fn window_descriptor_is_stable_for_absolute_and_distinct_for_calendar() {
455 assert_eq!(
456 WindowStep::Absolute(Duration::hours(6)).to_string(),
457 "21600"
458 );
459 assert_eq!(WindowStep::Absolute(Duration::days(1)).to_string(), "86400");
460 assert_eq!(WindowStep::Days(1).to_string(), "1d");
461 assert_eq!(WindowStep::Weeks(2).to_string(), "2w");
462 }
463
464 #[test]
465 fn dst_transition_produces_no_gap_or_overlap() {
466 let ny = tz("America/New_York");
469 let from = parse_boundary("2026-03-07", ny).unwrap();
470 let to = parse_boundary("2026-03-10T00:00:00-04:00", ny).unwrap();
471 let units =
472 plan_windows(from, to, Some(WindowStep::Absolute(Duration::days(1))), ny).unwrap();
473 for w in units.windows(2) {
474 assert_eq!(w[0].end, w[1].start, "no gap/overlap across DST");
475 }
476 assert!(units[0].start.to_rfc3339().ends_with("-05:00"));
478 assert!(units.last().unwrap().end.to_rfc3339().ends_with("-04:00"));
479 }
480
481 #[test]
482 fn rejects_inverted_range_and_unit_explosion() {
483 let utc = tz("UTC");
484 let from = parse_boundary("2026-06-02", utc).unwrap();
485 let to = parse_boundary("2026-06-01", utc).unwrap();
486 assert!(plan_windows(from, to, None, utc).is_err());
487
488 let from = parse_boundary("2020-01-01", utc).unwrap();
489 let to = parse_boundary("2026-01-01", utc).unwrap();
490 let err = plan_windows(
491 from,
492 to,
493 Some(WindowStep::Absolute(Duration::minutes(1))),
494 utc,
495 )
496 .unwrap_err();
497 assert!(err.to_string().contains("larger window"), "{err}");
498 }
499
500 #[test]
501 fn tokens_substitute_in_nested_config() {
502 let utc = tz("UTC");
503 let unit = BackfillUnit {
504 id: "20260601T000000Z".into(),
505 start: parse_boundary("2026-06-01T00:00:00Z", utc).unwrap(),
506 end: parse_boundary("2026-06-02T00:00:00Z", utc).unwrap(),
507 };
508 let mut cfg = json!({
509 "query": "SELECT * FROM t WHERE ts >= '${backfill.start}' AND ts < '${backfill.end}'",
510 "nested": { "path": "dt=${backfill.start_date}/part-${backfill.unit}.jsonl" },
511 "unix": ["${backfill.start_unix}", "${backfill.end_unix}"],
512 "count": 3,
513 });
514 substitute_unit_tokens(&mut cfg, &unit).unwrap();
515 assert_eq!(
516 cfg["query"],
517 "SELECT * FROM t WHERE ts >= '2026-06-01T00:00:00+00:00' AND ts < '2026-06-02T00:00:00+00:00'"
518 );
519 assert_eq!(
520 cfg["nested"]["path"],
521 "dt=2026-06-01/part-20260601T000000Z.jsonl"
522 );
523 assert_eq!(cfg["unix"][0], "1780272000");
524 assert_eq!(cfg["count"], 3);
525 }
526
527 #[test]
528 fn unknown_or_unterminated_token_is_a_typed_error() {
529 let utc = tz("UTC");
530 let unit = BackfillUnit {
531 id: "u".into(),
532 start: parse_boundary("2026-06-01", utc).unwrap(),
533 end: parse_boundary("2026-06-02", utc).unwrap(),
534 };
535 let mut bad = json!({"q": "${backfill.begin}"});
536 let err = substitute_unit_tokens(&mut bad, &unit).unwrap_err();
537 assert!(err.to_string().contains("backfill.begin"), "{err}");
538 let mut unterminated = json!({"q": "${backfill.start"});
539 assert!(substitute_unit_tokens(&mut unterminated, &unit).is_err());
540 }
541
542 #[test]
543 fn range_hash_is_stable_and_distinct() {
544 let a = range_hash("2026-06-01|2026-07-01|1d");
545 assert_eq!(a, range_hash("2026-06-01|2026-07-01|1d"), "deterministic");
546 assert_ne!(a, range_hash("2026-06-01|2026-07-01|6h"));
547 assert_eq!(a.len(), 16);
548 }
549}