use std::fmt;
use anyhow::{Result, bail};
const MODULE_SEPARATOR: char = '@';
const MAX_MODULE_NAME_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Partition {
Sync,
Schema,
Lock,
Meta,
}
impl Partition {
pub fn as_str(self) -> &'static str {
match self {
Self::Sync => "sync",
Self::Schema => "schema",
Self::Lock => "lock",
Self::Meta => "meta",
}
}
fn is_module_scoped(self) -> bool {
!matches!(self, Self::Meta)
}
}
impl fmt::Display for Partition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Module(String);
impl Module {
pub const DEFAULT_NAME: &'static str = "default";
pub fn new(name: impl Into<String>) -> Result<Self> {
let name = name.into();
if name.is_empty() {
bail!("schema module name is empty");
}
if name.len() > MAX_MODULE_NAME_LEN {
bail!(
"schema module name {name:?} is {} characters; the maximum is {MAX_MODULE_NAME_LEN}",
name.len()
);
}
if !name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) {
bail!(
"schema module name {name:?} must start with a lowercase letter or digit \
(allowed: a-z, 0-9, '_', '-')"
);
}
if let Some(bad) = name
.chars()
.find(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '-'))
{
bail!(
"schema module name {name:?} contains {bad:?}; allowed characters are \
a-z, 0-9, '_' and '-'"
);
}
if name == Partition::Meta.as_str() {
bail!("schema module name {name:?} is reserved");
}
Ok(Self(name))
}
pub fn default_module() -> Self {
Self(Self::DEFAULT_NAME.to_string())
}
pub fn is_default(&self) -> bool {
self.0 == Self::DEFAULT_NAME
}
pub fn name(&self) -> &str {
&self.0
}
pub fn partition(&self, partition: Partition) -> String {
if self.is_default() || !partition.is_module_scoped() {
partition.as_str().to_string()
} else {
format!("{}{MODULE_SEPARATOR}{}", partition.as_str(), self.0)
}
}
}
impl Default for Module {
fn default() -> Self {
Self::default_module()
}
}
impl fmt::Display for Module {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for Module {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
Self::new(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_module_uses_bare_partitions() {
let m = Module::default_module();
assert_eq!(m.partition(Partition::Sync), "sync");
assert_eq!(m.partition(Partition::Schema), "schema");
assert_eq!(m.partition(Partition::Lock), "lock");
assert_eq!(m.partition(Partition::Meta), "meta");
}
#[test]
fn named_module_qualifies_partitions() {
let m = Module::new("billing").unwrap();
assert_eq!(m.partition(Partition::Sync), "sync@billing");
assert_eq!(m.partition(Partition::Schema), "schema@billing");
assert_eq!(m.partition(Partition::Lock), "lock@billing");
}
#[test]
fn meta_is_never_module_qualified() {
let m = Module::new("billing").unwrap();
assert_eq!(m.partition(Partition::Meta), "meta");
}
#[test]
fn explicitly_naming_default_is_the_default_module() {
let m = Module::new("default").unwrap();
assert!(m.is_default());
assert_eq!(m.partition(Partition::Sync), "sync");
}
#[test]
fn distinct_modules_get_distinct_partitions() {
let a = Module::new("core").unwrap().partition(Partition::Schema);
let b = Module::new("billing").unwrap().partition(Partition::Schema);
let d = Module::default_module().partition(Partition::Schema);
assert_ne!(a, b);
assert_ne!(a, d);
assert_ne!(b, d);
}
#[test]
fn accepts_reasonable_names() {
for name in ["core", "billing", "a", "0", "with_underscore", "with-dash", "v2", "a1_b2-c3"]
{
assert!(Module::new(name).is_ok(), "should accept {name:?}");
}
}
#[test]
fn rejects_names_that_would_break_partitions_or_paths() {
for (name, why) in [
("", "empty"),
("Billing", "uppercase"),
("_leading", "leading underscore"),
("-leading", "leading dash"),
("has space", "space"),
("has@at", "separator character"),
("has/slash", "path separator"),
("has:colon", "entity-key separator"),
("has.dot", "dot"),
("meta", "reserved partition name"),
] {
assert!(Module::new(name).is_err(), "should reject {name:?} ({why})");
}
}
#[test]
fn rejects_over_long_names() {
assert!(Module::new("a".repeat(MAX_MODULE_NAME_LEN)).is_ok());
assert!(Module::new("a".repeat(MAX_MODULE_NAME_LEN + 1)).is_err());
}
#[test]
fn error_messages_name_the_offending_input() {
let err = Module::new("Bad Name").unwrap_err().to_string();
assert!(err.contains("Bad Name"), "error should quote the input: {err}");
}
#[test]
fn parses_from_str() {
assert_eq!("billing".parse::<Module>().unwrap().name(), "billing");
assert!("Bad".parse::<Module>().is_err());
}
}