use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupRole {
Owner,
Admin,
Moderator,
Member,
Guest,
}
impl GroupRole {
pub const OWNER_ASSIGNMENT_ERROR: &'static str =
"'owner' is a legacy role and cannot be assigned; valid roles: 'admin', 'member'";
fn rank(self) -> u8 {
match self {
Self::Owner => 4,
Self::Admin => 3,
Self::Moderator => 2,
Self::Member => 1,
Self::Guest => 0,
}
}
#[must_use]
pub fn as_u8(self) -> u8 {
match self {
Self::Owner => 0,
Self::Admin => 1,
Self::Moderator => 2,
Self::Member => 3,
Self::Guest => 4,
}
}
#[must_use]
pub fn at_least(self, minimum: Self) -> bool {
self.rank() >= minimum.rank()
}
#[must_use]
pub fn outranks(self, other: Self) -> bool {
self.rank() > other.rank()
}
#[must_use]
pub fn rank_below(self, other: Self) -> bool {
self.rank() < other.rank()
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name.to_lowercase().as_str() {
"owner" => Some(Self::Owner),
"admin" => Some(Self::Admin),
"moderator" => Some(Self::Moderator),
"member" => Some(Self::Member),
"guest" => Some(Self::Guest),
_ => None,
}
}
pub fn assignable_from_name(name: &str) -> Result<Self, String> {
match name {
"admin" => Ok(Self::Admin),
"member" => Ok(Self::Member),
"owner" => Err(Self::OWNER_ASSIGNMENT_ERROR.to_string()),
other
if other.eq_ignore_ascii_case("admin") || other.eq_ignore_ascii_case("member") =>
{
Err(format!(
"role names are case-sensitive; use '{}'",
other.to_ascii_lowercase()
))
}
other => Err(format!(
"role '{other}' is reserved and cannot be assigned; valid roles: 'admin', 'member'"
)),
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupMemberState {
#[default]
Active,
Pending,
Removed,
Banned,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroupMember {
pub agent_id: String,
#[serde(default)]
pub user_id: Option<String>,
pub role: GroupRole,
pub state: GroupMemberState,
#[serde(default)]
pub display_name: Option<String>,
pub joined_at: u64,
pub updated_at: u64,
#[serde(default)]
pub added_by: Option<String>,
#[serde(default)]
pub removed_by: Option<String>,
#[serde(default)]
pub kem_public_key_b64: Option<String>,
#[serde(default)]
pub treekem_key_package_b64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub treekem_key_package_hash: Option<String>,
}
impl GroupMember {
#[must_use]
pub fn new_admin(agent_id_hex: String, display_name: Option<String>, now_ms: u64) -> Self {
Self {
agent_id: agent_id_hex,
user_id: None,
role: GroupRole::Admin,
state: GroupMemberState::Active,
display_name,
joined_at: now_ms,
updated_at: now_ms,
added_by: None,
removed_by: None,
kem_public_key_b64: None,
treekem_key_package_b64: None,
treekem_key_package_hash: None,
}
}
#[must_use]
pub fn new_owner(agent_id_hex: String, display_name: Option<String>, now_ms: u64) -> Self {
Self {
agent_id: agent_id_hex,
user_id: None,
role: GroupRole::Owner,
state: GroupMemberState::Active,
display_name,
joined_at: now_ms,
updated_at: now_ms,
added_by: None,
removed_by: None,
kem_public_key_b64: None,
treekem_key_package_b64: None,
treekem_key_package_hash: None,
}
}
#[must_use]
pub fn new_member(
agent_id_hex: String,
display_name: Option<String>,
added_by: Option<String>,
now_ms: u64,
) -> Self {
Self {
agent_id: agent_id_hex,
user_id: None,
role: GroupRole::Member,
state: GroupMemberState::Active,
display_name,
joined_at: now_ms,
updated_at: now_ms,
added_by,
removed_by: None,
kem_public_key_b64: None,
treekem_key_package_b64: None,
treekem_key_package_hash: None,
}
}
#[must_use]
pub fn is_active(&self) -> bool {
self.state == GroupMemberState::Active
}
#[must_use]
pub fn is_banned(&self) -> bool {
self.state == GroupMemberState::Banned
}
#[must_use]
pub fn is_removed(&self) -> bool {
self.state == GroupMemberState::Removed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_rank_ordering() {
assert!(GroupRole::Owner.outranks(GroupRole::Admin));
assert!(GroupRole::Admin.outranks(GroupRole::Moderator));
assert!(GroupRole::Moderator.outranks(GroupRole::Member));
assert!(GroupRole::Member.outranks(GroupRole::Guest));
}
#[test]
fn role_at_least() {
assert!(GroupRole::Owner.at_least(GroupRole::Admin));
assert!(GroupRole::Admin.at_least(GroupRole::Admin));
assert!(!GroupRole::Member.at_least(GroupRole::Admin));
}
#[test]
fn role_from_name() {
assert_eq!(GroupRole::from_name("admin"), Some(GroupRole::Admin));
assert_eq!(GroupRole::from_name("OWNER"), Some(GroupRole::Owner));
assert_eq!(
GroupRole::from_name("moderator"),
Some(GroupRole::Moderator)
);
assert_eq!(GroupRole::from_name("guest"), Some(GroupRole::Guest));
assert_eq!(GroupRole::from_name("bogus"), None);
}
#[test]
fn role_assignment_accepts_only_admin_and_member_with_exact_errors() {
assert_eq!(
GroupRole::assignable_from_name("admin"),
Ok(GroupRole::Admin)
);
assert_eq!(
GroupRole::assignable_from_name("member"),
Ok(GroupRole::Member)
);
assert_eq!(
GroupRole::assignable_from_name("owner").unwrap_err(),
"'owner' is a legacy role and cannot be assigned; valid roles: 'admin', 'member'"
);
assert_eq!(
GroupRole::assignable_from_name("moderator").unwrap_err(),
"role 'moderator' is reserved and cannot be assigned; valid roles: 'admin', 'member'"
);
assert_eq!(
GroupRole::assignable_from_name("guest").unwrap_err(),
"role 'guest' is reserved and cannot be assigned; valid roles: 'admin', 'member'"
);
}
#[test]
fn role_assignment_is_exact_lowercase_vocabulary() {
assert_eq!(
GroupRole::assignable_from_name("ADMIN").unwrap_err(),
"role names are case-sensitive; use 'admin'"
);
assert_eq!(
GroupRole::assignable_from_name("Member").unwrap_err(),
"role names are case-sensitive; use 'member'"
);
}
#[test]
fn new_owner_is_active_owner() {
let m = GroupMember::new_owner("ff".repeat(32), None, 100);
assert_eq!(m.role, GroupRole::Owner);
assert!(m.is_active());
assert_eq!(m.joined_at, 100);
}
#[test]
fn new_admin_is_active_admin() {
let m = GroupMember::new_admin("ff".repeat(32), None, 100);
assert_eq!(m.role, GroupRole::Admin);
assert!(m.is_active());
assert_eq!(m.joined_at, 100);
}
#[test]
fn new_member_is_plain_member() {
let m = GroupMember::new_member("aa".repeat(32), Some("Alice".into()), None, 200);
assert_eq!(m.role, GroupRole::Member);
assert!(m.is_active());
assert_eq!(m.display_name.as_deref(), Some("Alice"));
}
#[test]
fn banned_flag() {
let mut m = GroupMember::new_member("aa".repeat(32), None, None, 0);
m.state = GroupMemberState::Banned;
assert!(m.is_banned());
assert!(!m.is_active());
}
}