use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use serde::{Deserialize, Serialize};
const PARTITION_VALUE_BAD: &AsciiSet = &CONTROLS.add(b'=').add(b'/').add(b' ').add(b'\\');
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PartitionScheme {
keys: Vec<String>,
}
impl PartitionScheme {
pub fn parse(template: &str) -> Result<Self, InvalidScheme> {
if template.is_empty() {
return Err(InvalidScheme::Empty);
}
let mut keys = Vec::new();
for level in template.split('/') {
let (k, placeholder) =
level
.split_once('=')
.ok_or_else(|| InvalidScheme::MissingEquals {
level: level.to_string(),
})?;
if !placeholder.starts_with('{') || !placeholder.ends_with('}') {
return Err(InvalidScheme::BadPlaceholder {
level: level.to_string(),
});
}
let inner = &placeholder[1..placeholder.len() - 1];
if k != inner {
return Err(InvalidScheme::PlaceholderMismatch {
key: k.to_string(),
placeholder: inner.to_string(),
});
}
if !is_valid_key(k) {
return Err(InvalidScheme::BadKey { key: k.to_string() });
}
keys.push(k.to_string());
}
Ok(PartitionScheme { keys })
}
#[must_use]
pub fn depth(&self) -> usize {
self.keys.len()
}
#[must_use]
pub fn keys(&self) -> &[String] {
&self.keys
}
}
impl fmt::Display for PartitionScheme {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, k) in self.keys.iter().enumerate() {
if i > 0 {
f.write_str("/")?;
}
write!(f, "{k}={{{k}}}")?;
}
Ok(())
}
}
fn is_valid_key(k: &str) -> bool {
!k.is_empty()
&& k.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvalidScheme {
#[error("partition scheme is empty")]
Empty,
#[error("partition scheme level missing '=': {level:?}")]
MissingEquals {
level: String,
},
#[error("partition scheme placeholder must be {{...}}: {level:?}")]
BadPlaceholder {
level: String,
},
#[error("partition scheme placeholder {placeholder:?} does not match key {key:?}")]
PlaceholderMismatch {
key: String,
placeholder: String,
},
#[error("partition scheme key {key:?} contains disallowed character")]
BadKey {
key: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Partitions {
values: BTreeMap<String, String>,
}
impl Partitions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.values.insert(key.into(), value.into());
self
}
#[must_use]
pub fn from_path(p: &PartitionPath) -> Self {
let mut out = Partitions::new();
for (k, v) in p.kv_pairs() {
out = out.with(k.to_string(), v.into_owned());
}
out
}
pub fn resolve(self, scheme: &PartitionScheme) -> Result<PartitionPath, PartitionResolveError> {
for k in scheme.keys() {
if !self.values.contains_key(k) {
return Err(PartitionResolveError::MissingKey { key: k.clone() });
}
}
for k in self.values.keys() {
if !scheme.keys().iter().any(|s| s == k) {
return Err(PartitionResolveError::UnknownKey { key: k.clone() });
}
}
let mut segments = Vec::with_capacity(scheme.depth());
for k in scheme.keys() {
let v = self
.values
.get(k)
.ok_or_else(|| PartitionResolveError::MissingKey { key: k.clone() })?;
if v.is_empty() {
return Err(PartitionResolveError::EmptyValue { key: k.clone() });
}
let encoded = utf8_percent_encode(v, PARTITION_VALUE_BAD).to_string();
segments.push(format!("{k}={encoded}"));
}
Ok(PartitionPath(segments.join("/")))
}
}
impl<S1, S2> From<&[(S1, S2)]> for Partitions
where
S1: AsRef<str>,
S2: AsRef<str>,
{
fn from(pairs: &[(S1, S2)]) -> Self {
let mut p = Partitions::new();
for (k, v) in pairs {
p.values
.insert(k.as_ref().to_string(), v.as_ref().to_string());
}
p
}
}
impl<S1, S2, const N: usize> From<[(S1, S2); N]> for Partitions
where
S1: AsRef<str>,
S2: AsRef<str>,
{
fn from(arr: [(S1, S2); N]) -> Self {
let mut p = Partitions::new();
for (k, v) in arr {
p.values
.insert(k.as_ref().to_string(), v.as_ref().to_string());
}
p
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum PartitionResolveError {
#[error("missing partition key {key:?}")]
MissingKey {
key: String,
},
#[error("unknown partition key {key:?}")]
UnknownKey {
key: String,
},
#[error("partition value for {key:?} is empty")]
EmptyValue {
key: String,
},
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PartitionPath(String);
impl PartitionPath {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn depth(&self) -> usize {
self.0.split('/').count()
}
pub fn kv_pairs(&self) -> impl Iterator<Item = (&str, std::borrow::Cow<'_, str>)> + '_ {
self.0.split('/').filter(|s| !s.is_empty()).map(|seg| {
let (k, v) = seg.split_once('=').unwrap_or((seg, ""));
let decoded = percent_encoding::percent_decode_str(v).decode_utf8_lossy();
(k, decoded)
})
}
pub fn ancestors(&self) -> impl Iterator<Item = PartitionPath> + '_ {
let mut s = self.0.as_str();
std::iter::from_fn(move || {
let idx = s.rfind('/')?;
s = &s[..idx];
Some(PartitionPath(s.to_string()))
})
}
pub fn from_string(s: &str) -> Result<Self, PartitionResolveError> {
if s.is_empty() {
return Err(PartitionResolveError::MissingKey {
key: "<empty>".into(),
});
}
for seg in s.split('/') {
if seg.is_empty() || !seg.contains('=') {
return Err(PartitionResolveError::MissingKey {
key: seg.to_string(),
});
}
}
Ok(Self(s.to_string()))
}
}
impl fmt::Display for PartitionPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for PartitionPath {
type Err = PartitionResolveError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if s.is_empty() {
return Err(PartitionResolveError::EmptyValue {
key: String::from("<root>"),
});
}
Ok(PartitionPath(s.to_string()))
}
}
pub const TENANT_ROOT_SENTINEL: &str = "<root>";
#[must_use]
#[allow(clippy::expect_used)] pub fn tenant_root_path() -> PartitionPath {
TENANT_ROOT_SENTINEL
.parse()
.expect("<root> sentinel always parses")
}
#[cfg(test)]
mod tests {
use super::*;
fn scheme() -> PartitionScheme {
PartitionScheme::parse("user={user}/year={year}/month={month}/topic={topic}").unwrap()
}
#[test]
fn parses_template() {
let s = scheme();
assert_eq!(s.depth(), 4);
assert_eq!(s.keys(), &["user", "year", "month", "topic"]);
}
#[test]
fn rejects_bad_template() {
assert!(matches!(
PartitionScheme::parse(""),
Err(InvalidScheme::Empty)
));
assert!(matches!(
PartitionScheme::parse("user"),
Err(InvalidScheme::MissingEquals { .. })
));
assert!(matches!(
PartitionScheme::parse("user={uid}"),
Err(InvalidScheme::PlaceholderMismatch { .. })
));
assert!(matches!(
PartitionScheme::parse("9bad={9bad}"),
Err(InvalidScheme::BadKey { .. })
));
}
#[test]
fn resolves_partitions() {
let s = scheme();
let p = Partitions::from([
("user", "alex"),
("year", "2026"),
("month", "05"),
("topic", "meetings"),
])
.resolve(&s)
.unwrap();
assert_eq!(p.as_str(), "user=alex/year=2026/month=05/topic=meetings");
assert_eq!(p.depth(), 4);
}
#[test]
fn percent_encodes_slashes_in_values() {
let s = PartitionScheme::parse("topic={topic}").unwrap();
let p = Partitions::from([("topic", "a/b c")]).resolve(&s).unwrap();
assert_eq!(p.as_str(), "topic=a%2Fb%20c");
}
#[test]
fn resolve_rejects_missing_or_unknown() {
let s = scheme();
assert!(matches!(
Partitions::from([("user", "alex")])
.resolve(&s)
.unwrap_err(),
PartitionResolveError::MissingKey { .. }
));
assert!(matches!(
Partitions::from([
("user", "alex"),
("year", "2026"),
("month", "05"),
("topic", "meetings"),
("extra", "x"),
])
.resolve(&s)
.unwrap_err(),
PartitionResolveError::UnknownKey { .. }
));
}
#[test]
fn ancestors_iterates_deepest_first() {
let p = PartitionPath::from_str("user=alex/year=2026/month=05/topic=meetings").unwrap();
let got: Vec<String> = p.ancestors().map(|a| a.0).collect();
assert_eq!(
got,
vec![
"user=alex/year=2026/month=05".to_string(),
"user=alex/year=2026".to_string(),
"user=alex".to_string(),
]
);
}
#[test]
fn from_string_accepts_valid_path() {
let p = PartitionPath::from_string("user=alex/year=2026").unwrap();
assert_eq!(p.as_str(), "user=alex/year=2026");
}
#[test]
fn from_string_rejects_empty() {
assert!(PartitionPath::from_string("").is_err());
}
#[test]
fn from_string_rejects_missing_eq() {
assert!(PartitionPath::from_string("alex/2026").is_err());
}
#[test]
fn from_string_rejects_empty_segment() {
assert!(PartitionPath::from_string("user=alex//year=2026").is_err());
}
}