use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SovereignRole {
#[default]
Voter,
NonVoter,
}
impl SovereignRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Voter => "voter",
Self::NonVoter => "non-voter",
}
}
pub fn is_voter(&self) -> bool {
matches!(self, Self::Voter)
}
}
impl std::fmt::Display for SovereignRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for SovereignRole {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"voter" => Ok(Self::Voter),
"non-voter" => Ok(Self::NonVoter),
other => Err(format!(
"unknown sovereign role {other:?} — expected \"voter\" or \"non-voter\""
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Membership<'a> {
pub group: Option<&'a str>,
pub role: SovereignRole,
}
impl<'a> Membership<'a> {
pub fn new(group: &'a str, role: SovereignRole) -> Self {
Self {
group: Some(group),
role,
}
}
pub fn standalone() -> Self {
Self {
group: None,
role: SovereignRole::default(),
}
}
}
pub fn join_permitted(joiner: Membership<'_>, target: Membership<'_>) -> bool {
matches!((joiner.group, target.group), (Some(a), Some(b)) if a == b)
&& joiner.role.is_voter()
&& target.role.is_voter()
}
#[cfg(test)]
mod tests {
use super::*;
fn voter(group: &str) -> Membership<'_> {
Membership::new(group, SovereignRole::Voter)
}
fn non_voter(group: &str) -> Membership<'_> {
Membership::new(group, SovereignRole::NonVoter)
}
#[test]
fn one_group_joins_itself() {
assert!(join_permitted(voter("dev"), voter("dev")));
assert!(join_permitted(voter("prod"), voter("prod")));
}
#[test]
fn two_groups_do_not_merge() {
assert!(!join_permitted(voter("dev"), voter("prod")));
}
#[test]
fn undeclared_never_joins_anything() {
assert!(!join_permitted(Membership::standalone(), voter("prod")));
assert!(!join_permitted(voter("dev"), Membership::standalone()));
assert!(!join_permitted(
Membership::standalone(),
Membership::standalone()
));
}
#[test]
fn labels_are_compared_exactly() {
assert!(!join_permitted(voter("Dev"), voter("dev")));
assert!(!join_permitted(voter("dev "), voter("dev")));
}
#[test]
fn a_non_voting_member_does_not_join_its_own_group() {
assert!(!join_permitted(non_voter("prod"), voter("prod")));
}
#[test]
fn a_non_voting_target_has_no_quorum_to_join() {
assert!(!join_permitted(voter("prod"), non_voter("prod")));
assert!(!join_permitted(non_voter("prod"), non_voter("prod")));
}
#[test]
fn the_default_role_is_the_pre_r605_f12_meaning() {
assert_eq!(SovereignRole::default(), SovereignRole::Voter);
assert!(join_permitted(
Membership::new("prod", SovereignRole::default()),
Membership::new("prod", SovereignRole::default())
));
}
#[test]
fn roles_round_trip_through_their_one_spelling() {
for role in [SovereignRole::Voter, SovereignRole::NonVoter] {
assert_eq!(role.as_str().parse::<SovereignRole>().unwrap(), role);
assert_eq!(
serde_json::to_string(&role).unwrap(),
format!("\"{}\"", role.as_str())
);
assert_eq!(
serde_json::from_str::<SovereignRole>(&format!("\"{}\"", role.as_str())).unwrap(),
role
);
}
}
#[test]
fn a_near_miss_role_spelling_is_an_error_naming_both_legal_values() {
for wrong in ["nonvoter", "no-voter", "Voter", "learner", ""] {
let err = wrong.parse::<SovereignRole>().unwrap_err();
assert!(err.contains("voter") && err.contains("non-voter"), "{err}");
}
}
}