use super::spec::{CountBound, IntBound, PartitionSpec};
use crate::chunking::{self, Bounds};
use crate::error::{CliError, CliResult};
use serde_json::Value;
use std::collections::BTreeMap;
const PREFIX: &str = "${partition.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PartitionChunk {
pub id: String,
pub tokens: BTreeMap<String, String>,
pub open_ended: bool,
}
pub fn plan(spec: &PartitionSpec) -> CliResult<Vec<PartitionChunk>> {
spec.validate()?;
match spec {
PartitionSpec::Integer {
from,
to,
chunk_size,
bounds,
to_unbounded,
} => {
let to = match to {
IntBound::Literal(v) => *v,
IntBound::Discovered(_) => {
return Err(CliError::Internal(
"partition: an undiscovered bound reached the planner — `resolve_bounds` must run before `plan`"
.into(),
));
}
};
let chunks = chunking::plan_int_chunks(*from, to, *chunk_size, *bounds)?;
Ok(chunks
.into_iter()
.enumerate()
.map(|(i, c)| {
let open = to_unbounded.unwrap_or(false) && c.is_last;
let mut tokens = BTreeMap::new();
tokens.insert("start".into(), c.start.to_string());
tokens.insert("end".into(), c.end.to_string());
tokens.insert("index".into(), i.to_string());
tokens.insert("id".into(), c.id.clone());
PartitionChunk {
id: c.id,
tokens,
open_ended: open,
}
})
.collect())
}
PartitionSpec::Timestamp {
from,
to,
chunk_size,
timezone,
} => {
let tz: chrono_tz::Tz = timezone
.as_deref()
.unwrap_or("UTC")
.parse()
.map_err(|_| CliError::Config("invalid partition.timezone".into()))?;
let step = chunking::parse_window(chunk_size)?;
let from = chunking::parse_boundary(from, tz)?;
let to = chunking::parse_boundary(to, tz)?;
let chunks = chunking::plan_windows(from, to, Some(step), tz)?;
Ok(chunks
.into_iter()
.enumerate()
.map(|(i, c)| {
let mut tokens = BTreeMap::new();
tokens.insert("start".into(), c.start.to_rfc3339());
tokens.insert("end".into(), c.end.to_rfc3339());
tokens.insert("start_date".into(), c.start.format("%Y-%m-%d").to_string());
tokens.insert("end_date".into(), c.end.format("%Y-%m-%d").to_string());
tokens.insert("start_unix".into(), c.start.timestamp().to_string());
tokens.insert("end_unix".into(), c.end.timestamp().to_string());
tokens.insert("index".into(), i.to_string());
tokens.insert("id".into(), c.id.clone());
PartitionChunk {
id: c.id,
tokens,
open_ended: false,
}
})
.collect())
}
PartitionSpec::Offset { total, chunk_size } => {
let total = match total {
CountBound::Literal(v) => *v,
CountBound::Discovered(_) => {
return Err(CliError::Internal(
"partition: an undiscovered total reached the planner — `resolve_bounds` must run before `plan`"
.into(),
));
}
};
let chunks = chunking::plan_offset_chunks(total, *chunk_size)?;
Ok(chunks
.into_iter()
.enumerate()
.map(|(i, c)| {
let mut tokens = BTreeMap::new();
tokens.insert("offset".into(), c.offset.to_string());
tokens.insert("limit".into(), c.limit.to_string());
tokens.insert("index".into(), i.to_string());
tokens.insert("id".into(), c.id.clone());
PartitionChunk {
id: c.id,
tokens,
open_ended: false,
}
})
.collect())
}
}
}
pub fn substitute(value: &mut Value, chunk: &PartitionChunk) -> CliResult<()> {
match value {
Value::String(s) => {
*s = substitute_in_str(s, chunk)?;
Ok(())
}
Value::Array(a) => a.iter_mut().try_for_each(|v| substitute(v, chunk)),
Value::Object(m) => m.values_mut().try_for_each(|v| substitute(v, chunk)),
_ => Ok(()),
}
}
fn substitute_in_str(input: &str, chunk: &PartitionChunk) -> CliResult<String> {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(pos) = rest.find(PREFIX) {
out.push_str(&rest[..pos]);
let after = &rest[pos + PREFIX.len()..];
let close = after.find('}').ok_or_else(|| {
CliError::Config(format!("unterminated ${{partition.…}} token in '{input}'"))
})?;
let token = &after[..close];
let rendered = chunk.tokens.get(token).ok_or_else(|| {
CliError::Config(format!(
"unknown token ${{partition.{token}}} — this partition defines: {}",
chunk.tokens.keys().cloned().collect::<Vec<_>>().join(", ")
))
})?;
out.push_str(rendered);
rest = &after[close + 1..];
}
out.push_str(rest);
Ok(out)
}
pub fn references_partition(serialized: &str) -> bool {
serialized.contains(PREFIX)
}
pub fn has_open_ended(chunks: &[PartitionChunk]) -> bool {
chunks.iter().any(|c| c.open_ended)
}
pub fn bounds_label(b: Bounds) -> &'static str {
match b {
Bounds::Inclusive => "inclusive",
Bounds::HalfOpen => "half_open",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn int_spec(to_unbounded: bool) -> PartitionSpec {
PartitionSpec::Integer {
from: 0,
to: IntBound::Literal(24),
chunk_size: 10,
bounds: Bounds::Inclusive,
to_unbounded: to_unbounded.then_some(true),
}
}
#[test]
fn integer_chunks_carry_start_end_index_id() {
let chunks = plan(&int_spec(false)).unwrap();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].tokens["start"], "0");
assert_eq!(chunks[0].tokens["end"], "9");
assert_eq!(chunks[0].tokens["index"], "0");
assert_eq!(
chunks[2].tokens["end"], "24",
"final chunk truncates at `to`"
);
assert!(chunks.iter().all(|c| !c.open_ended));
}
#[test]
fn only_the_final_chunk_is_open_ended_and_only_when_asked() {
let chunks = plan(&int_spec(true)).unwrap();
assert_eq!(chunks.iter().filter(|c| c.open_ended).count(), 1);
assert!(chunks.last().unwrap().open_ended);
assert!(has_open_ended(&chunks));
assert!(!has_open_ended(&plan(&int_spec(false)).unwrap()));
}
#[test]
fn offset_chunks_carry_offset_and_limit() {
let chunks = plan(&PartitionSpec::Offset {
total: CountBound::Literal(25),
chunk_size: 10,
})
.unwrap();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].tokens["offset"], "0");
assert_eq!(chunks[0].tokens["limit"], "10");
assert_eq!(
chunks[2].tokens["limit"], "5",
"final limit is the remainder"
);
assert!(
!chunks[0].tokens.contains_key("start"),
"no id-range tokens"
);
}
#[test]
fn timestamp_chunks_carry_the_backfill_token_set() {
let chunks = plan(&PartitionSpec::Timestamp {
from: "2026-06-01".into(),
to: "2026-06-04".into(),
chunk_size: "1d".into(),
timezone: None,
})
.unwrap();
assert_eq!(chunks.len(), 3);
for t in [
"start",
"end",
"start_date",
"end_date",
"start_unix",
"end_unix",
] {
assert!(chunks[0].tokens.contains_key(t), "missing {t}");
}
assert_eq!(chunks[0].tokens["start_date"], "2026-06-01");
assert_eq!(chunks[1].tokens["start_date"], "2026-06-02");
}
#[test]
fn substitutes_into_nested_string_leaves_only() {
let chunks = plan(&int_spec(false)).unwrap();
let mut cfg = json!({
"url": "https://api/x?from=${partition.start}&to=${partition.end}",
"nested": { "list": ["chunk-${partition.index}", 7, true] },
"count": 3
});
substitute(&mut cfg, &chunks[1]).unwrap();
assert_eq!(cfg["url"], "https://api/x?from=10&to=19");
assert_eq!(cfg["nested"]["list"][0], "chunk-1");
assert_eq!(cfg["nested"]["list"][1], 7, "non-strings untouched");
assert_eq!(cfg["count"], 3);
}
#[test]
fn substitutes_the_same_token_more_than_once() {
let chunks = plan(&int_spec(false)).unwrap();
let mut cfg = json!({ "q": "id >= ${partition.start} AND ${partition.start} > 0" });
substitute(&mut cfg, &chunks[0]).unwrap();
assert_eq!(cfg["q"], "id >= 0 AND 0 > 0");
}
#[test]
fn an_unknown_token_errors_and_lists_what_is_available() {
let chunks = plan(&int_spec(false)).unwrap();
let mut cfg = json!({ "url": "x?a=${partition.strat}" });
let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
assert!(err.contains("strat"), "{err}");
assert!(err.contains("start"), "should list the real tokens: {err}");
}
#[test]
fn an_offset_config_cannot_reference_id_range_tokens() {
let chunks = plan(&PartitionSpec::Offset {
total: CountBound::Literal(10),
chunk_size: 5,
})
.unwrap();
let mut cfg = json!({ "url": "x?from=${partition.start}" });
let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
assert!(err.contains("start"), "{err}");
assert!(err.contains("offset"), "lists the offset tokens: {err}");
}
#[test]
fn an_unterminated_token_is_a_typed_error() {
let chunks = plan(&int_spec(false)).unwrap();
let mut cfg = json!({ "url": "x?a=${partition.start" });
let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
assert!(err.contains("unterminated"), "{err}");
}
#[test]
fn detects_whether_a_config_references_the_tokens() {
assert!(references_partition(r#"{"url":"x?a=${partition.start}"}"#));
assert!(!references_partition(r#"{"url":"x?a=${now.date}"}"#));
}
#[test]
fn rendered_values_are_numeric_or_rfc3339_never_passthrough() {
let chunks = plan(&int_spec(false)).unwrap();
for c in &chunks {
for k in ["start", "end", "index"] {
assert!(
c.tokens[k].parse::<i64>().is_ok(),
"{k} must render as an integer, got {:?}",
c.tokens[k]
);
}
}
let ts = plan(&PartitionSpec::Timestamp {
from: "2026-06-01".into(),
to: "2026-06-02".into(),
chunk_size: "1d".into(),
timezone: None,
})
.unwrap();
assert!(chrono::DateTime::parse_from_rfc3339(&ts[0].tokens["start"]).is_ok());
}
}