1use super::spec::{CountBound, IntBound, PartitionSpec};
20use crate::chunking::{self, Bounds};
21use crate::error::{CliError, CliResult};
22use serde_json::Value;
23use std::collections::BTreeMap;
24
25const PREFIX: &str = "${partition.";
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PartitionChunk {
31 pub id: String,
34 pub tokens: BTreeMap<String, String>,
36 pub open_ended: bool,
40}
41
42pub fn plan(spec: &PartitionSpec) -> CliResult<Vec<PartitionChunk>> {
44 spec.validate()?;
45 match spec {
46 PartitionSpec::Integer {
47 from,
48 to,
49 chunk_size,
50 bounds,
51 to_unbounded,
52 } => {
53 let to = match to {
54 IntBound::Literal(v) => *v,
55 IntBound::Discovered(_) => {
56 return Err(CliError::Internal(
57 "partition: an undiscovered bound reached the planner — `resolve_bounds` must run before `plan`"
58 .into(),
59 ));
60 }
61 };
62 let chunks = chunking::plan_int_chunks(*from, to, *chunk_size, *bounds)?;
63 Ok(chunks
64 .into_iter()
65 .enumerate()
66 .map(|(i, c)| {
67 let open = to_unbounded.unwrap_or(false) && c.is_last;
68 let mut tokens = BTreeMap::new();
69 tokens.insert("start".into(), c.start.to_string());
70 tokens.insert("end".into(), c.end.to_string());
71 tokens.insert("index".into(), i.to_string());
72 tokens.insert("id".into(), c.id.clone());
73 PartitionChunk {
74 id: c.id,
75 tokens,
76 open_ended: open,
77 }
78 })
79 .collect())
80 }
81
82 PartitionSpec::Timestamp {
83 from,
84 to,
85 chunk_size,
86 timezone,
87 } => {
88 let tz: chrono_tz::Tz = timezone
89 .as_deref()
90 .unwrap_or("UTC")
91 .parse()
92 .map_err(|_| CliError::Config("invalid partition.timezone".into()))?;
93 let step = chunking::parse_window(chunk_size)?;
94 let from = chunking::parse_boundary(from, tz)?;
95 let to = chunking::parse_boundary(to, tz)?;
96 let chunks = chunking::plan_windows(from, to, Some(step), tz)?;
97 Ok(chunks
98 .into_iter()
99 .enumerate()
100 .map(|(i, c)| {
101 let mut tokens = BTreeMap::new();
102 tokens.insert("start".into(), c.start.to_rfc3339());
103 tokens.insert("end".into(), c.end.to_rfc3339());
104 tokens.insert("start_date".into(), c.start.format("%Y-%m-%d").to_string());
105 tokens.insert("end_date".into(), c.end.format("%Y-%m-%d").to_string());
106 tokens.insert("start_unix".into(), c.start.timestamp().to_string());
107 tokens.insert("end_unix".into(), c.end.timestamp().to_string());
108 tokens.insert("index".into(), i.to_string());
109 tokens.insert("id".into(), c.id.clone());
110 PartitionChunk {
111 id: c.id,
112 tokens,
113 open_ended: false,
114 }
115 })
116 .collect())
117 }
118
119 PartitionSpec::Offset { total, chunk_size } => {
120 let total = match total {
121 CountBound::Literal(v) => *v,
122 CountBound::Discovered(_) => {
123 return Err(CliError::Internal(
124 "partition: an undiscovered total reached the planner — `resolve_bounds` must run before `plan`"
125 .into(),
126 ));
127 }
128 };
129 let chunks = chunking::plan_offset_chunks(total, *chunk_size)?;
130 Ok(chunks
131 .into_iter()
132 .enumerate()
133 .map(|(i, c)| {
134 let mut tokens = BTreeMap::new();
135 tokens.insert("offset".into(), c.offset.to_string());
136 tokens.insert("limit".into(), c.limit.to_string());
137 tokens.insert("index".into(), i.to_string());
138 tokens.insert("id".into(), c.id.clone());
139 PartitionChunk {
140 id: c.id,
141 tokens,
142 open_ended: false,
143 }
144 })
145 .collect())
146 }
147 }
148}
149
150pub fn substitute(value: &mut Value, chunk: &PartitionChunk) -> CliResult<()> {
156 match value {
157 Value::String(s) => {
158 *s = substitute_in_str(s, chunk)?;
159 Ok(())
160 }
161 Value::Array(a) => a.iter_mut().try_for_each(|v| substitute(v, chunk)),
162 Value::Object(m) => m.values_mut().try_for_each(|v| substitute(v, chunk)),
163 _ => Ok(()),
164 }
165}
166
167fn substitute_in_str(input: &str, chunk: &PartitionChunk) -> CliResult<String> {
168 let mut out = String::with_capacity(input.len());
169 let mut rest = input;
170 while let Some(pos) = rest.find(PREFIX) {
171 out.push_str(&rest[..pos]);
172 let after = &rest[pos + PREFIX.len()..];
173 let close = after.find('}').ok_or_else(|| {
174 CliError::Config(format!("unterminated ${{partition.…}} token in '{input}'"))
175 })?;
176 let token = &after[..close];
177 let rendered = chunk.tokens.get(token).ok_or_else(|| {
178 CliError::Config(format!(
179 "unknown token ${{partition.{token}}} — this partition defines: {}",
180 chunk.tokens.keys().cloned().collect::<Vec<_>>().join(", ")
181 ))
182 })?;
183 out.push_str(rendered);
184 rest = &after[close + 1..];
185 }
186 out.push_str(rest);
187 Ok(out)
188}
189
190pub fn references_partition(serialized: &str) -> bool {
195 serialized.contains(PREFIX)
196}
197
198pub fn has_open_ended(chunks: &[PartitionChunk]) -> bool {
202 chunks.iter().any(|c| c.open_ended)
203}
204
205pub fn bounds_label(b: Bounds) -> &'static str {
207 match b {
208 Bounds::Inclusive => "inclusive",
209 Bounds::HalfOpen => "half_open",
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use serde_json::json;
217
218 fn int_spec(to_unbounded: bool) -> PartitionSpec {
219 PartitionSpec::Integer {
220 from: 0,
221 to: IntBound::Literal(24),
222 chunk_size: 10,
223 bounds: Bounds::Inclusive,
224 to_unbounded: to_unbounded.then_some(true),
225 }
226 }
227
228 #[test]
229 fn integer_chunks_carry_start_end_index_id() {
230 let chunks = plan(&int_spec(false)).unwrap();
231 assert_eq!(chunks.len(), 3);
232 assert_eq!(chunks[0].tokens["start"], "0");
233 assert_eq!(chunks[0].tokens["end"], "9");
234 assert_eq!(chunks[0].tokens["index"], "0");
235 assert_eq!(
236 chunks[2].tokens["end"], "24",
237 "final chunk truncates at `to`"
238 );
239 assert!(chunks.iter().all(|c| !c.open_ended));
240 }
241
242 #[test]
243 fn only_the_final_chunk_is_open_ended_and_only_when_asked() {
244 let chunks = plan(&int_spec(true)).unwrap();
245 assert_eq!(chunks.iter().filter(|c| c.open_ended).count(), 1);
246 assert!(chunks.last().unwrap().open_ended);
247 assert!(has_open_ended(&chunks));
248 assert!(!has_open_ended(&plan(&int_spec(false)).unwrap()));
249 }
250
251 #[test]
252 fn offset_chunks_carry_offset_and_limit() {
253 let chunks = plan(&PartitionSpec::Offset {
254 total: CountBound::Literal(25),
255 chunk_size: 10,
256 })
257 .unwrap();
258 assert_eq!(chunks.len(), 3);
259 assert_eq!(chunks[0].tokens["offset"], "0");
260 assert_eq!(chunks[0].tokens["limit"], "10");
261 assert_eq!(
262 chunks[2].tokens["limit"], "5",
263 "final limit is the remainder"
264 );
265 assert!(
266 !chunks[0].tokens.contains_key("start"),
267 "no id-range tokens"
268 );
269 }
270
271 #[test]
272 fn timestamp_chunks_carry_the_backfill_token_set() {
273 let chunks = plan(&PartitionSpec::Timestamp {
274 from: "2026-06-01".into(),
275 to: "2026-06-04".into(),
276 chunk_size: "1d".into(),
277 timezone: None,
278 })
279 .unwrap();
280 assert_eq!(chunks.len(), 3);
281 for t in [
282 "start",
283 "end",
284 "start_date",
285 "end_date",
286 "start_unix",
287 "end_unix",
288 ] {
289 assert!(chunks[0].tokens.contains_key(t), "missing {t}");
290 }
291 assert_eq!(chunks[0].tokens["start_date"], "2026-06-01");
292 assert_eq!(chunks[1].tokens["start_date"], "2026-06-02");
293 }
294
295 #[test]
296 fn substitutes_into_nested_string_leaves_only() {
297 let chunks = plan(&int_spec(false)).unwrap();
298 let mut cfg = json!({
299 "url": "https://api/x?from=${partition.start}&to=${partition.end}",
300 "nested": { "list": ["chunk-${partition.index}", 7, true] },
301 "count": 3
302 });
303 substitute(&mut cfg, &chunks[1]).unwrap();
304 assert_eq!(cfg["url"], "https://api/x?from=10&to=19");
305 assert_eq!(cfg["nested"]["list"][0], "chunk-1");
306 assert_eq!(cfg["nested"]["list"][1], 7, "non-strings untouched");
307 assert_eq!(cfg["count"], 3);
308 }
309
310 #[test]
311 fn substitutes_the_same_token_more_than_once() {
312 let chunks = plan(&int_spec(false)).unwrap();
313 let mut cfg = json!({ "q": "id >= ${partition.start} AND ${partition.start} > 0" });
314 substitute(&mut cfg, &chunks[0]).unwrap();
315 assert_eq!(cfg["q"], "id >= 0 AND 0 > 0");
316 }
317
318 #[test]
319 fn an_unknown_token_errors_and_lists_what_is_available() {
320 let chunks = plan(&int_spec(false)).unwrap();
321 let mut cfg = json!({ "url": "x?a=${partition.strat}" });
322 let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
323 assert!(err.contains("strat"), "{err}");
324 assert!(err.contains("start"), "should list the real tokens: {err}");
325 }
326
327 #[test]
328 fn an_offset_config_cannot_reference_id_range_tokens() {
329 let chunks = plan(&PartitionSpec::Offset {
332 total: CountBound::Literal(10),
333 chunk_size: 5,
334 })
335 .unwrap();
336 let mut cfg = json!({ "url": "x?from=${partition.start}" });
337 let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
338 assert!(err.contains("start"), "{err}");
339 assert!(err.contains("offset"), "lists the offset tokens: {err}");
340 }
341
342 #[test]
343 fn an_unterminated_token_is_a_typed_error() {
344 let chunks = plan(&int_spec(false)).unwrap();
345 let mut cfg = json!({ "url": "x?a=${partition.start" });
346 let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
347 assert!(err.contains("unterminated"), "{err}");
348 }
349
350 #[test]
351 fn detects_whether_a_config_references_the_tokens() {
352 assert!(references_partition(r#"{"url":"x?a=${partition.start}"}"#));
353 assert!(!references_partition(r#"{"url":"x?a=${now.date}"}"#));
354 }
355
356 #[test]
357 fn rendered_values_are_numeric_or_rfc3339_never_passthrough() {
358 let chunks = plan(&int_spec(false)).unwrap();
360 for c in &chunks {
361 for k in ["start", "end", "index"] {
362 assert!(
363 c.tokens[k].parse::<i64>().is_ok(),
364 "{k} must render as an integer, got {:?}",
365 c.tokens[k]
366 );
367 }
368 }
369 let ts = plan(&PartitionSpec::Timestamp {
370 from: "2026-06-01".into(),
371 to: "2026-06-02".into(),
372 chunk_size: "1d".into(),
373 timezone: None,
374 })
375 .unwrap();
376 assert!(chrono::DateTime::parse_from_rfc3339(&ts[0].tokens["start"]).is_ok());
377 }
378}