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
28pub fn parse_window(s: &str) -> CliResult<Duration> {
31 let s = s.trim();
32 let err = || {
33 CliError::Config(format!(
34 "'{s}' is not a valid window — use e.g. 45s, 30m, 6h, 1d, 1w"
35 ))
36 };
37 let (num, unit) = match s.chars().last() {
38 Some(c) if c.is_ascii_digit() => (s, "s"),
39 Some(c) => (&s[..s.len() - c.len_utf8()], &s[s.len() - c.len_utf8()..]),
40 None => return Err(err()),
41 };
42 let n: i64 = num.parse().map_err(|_| err())?;
43 if n <= 0 {
44 return Err(CliError::Config(format!(
45 "window '{s}' must be a positive duration"
46 )));
47 }
48 let dur = match unit {
49 "s" => Duration::seconds(n),
50 "m" => Duration::minutes(n),
51 "h" => Duration::hours(n),
52 "d" => Duration::days(n),
53 "w" => Duration::weeks(n),
54 _ => return Err(err()),
55 };
56 Ok(dur)
57}
58
59pub fn parse_boundary(s: &str, tz: chrono_tz::Tz) -> CliResult<DateTime<FixedOffset>> {
63 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
64 return Ok(dt.with_timezone(&tz).fixed_offset());
65 }
66 if let Ok(date) = s.parse::<chrono::NaiveDate>() {
67 let midnight = date
68 .and_hms_opt(0, 0, 0)
69 .ok_or_else(|| CliError::Config(format!("'{s}' has no valid midnight in {tz}")))?;
70 let local = tz
71 .from_local_datetime(&midnight)
72 .earliest()
73 .ok_or_else(|| {
74 CliError::Config(format!("'{s}' midnight does not exist in {tz} (DST gap)"))
75 })?;
76 return Ok(local.fixed_offset());
77 }
78 Err(CliError::Config(format!(
79 "'{s}' is not RFC3339 (2026-06-01T00:00:00Z) or a date (2026-06-01)"
80 )))
81}
82
83pub fn plan_windows(
89 from: DateTime<FixedOffset>,
90 to: DateTime<FixedOffset>,
91 window: Option<Duration>,
92 tz: chrono_tz::Tz,
93) -> CliResult<Vec<BackfillUnit>> {
94 if from >= to {
95 return Err(CliError::Config(format!(
96 "--from ({from}) must be before --to ({to})"
97 )));
98 }
99 let mut units = Vec::new();
100 let mut cursor = from.with_timezone(&Utc);
101 let end = to.with_timezone(&Utc);
102 let step = window.unwrap_or_else(|| end - cursor);
103 while cursor < end {
104 if units.len() >= MAX_UNITS {
105 return Err(CliError::Config(format!(
106 "the range would produce more than {MAX_UNITS} units with this --window — \
107 use a larger window"
108 )));
109 }
110 let unit_end = (cursor + step).min(end);
111 units.push(BackfillUnit {
112 id: cursor.format("%Y%m%dT%H%M%SZ").to_string(),
113 start: cursor.with_timezone(&tz).fixed_offset(),
114 end: unit_end.with_timezone(&tz).fixed_offset(),
115 });
116 cursor = unit_end;
117 }
118 Ok(units)
119}
120
121pub fn substitute_unit_tokens(value: &mut Value, unit: &BackfillUnit) -> CliResult<()> {
126 match value {
127 Value::String(s) => {
128 *s = substitute_in_str(s, unit)?;
129 Ok(())
130 }
131 Value::Array(a) => a
132 .iter_mut()
133 .try_for_each(|v| substitute_unit_tokens(v, unit)),
134 Value::Object(m) => m
135 .values_mut()
136 .try_for_each(|v| substitute_unit_tokens(v, unit)),
137 _ => Ok(()),
138 }
139}
140
141fn substitute_in_str(input: &str, unit: &BackfillUnit) -> CliResult<String> {
142 const PREFIX: &str = "${backfill.";
143 let mut out = String::with_capacity(input.len());
144 let mut rest = input;
145 while let Some(pos) = rest.find(PREFIX) {
146 out.push_str(&rest[..pos]);
147 let after = &rest[pos + PREFIX.len()..];
148 let close = after.find('}').ok_or_else(|| {
149 CliError::Config(format!("unterminated ${{backfill.…}} token in '{input}'"))
150 })?;
151 let token = &after[..close];
152 let rendered = match token {
153 "start" => unit.start.to_rfc3339(),
154 "end" => unit.end.to_rfc3339(),
155 "start_date" => unit.start.format("%Y-%m-%d").to_string(),
156 "end_date" => unit.end.format("%Y-%m-%d").to_string(),
157 "start_unix" => unit.start.timestamp().to_string(),
158 "end_unix" => unit.end.timestamp().to_string(),
159 "unit" => unit.id.clone(),
160 other => {
161 return Err(CliError::Config(format!(
162 "unknown token ${{backfill.{other}}} — supported: start, end, start_date, \
163 end_date, start_unix, end_unix, unit"
164 )));
165 }
166 };
167 out.push_str(&rendered);
168 rest = &after[close + 1..];
169 }
170 out.push_str(rest);
171 Ok(out)
172}
173
174pub fn range_hash(descriptor: &str) -> String {
178 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
179 const PRIME: u64 = 0x0000_0100_0000_01b3;
180 let mut hash = OFFSET;
181 for b in descriptor.as_bytes() {
182 hash ^= u64::from(*b);
183 hash = hash.wrapping_mul(PRIME);
184 }
185 format!("{hash:016x}")
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use serde_json::json;
192
193 fn tz(name: &str) -> chrono_tz::Tz {
194 name.parse().unwrap()
195 }
196
197 #[test]
198 fn window_durations_parse() {
199 assert_eq!(parse_window("45s").unwrap(), Duration::seconds(45));
200 assert_eq!(parse_window("30m").unwrap(), Duration::minutes(30));
201 assert_eq!(parse_window("6h").unwrap(), Duration::hours(6));
202 assert_eq!(parse_window("1d").unwrap(), Duration::days(1));
203 assert_eq!(parse_window("2w").unwrap(), Duration::weeks(2));
204 assert_eq!(parse_window("3600").unwrap(), Duration::seconds(3600));
205 assert!(parse_window("0d").is_err());
206 assert!(parse_window("-1h").is_err());
207 assert!(parse_window("soon").is_err());
208 assert!(parse_window("1y").is_err());
209 }
210
211 #[test]
212 fn boundaries_parse_rfc3339_and_dates() {
213 let utc = tz("UTC");
214 let dt = parse_boundary("2026-06-01T12:30:00Z", utc).unwrap();
215 assert_eq!(dt.to_rfc3339(), "2026-06-01T12:30:00+00:00");
216 let ny = tz("America/New_York");
218 let dt = parse_boundary("2026-06-01", ny).unwrap();
219 assert_eq!(dt.to_rfc3339(), "2026-06-01T00:00:00-04:00");
220 assert!(parse_boundary("yesterday", utc).is_err());
221 }
222
223 #[test]
224 fn thirty_one_days_one_day_window_is_31_units() {
225 let utc = tz("UTC");
228 let from = parse_boundary("2026-06-01", utc).unwrap();
229 let to = parse_boundary("2026-07-02", utc).unwrap();
230 let units = plan_windows(from, to, Some(Duration::days(1)), utc).unwrap();
231 assert_eq!(units.len(), 31);
232 assert_eq!(units[0].id, "20260601T000000Z");
233 assert_eq!(units[0].start.to_rfc3339(), "2026-06-01T00:00:00+00:00");
234 assert_eq!(units[0].end.to_rfc3339(), "2026-06-02T00:00:00+00:00");
235 for w in units.windows(2) {
237 assert_eq!(w[0].end, w[1].start);
238 }
239 assert_eq!(units[30].end.to_rfc3339(), "2026-07-02T00:00:00+00:00");
240 }
241
242 #[test]
243 fn last_window_truncates_at_to() {
244 let utc = tz("UTC");
245 let from = parse_boundary("2026-06-01T00:00:00Z", utc).unwrap();
246 let to = parse_boundary("2026-06-01T05:30:00Z", utc).unwrap();
247 let units = plan_windows(from, to, Some(Duration::hours(2)), utc).unwrap();
248 assert_eq!(units.len(), 3);
249 assert_eq!(units[2].start.to_rfc3339(), "2026-06-01T04:00:00+00:00");
250 assert_eq!(units[2].end.to_rfc3339(), "2026-06-01T05:30:00+00:00");
251 }
252
253 #[test]
254 fn no_window_is_a_single_unit() {
255 let utc = tz("UTC");
256 let from = parse_boundary("2026-06-01", utc).unwrap();
257 let to = parse_boundary("2026-07-01", utc).unwrap();
258 let units = plan_windows(from, to, None, utc).unwrap();
259 assert_eq!(units.len(), 1);
260 assert_eq!(units[0].start, from);
261 assert_eq!(units[0].end, to);
262 }
263
264 #[test]
265 fn dst_transition_produces_no_gap_or_overlap() {
266 let ny = tz("America/New_York");
269 let from = parse_boundary("2026-03-07", ny).unwrap();
270 let to = parse_boundary("2026-03-10T00:00:00-04:00", ny).unwrap();
271 let units = plan_windows(from, to, Some(Duration::days(1)), ny).unwrap();
272 for w in units.windows(2) {
273 assert_eq!(w[0].end, w[1].start, "no gap/overlap across DST");
274 }
275 assert!(units[0].start.to_rfc3339().ends_with("-05:00"));
277 assert!(units.last().unwrap().end.to_rfc3339().ends_with("-04:00"));
278 }
279
280 #[test]
281 fn rejects_inverted_range_and_unit_explosion() {
282 let utc = tz("UTC");
283 let from = parse_boundary("2026-06-02", utc).unwrap();
284 let to = parse_boundary("2026-06-01", utc).unwrap();
285 assert!(plan_windows(from, to, None, utc).is_err());
286
287 let from = parse_boundary("2020-01-01", utc).unwrap();
288 let to = parse_boundary("2026-01-01", utc).unwrap();
289 let err = plan_windows(from, to, Some(Duration::minutes(1)), utc).unwrap_err();
290 assert!(err.to_string().contains("larger window"), "{err}");
291 }
292
293 #[test]
294 fn tokens_substitute_in_nested_config() {
295 let utc = tz("UTC");
296 let unit = BackfillUnit {
297 id: "20260601T000000Z".into(),
298 start: parse_boundary("2026-06-01T00:00:00Z", utc).unwrap(),
299 end: parse_boundary("2026-06-02T00:00:00Z", utc).unwrap(),
300 };
301 let mut cfg = json!({
302 "query": "SELECT * FROM t WHERE ts >= '${backfill.start}' AND ts < '${backfill.end}'",
303 "nested": { "path": "dt=${backfill.start_date}/part-${backfill.unit}.jsonl" },
304 "unix": ["${backfill.start_unix}", "${backfill.end_unix}"],
305 "count": 3,
306 });
307 substitute_unit_tokens(&mut cfg, &unit).unwrap();
308 assert_eq!(
309 cfg["query"],
310 "SELECT * FROM t WHERE ts >= '2026-06-01T00:00:00+00:00' AND ts < '2026-06-02T00:00:00+00:00'"
311 );
312 assert_eq!(
313 cfg["nested"]["path"],
314 "dt=2026-06-01/part-20260601T000000Z.jsonl"
315 );
316 assert_eq!(cfg["unix"][0], "1780272000");
317 assert_eq!(cfg["count"], 3);
318 }
319
320 #[test]
321 fn unknown_or_unterminated_token_is_a_typed_error() {
322 let utc = tz("UTC");
323 let unit = BackfillUnit {
324 id: "u".into(),
325 start: parse_boundary("2026-06-01", utc).unwrap(),
326 end: parse_boundary("2026-06-02", utc).unwrap(),
327 };
328 let mut bad = json!({"q": "${backfill.begin}"});
329 let err = substitute_unit_tokens(&mut bad, &unit).unwrap_err();
330 assert!(err.to_string().contains("backfill.begin"), "{err}");
331 let mut unterminated = json!({"q": "${backfill.start"});
332 assert!(substitute_unit_tokens(&mut unterminated, &unit).is_err());
333 }
334
335 #[test]
336 fn range_hash_is_stable_and_distinct() {
337 let a = range_hash("2026-06-01|2026-07-01|1d");
338 assert_eq!(a, range_hash("2026-06-01|2026-07-01|1d"), "deterministic");
339 assert_ne!(a, range_hash("2026-06-01|2026-07-01|6h"));
340 assert_eq!(a.len(), 16);
341 }
342}