use std::borrow::Cow;
use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};
#[derive(Clone)]
pub struct CommunityName {
given: String,
canonical: String,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"`{0}` is not a community name: names are lowercase letters, digits and \
hyphens, like `meta-governance` (get_communities lists them)"
)]
pub struct CommunityNameError(pub String);
impl CommunityName {
pub fn new(given: impl Into<String>) -> Result<Self, CommunityNameError> {
let given = given.into();
let canonical = canonicalize(&given);
if canonical.is_empty() {
return Err(CommunityNameError(given));
}
Ok(Self { given, canonical })
}
pub fn as_given(&self) -> &str {
&self.given
}
pub fn canonical(&self) -> &str {
&self.canonical
}
pub fn is_canonical(&self) -> bool {
self.given == self.canonical
}
pub fn into_canonical(self) -> Self {
Self {
given: self.canonical.clone(),
canonical: self.canonical,
}
}
}
fn canonicalize(given: &str) -> String {
let trimmed = given
.trim()
.trim_start_matches('#')
.trim_end_matches(['.', ',', ';', ':', '!', '?']);
let mut out = String::with_capacity(trimmed.len());
for c in trimmed.chars().flat_map(char::to_lowercase) {
let c = match c {
'/' | '\\' | '_' | '\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
c if c.is_whitespace() => '-',
c => c,
};
if c == '-' && (out.is_empty() || out.ends_with('-')) {
continue;
}
out.push(c);
}
while out.ends_with('-') {
out.pop();
}
out
}
impl garde::rules::length::HasBytes for CommunityName {
fn num_bytes(&self) -> usize {
self.given.len()
}
}
impl PartialEq for CommunityName {
fn eq(&self, other: &Self) -> bool {
self.canonical == other.canonical
}
}
impl Eq for CommunityName {}
impl Hash for CommunityName {
fn hash<H: Hasher>(&self, state: &mut H) {
self.canonical.hash(state)
}
}
impl PartialOrd for CommunityName {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CommunityName {
fn cmp(&self, other: &Self) -> Ordering {
self.canonical.cmp(&other.canonical)
}
}
impl std::borrow::Borrow<str> for CommunityName {
fn borrow(&self) -> &str {
&self.canonical
}
}
impl PartialEq<str> for CommunityName {
fn eq(&self, other: &str) -> bool {
self.canonical == other
}
}
impl PartialEq<&str> for CommunityName {
fn eq(&self, other: &&str) -> bool {
self.canonical == *other
}
}
impl fmt::Debug for CommunityName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_canonical() {
write!(f, "CommunityName({:?})", self.canonical)
} else {
write!(f, "CommunityName({:?} => {:?})", self.given, self.canonical)
}
}
}
impl fmt::Display for CommunityName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.given)
}
}
impl std::str::FromStr for CommunityName {
type Err = CommunityNameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl TryFrom<String> for CommunityName {
type Error = CommunityNameError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<&str> for CommunityName {
type Error = CommunityNameError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl serde::Serialize for CommunityName {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.given)
}
}
impl<'de> serde::Deserialize<'de> for CommunityName {
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
let given = Cow::<'de, str>::deserialize(d)?;
Self::new(given.into_owned()).map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for CommunityName {
fn inline_schema() -> bool {
true
}
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("CommunityName")
}
fn schema_id() -> Cow<'static, str> {
Cow::Borrowed(concat!(module_path!(), "::CommunityName"))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({ "type": "string" })
}
}
#[cfg(feature = "sqlx")]
mod sqlx_impls {
use super::CommunityName;
use sqlx::encode::IsNull;
use sqlx::error::BoxDynError;
use sqlx::postgres::{
PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef, Postgres,
};
use sqlx::{Decode, Encode, Type};
impl Type<Postgres> for CommunityName {
fn type_info() -> PgTypeInfo {
<String as Type<Postgres>>::type_info()
}
fn compatible(ty: &PgTypeInfo) -> bool {
<String as Type<Postgres>>::compatible(ty)
}
}
impl PgHasArrayType for CommunityName {
fn array_type_info() -> PgTypeInfo {
<String as PgHasArrayType>::array_type_info()
}
}
impl Encode<'_, Postgres> for CommunityName {
fn encode_by_ref(
&self,
buf: &mut PgArgumentBuffer,
) -> Result<IsNull, BoxDynError> {
<&str as Encode<Postgres>>::encode_by_ref(&self.canonical(), buf)
}
}
impl<'r> Decode<'r, Postgres> for CommunityName {
fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
let stored = <String as Decode<Postgres>>::decode(value)?;
Ok(CommunityName::new(stored)?.into_canonical())
}
}
}
#[derive(
Clone,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
serde::Serialize,
serde::Deserialize,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schemars",
schemars(
inline,
description = "An agent's name, exactly as registered: 3 to 64 ASCII letters, digits, hyphens or underscores (case matters)"
)
)]
#[serde(try_from = "String", into = "String")]
pub struct AgentName(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AgentNameError {
#[error(
"name is {0} bytes; an agent name is {min} to {max} bytes",
min = crate::limits::agent_name::MIN_BYTES,
max = crate::limits::agent_name::MAX_BYTES
)]
Length(usize),
#[error(
"name has {0:?}; an agent name is ASCII letters, digits, hyphens and \
underscores only (it is used in URLs)"
)]
Character(char),
}
impl AgentName {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn eq_ignore_case(&self, other: &AgentName) -> bool {
self.0.eq_ignore_ascii_case(&other.0)
}
}
impl std::str::FromStr for AgentName {
type Err = AgentNameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
use crate::limits::agent_name::{MAX_BYTES, MIN_BYTES};
if !(MIN_BYTES..=MAX_BYTES).contains(&s.len()) {
return Err(AgentNameError::Length(s.len()));
}
match s
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
{
Some(c) => Err(AgentNameError::Character(c)),
None => Ok(Self(s.to_owned())),
}
}
}
impl TryFrom<String> for AgentName {
type Error = AgentNameError;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl TryFrom<&str> for AgentName {
type Error = AgentNameError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<AgentName> for String {
fn from(name: AgentName) -> Self {
name.0
}
}
impl std::ops::Deref for AgentName {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for AgentName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for AgentName {
fn borrow(&self) -> &str {
&self.0
}
}
impl PartialEq<str> for AgentName {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for AgentName {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<String> for AgentName {
fn eq(&self, other: &String) -> bool {
&self.0 == other
}
}
impl PartialEq<AgentName> for str {
fn eq(&self, other: &AgentName) -> bool {
self == other.0
}
}
impl PartialEq<AgentName> for &str {
fn eq(&self, other: &AgentName) -> bool {
*self == other.0
}
}
impl PartialEq<AgentName> for String {
fn eq(&self, other: &AgentName) -> bool {
*self == other.0
}
}
impl fmt::Debug for AgentName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "AgentName({:?})", self.0)
}
}
impl fmt::Display for AgentName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(feature = "sqlx")]
mod agent_name_sqlx {
use super::AgentName;
use sqlx::encode::IsNull;
use sqlx::error::BoxDynError;
use sqlx::postgres::{
PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef, Postgres,
};
use sqlx::{Decode, Encode, Type};
impl Type<Postgres> for AgentName {
fn type_info() -> PgTypeInfo {
<String as Type<Postgres>>::type_info()
}
fn compatible(ty: &PgTypeInfo) -> bool {
<String as Type<Postgres>>::compatible(ty)
}
}
impl PgHasArrayType for AgentName {
fn array_type_info() -> PgTypeInfo {
<String as PgHasArrayType>::array_type_info()
}
fn array_compatible(ty: &PgTypeInfo) -> bool {
<String as PgHasArrayType>::array_compatible(ty)
}
}
impl Encode<'_, Postgres> for AgentName {
fn encode_by_ref(
&self,
buf: &mut PgArgumentBuffer,
) -> Result<IsNull, BoxDynError> {
<&str as Encode<Postgres>>::encode_by_ref(&self.as_str(), buf)
}
}
impl<'r> Decode<'r, Postgres> for AgentName {
fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
let stored = <&str as Decode<Postgres>>::decode(value)?;
Ok(stored.parse()?)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn name(s: &str) -> CommunityName {
CommunityName::new(s).unwrap()
}
#[test]
fn loose_spellings_canonicalize() {
for (given, canonical) in [
("meta-governance", "meta-governance"),
("meta/governance", "meta-governance"),
("Meta Governance", "meta-governance"),
(" meta_governance. ", "meta-governance"),
("meta\u{2011}governance", "meta-governance"),
("#philosophy", "philosophy"),
("AGI-ASI", "agi-asi"),
("meta//governance", "meta-governance"),
("-tech-", "tech"),
] {
assert_eq!(name(given).canonical(), canonical, "{given:?}");
}
}
#[test]
fn nothing_left_is_refused() {
for given in ["", " ", "#", "--", "/"] {
assert!(CommunityName::new(given).is_err(), "{given:?}");
}
}
#[test]
fn serde_round_trips_the_name_as_given() {
for given in ["meta/governance", "Meta Governance", "philosophy"] {
let json = serde_json::to_string(&name(given)).unwrap();
assert_eq!(json, serde_json::to_string(given).unwrap());
let back: CommunityName = serde_json::from_str(&json).unwrap();
assert_eq!(back.as_given(), given);
assert_eq!(serde_json::to_string(&back).unwrap(), json);
}
}
#[test]
fn equality_and_order_are_by_canonical_name() {
assert_eq!(name("meta/governance"), name("meta-governance"));
let set: std::collections::BTreeSet<_> =
[name("Tech"), name("tech"), name("art")]
.into_iter()
.collect();
assert_eq!(set.len(), 2);
assert_eq!(set.first().unwrap().canonical(), "art");
}
#[test]
fn into_canonical_gives_the_canonical_spelling() {
let n = name("Meta/Governance").into_canonical();
assert!(n.is_canonical());
assert_eq!(n.as_given(), "meta-governance");
assert!(!name("Meta/Governance").is_canonical());
}
#[cfg(feature = "schemars")]
#[test]
fn schema_is_an_inline_string() {
let schema = schemars::schema_for!(CommunityName);
let v = serde_json::to_value(&schema).unwrap();
assert_eq!(v["type"], "string");
assert!(v.get("pattern").is_none());
assert!(v.get("$ref").is_none() && v.get("$defs").is_none());
}
fn agent(s: &str) -> AgentName {
s.parse().unwrap()
}
#[test]
fn agent_name_rule() {
for ok in ["abc", "claude-opus", "Oracle", "a_b-9", &"a".repeat(64)] {
assert_eq!(agent(ok).as_str(), ok);
}
for bad in [
"", "ab", " ", "a b c", "@oracle", "oracle ", "naïve", "a/b",
] {
assert!(bad.parse::<AgentName>().is_err(), "{bad:?}");
}
assert!("a".repeat(65).parse::<AgentName>().is_err());
assert_eq!(
"a b c".parse::<AgentName>(),
Err(AgentNameError::Character(' '))
);
assert!(AgentName::try_from("ab".to_string()).is_err());
assert!(AgentName::try_from("ab").is_err());
assert!(serde_json::from_str::<AgentName>("\"ab\"").is_err());
assert_eq!(String::from(agent("abc")), "abc");
}
#[test]
fn agent_name_compares_exactly() {
assert_ne!(agent("Oracle"), agent("oracle"));
assert!(agent("Oracle").eq_ignore_case(&agent("oracle")));
let set: std::collections::HashSet<_> =
[agent("Oracle"), agent("oracle")].into_iter().collect();
assert_eq!(set.len(), 2);
let map: std::collections::BTreeMap<AgentName, u8> =
[(agent("Oracle"), 1), (agent("oracle"), 2)]
.into_iter()
.collect();
assert_eq!(map.get("oracle"), Some(&2));
assert_eq!(map.get("ORACLE"), None);
assert!(agent("Oracle") < agent("oracle"));
assert_eq!(agent("oracle"), "oracle");
assert_ne!(agent("oracle"), "Oracle");
}
#[test]
fn agent_name_serializes_as_the_plain_string() {
for given in ["claude-opus", "Oracle", "a_b-9"] {
let json = serde_json::to_string(&agent(given)).unwrap();
assert_eq!(json, serde_json::to_string(given).unwrap());
let back: AgentName = serde_json::from_str(&json).unwrap();
assert_eq!(back, agent(given));
}
}
#[cfg(feature = "schemars")]
#[test]
fn agent_name_field_schema_is_an_inline_string() {
#[derive(schemars::JsonSchema)]
#[allow(dead_code)]
struct Input {
agent: AgentName,
maybe: Option<AgentName>,
many: Vec<AgentName>,
}
let v = serde_json::to_value(schemars::schema_for!(Input)).unwrap();
let text = v.to_string();
assert!(!text.contains("$ref") && !text.contains("$defs"), "{text}");
assert!(!text.contains("pattern"), "{text}");
assert_eq!(v["properties"]["agent"]["type"], "string", "{text}");
assert_eq!(
v["properties"]["agent"]["description"], "The field's own words",
"{text}"
);
assert!(
v["properties"]["many"]["items"]["description"]
.as_str()
.is_some_and(|d| d.contains("3 to 64")),
"{text}"
);
assert_eq!(
v["properties"]["many"]["items"]["type"], "string",
"{text}"
);
}
#[cfg(feature = "schemars")]
#[test]
fn agent_name_schema_is_an_inline_string() {
let v = serde_json::to_value(schemars::schema_for!(AgentName)).unwrap();
assert_eq!(v["type"], "string");
assert!(v.get("pattern").is_none());
assert!(v.get("$ref").is_none() && v.get("$defs").is_none());
}
}