use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShardSpec {
pub id: String,
pub descriptor: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_estimate: Option<u64>,
}
impl ShardSpec {
pub fn whole() -> Self {
Self {
id: "0".to_string(),
descriptor: serde_json::Value::Null,
size_estimate: None,
}
}
pub fn new(id: impl Into<String>, descriptor: serde_json::Value) -> Self {
Self {
id: id.into(),
descriptor,
size_estimate: None,
}
}
pub fn with_size(mut self, size: u64) -> Self {
self.size_estimate = Some(size);
self
}
pub fn is_whole(&self) -> bool {
self.id == "0" && self.descriptor.is_null()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn whole_is_the_no_op_shard() {
let w = ShardSpec::whole();
assert_eq!(w.id, "0");
assert!(w.descriptor.is_null());
assert_eq!(w.size_estimate, None);
assert!(w.is_whole());
}
#[test]
fn new_and_with_size() {
let s = ShardSpec::new("3", json!({ "partition": 3 })).with_size(42);
assert_eq!(s.id, "3");
assert_eq!(s.descriptor, json!({ "partition": 3 }));
assert_eq!(s.size_estimate, Some(42));
assert!(!s.is_whole(), "a real shard is not the whole-dataset shard");
}
#[test]
fn round_trips_through_json_with_size_omitted_when_none() {
let s = ShardSpec::new("a", json!({ "prefix": "dt=2026-06-11/" }));
let text = serde_json::to_string(&s).unwrap();
assert!(
!text.contains("size_estimate"),
"None size is skipped: {text}"
);
let back: ShardSpec = serde_json::from_str(&text).unwrap();
assert_eq!(back, s);
}
#[test]
fn round_trips_with_size_present() {
let s = ShardSpec::new("b", json!({ "pk_range": [0, 1000] })).with_size(1000);
let back: ShardSpec = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
assert_eq!(back.size_estimate, Some(1000));
}
#[test]
fn id_zero_with_descriptor_is_not_whole() {
let s = ShardSpec::new("0", json!({ "partition": 0 }));
assert!(!s.is_whole());
}
}