use std::fmt;
use chrono::{DateTime, Datelike, Timelike};
use serde::{Deserialize, Serialize};
use crate::graph::Partitioning;
pub const MAX_PARTITION_LEN: usize = 16;
pub const UNPARTITIONED: &str = "none";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Partition(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("`{0}` is not a partition key: `[A-Za-z0-9_]` of one to {MAX_PARTITION_LEN} bytes")]
pub struct InvalidPartition(pub String);
impl Partition {
pub fn new(text: impl Into<String>) -> Result<Self, InvalidPartition> {
let text = text.into();
let valid = !text.is_empty()
&& text.len() <= MAX_PARTITION_LEN
&& text.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
if valid {
Ok(Partition(text))
} else {
Err(InvalidPartition(text))
}
}
pub fn of_time(partitioning: Partitioning, ms: u64) -> Option<Self> {
let time = DateTime::from_timestamp_millis(i64::try_from(ms).ok()?)?;
let (year, month, day) = (time.year(), time.month(), time.day());
if year > 9999 {
return None;
}
let key = match partitioning {
Partitioning::Daily => format!("{year:04}{month:02}{day:02}"),
Partitioning::Hourly => format!("{year:04}{month:02}{day:02}T{:02}", time.hour()),
Partitioning::Unpartitioned => return Some(Partition::none()),
};
Some(Partition(key))
}
pub fn none() -> Self {
Partition(UNPARTITIONED.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Partition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for Partition {
type Error = InvalidPartition;
fn try_from(text: String) -> Result<Self, InvalidPartition> {
Partition::new(text)
}
}
impl From<Partition> for String {
fn from(partition: Partition) -> String {
partition.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_the_documented_forms_and_rejects_the_rest() {
for text in ["20260915", "20260915T02", "none", "a", "0123456789ABCDEF"] {
assert_eq!(Partition::new(text).unwrap().as_str(), text);
}
for text in ["", "2026-09-15", "0123456789ABCDEFG", "a b", "é"] {
assert_eq!(
Partition::new(text),
Err(InvalidPartition(text.to_string()))
);
}
assert_eq!(Partition::none().as_str(), UNPARTITIONED);
}
#[test]
fn the_partition_of_a_time_has_the_format_of_the_partitioning() {
let end_of_february = 1_772_323_199_999;
let of_time = |partitioning, ms| Partition::of_time(partitioning, ms).unwrap();
assert_eq!(
of_time(Partitioning::Daily, end_of_february).as_str(),
"20260228"
);
assert_eq!(
of_time(Partitioning::Daily, end_of_february + 1).as_str(),
"20260301"
);
assert_eq!(
of_time(Partitioning::Hourly, end_of_february).as_str(),
"20260228T23"
);
assert_eq!(
of_time(Partitioning::Hourly, end_of_february + 1).as_str(),
"20260301T00"
);
assert_eq!(of_time(Partitioning::Unpartitioned, 0), Partition::none());
assert_eq!(Partition::of_time(Partitioning::Daily, u64::MAX), None);
assert_eq!(
Partition::of_time(Partitioning::Daily, 253_402_300_800_000),
None
);
assert_eq!(
of_time(Partitioning::Daily, 253_402_300_799_999).as_str(),
"99991231"
);
}
#[test]
fn serde_form_is_the_key_text_and_is_validated() {
let partition: Partition = serde_json::from_str("\"20260915\"").unwrap();
assert_eq!(serde_json::to_string(&partition).unwrap(), "\"20260915\"");
assert!(serde_json::from_str::<Partition>("\"2026-09-15\"").is_err());
}
}