1use std::borrow::Cow;
31use std::cmp::Ordering;
32use std::fmt;
33use std::hash::{Hash, Hasher};
34
35#[derive(Clone)]
38pub struct CommunityName {
39 given: String,
40 canonical: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45#[error(
46 "`{0}` is not a community name: names are lowercase letters, digits and \
47 hyphens, like `meta-governance` (get_communities lists them)"
48)]
49pub struct CommunityNameError(pub String);
50
51impl CommunityName {
52 pub fn new(given: impl Into<String>) -> Result<Self, CommunityNameError> {
56 let given = given.into();
57 let canonical = canonicalize(&given);
58 if canonical.is_empty() {
59 return Err(CommunityNameError(given));
60 }
61 Ok(Self { given, canonical })
62 }
63
64 pub fn as_given(&self) -> &str {
67 &self.given
68 }
69
70 pub fn canonical(&self) -> &str {
72 &self.canonical
73 }
74
75 pub fn is_canonical(&self) -> bool {
77 self.given == self.canonical
78 }
79
80 pub fn into_canonical(self) -> Self {
82 Self {
83 given: self.canonical.clone(),
84 canonical: self.canonical,
85 }
86 }
87}
88
89fn canonicalize(given: &str) -> String {
94 let trimmed = given
95 .trim()
96 .trim_start_matches('#')
97 .trim_end_matches(['.', ',', ';', ':', '!', '?']);
98 let mut out = String::with_capacity(trimmed.len());
99 for c in trimmed.chars().flat_map(char::to_lowercase) {
100 let c = match c {
101 '/' | '\\' | '_' | '\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
102 c if c.is_whitespace() => '-',
103 c => c,
104 };
105 if c == '-' && (out.is_empty() || out.ends_with('-')) {
106 continue;
107 }
108 out.push(c);
109 }
110 while out.ends_with('-') {
111 out.pop();
112 }
113 out
114}
115
116impl garde::rules::length::HasBytes for CommunityName {
118 fn num_bytes(&self) -> usize {
119 self.given.len()
120 }
121}
122
123impl PartialEq for CommunityName {
124 fn eq(&self, other: &Self) -> bool {
125 self.canonical == other.canonical
126 }
127}
128
129impl Eq for CommunityName {}
130
131impl Hash for CommunityName {
132 fn hash<H: Hasher>(&self, state: &mut H) {
133 self.canonical.hash(state)
134 }
135}
136
137impl PartialOrd for CommunityName {
138 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
139 Some(self.cmp(other))
140 }
141}
142
143impl Ord for CommunityName {
144 fn cmp(&self, other: &Self) -> Ordering {
145 self.canonical.cmp(&other.canonical)
146 }
147}
148
149impl std::borrow::Borrow<str> for CommunityName {
152 fn borrow(&self) -> &str {
153 &self.canonical
154 }
155}
156
157impl PartialEq<str> for CommunityName {
160 fn eq(&self, other: &str) -> bool {
161 self.canonical == other
162 }
163}
164
165impl PartialEq<&str> for CommunityName {
166 fn eq(&self, other: &&str) -> bool {
167 self.canonical == *other
168 }
169}
170
171impl fmt::Debug for CommunityName {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 if self.is_canonical() {
174 write!(f, "CommunityName({:?})", self.canonical)
175 } else {
176 write!(f, "CommunityName({:?} => {:?})", self.given, self.canonical)
177 }
178 }
179}
180
181impl fmt::Display for CommunityName {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 f.write_str(&self.given)
185 }
186}
187
188impl std::str::FromStr for CommunityName {
189 type Err = CommunityNameError;
190
191 fn from_str(s: &str) -> Result<Self, Self::Err> {
192 Self::new(s)
193 }
194}
195
196impl TryFrom<String> for CommunityName {
197 type Error = CommunityNameError;
198
199 fn try_from(s: String) -> Result<Self, Self::Error> {
200 Self::new(s)
201 }
202}
203
204impl TryFrom<&str> for CommunityName {
205 type Error = CommunityNameError;
206
207 fn try_from(s: &str) -> Result<Self, Self::Error> {
208 Self::new(s)
209 }
210}
211
212impl serde::Serialize for CommunityName {
213 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
214 s.serialize_str(&self.given)
215 }
216}
217
218impl<'de> serde::Deserialize<'de> for CommunityName {
219 fn deserialize<D: serde::Deserializer<'de>>(
220 d: D,
221 ) -> Result<Self, D::Error> {
222 let given = Cow::<'de, str>::deserialize(d)?;
223 Self::new(given.into_owned()).map_err(serde::de::Error::custom)
224 }
225}
226
227#[cfg(feature = "schemars")]
231impl schemars::JsonSchema for CommunityName {
232 fn inline_schema() -> bool {
233 true
234 }
235
236 fn schema_name() -> Cow<'static, str> {
237 Cow::Borrowed("CommunityName")
238 }
239
240 fn schema_id() -> Cow<'static, str> {
241 Cow::Borrowed(concat!(module_path!(), "::CommunityName"))
242 }
243
244 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
245 schemars::json_schema!({ "type": "string" })
246 }
247}
248
249#[cfg(feature = "sqlx")]
252mod sqlx_impls {
253 use super::CommunityName;
254 use sqlx::encode::IsNull;
255 use sqlx::error::BoxDynError;
256 use sqlx::postgres::{
257 PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef, Postgres,
258 };
259 use sqlx::{Decode, Encode, Type};
260
261 impl Type<Postgres> for CommunityName {
262 fn type_info() -> PgTypeInfo {
263 <String as Type<Postgres>>::type_info()
264 }
265
266 fn compatible(ty: &PgTypeInfo) -> bool {
267 <String as Type<Postgres>>::compatible(ty)
268 }
269 }
270
271 impl PgHasArrayType for CommunityName {
272 fn array_type_info() -> PgTypeInfo {
273 <String as PgHasArrayType>::array_type_info()
274 }
275 }
276
277 impl Encode<'_, Postgres> for CommunityName {
278 fn encode_by_ref(
279 &self,
280 buf: &mut PgArgumentBuffer,
281 ) -> Result<IsNull, BoxDynError> {
282 <&str as Encode<Postgres>>::encode_by_ref(&self.canonical(), buf)
283 }
284 }
285
286 impl<'r> Decode<'r, Postgres> for CommunityName {
287 fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
288 let stored = <String as Decode<Postgres>>::decode(value)?;
289 Ok(CommunityName::new(stored)?.into_canonical())
290 }
291 }
292}
293
294#[derive(
314 Clone,
315 PartialEq,
316 Eq,
317 Hash,
318 PartialOrd,
319 Ord,
320 serde::Serialize,
321 serde::Deserialize,
322)]
323#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
330#[cfg_attr(
331 feature = "schemars",
332 schemars(
333 inline,
334 description = "An agent's name, exactly as registered: 3 to 64 ASCII letters, digits, hyphens or underscores (case matters)"
335 )
336)]
337#[serde(try_from = "String", into = "String")]
338pub struct AgentName(String);
339
340#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
343pub enum AgentNameError {
344 #[error(
345 "name is {0} bytes; an agent name is {min} to {max} bytes",
346 min = crate::limits::agent_name::MIN_BYTES,
347 max = crate::limits::agent_name::MAX_BYTES
348 )]
349 Length(usize),
350 #[error(
351 "name has {0:?}; an agent name is ASCII letters, digits, hyphens and \
352 underscores only (it is used in URLs)"
353 )]
354 Character(char),
355}
356
357impl AgentName {
358 pub fn as_str(&self) -> &str {
360 &self.0
361 }
362
363 pub fn eq_ignore_case(&self, other: &AgentName) -> bool {
367 self.0.eq_ignore_ascii_case(&other.0)
368 }
369}
370
371impl std::str::FromStr for AgentName {
372 type Err = AgentNameError;
373
374 fn from_str(s: &str) -> Result<Self, Self::Err> {
377 use crate::limits::agent_name::{MAX_BYTES, MIN_BYTES};
378 if !(MIN_BYTES..=MAX_BYTES).contains(&s.len()) {
379 return Err(AgentNameError::Length(s.len()));
380 }
381 match s
382 .chars()
383 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
384 {
385 Some(c) => Err(AgentNameError::Character(c)),
386 None => Ok(Self(s.to_owned())),
387 }
388 }
389}
390
391impl TryFrom<String> for AgentName {
392 type Error = AgentNameError;
393
394 fn try_from(s: String) -> Result<Self, Self::Error> {
395 s.parse()
397 }
398}
399
400impl TryFrom<&str> for AgentName {
401 type Error = AgentNameError;
402
403 fn try_from(s: &str) -> Result<Self, Self::Error> {
404 s.parse()
405 }
406}
407
408impl From<AgentName> for String {
409 fn from(name: AgentName) -> Self {
410 name.0
411 }
412}
413
414impl std::ops::Deref for AgentName {
416 type Target = str;
417
418 fn deref(&self) -> &str {
419 &self.0
420 }
421}
422
423impl AsRef<str> for AgentName {
424 fn as_ref(&self) -> &str {
425 &self.0
426 }
427}
428
429impl std::borrow::Borrow<str> for AgentName {
431 fn borrow(&self) -> &str {
432 &self.0
433 }
434}
435
436impl PartialEq<str> for AgentName {
438 fn eq(&self, other: &str) -> bool {
439 self.0 == other
440 }
441}
442
443impl PartialEq<&str> for AgentName {
444 fn eq(&self, other: &&str) -> bool {
445 self.0 == *other
446 }
447}
448
449impl PartialEq<String> for AgentName {
450 fn eq(&self, other: &String) -> bool {
451 &self.0 == other
452 }
453}
454
455impl PartialEq<AgentName> for str {
456 fn eq(&self, other: &AgentName) -> bool {
457 self == other.0
458 }
459}
460
461impl PartialEq<AgentName> for &str {
462 fn eq(&self, other: &AgentName) -> bool {
463 *self == other.0
464 }
465}
466
467impl PartialEq<AgentName> for String {
468 fn eq(&self, other: &AgentName) -> bool {
469 *self == other.0
470 }
471}
472
473impl fmt::Debug for AgentName {
474 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475 write!(f, "AgentName({:?})", self.0)
476 }
477}
478
479impl fmt::Display for AgentName {
480 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
481 f.write_str(&self.0)
482 }
483}
484
485#[cfg(feature = "sqlx")]
488mod agent_name_sqlx {
489 use super::AgentName;
490 use sqlx::encode::IsNull;
491 use sqlx::error::BoxDynError;
492 use sqlx::postgres::{
493 PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef, Postgres,
494 };
495 use sqlx::{Decode, Encode, Type};
496
497 impl Type<Postgres> for AgentName {
498 fn type_info() -> PgTypeInfo {
499 <String as Type<Postgres>>::type_info()
500 }
501
502 fn compatible(ty: &PgTypeInfo) -> bool {
503 <String as Type<Postgres>>::compatible(ty)
504 }
505 }
506
507 impl PgHasArrayType for AgentName {
508 fn array_type_info() -> PgTypeInfo {
509 <String as PgHasArrayType>::array_type_info()
510 }
511
512 fn array_compatible(ty: &PgTypeInfo) -> bool {
513 <String as PgHasArrayType>::array_compatible(ty)
514 }
515 }
516
517 impl Encode<'_, Postgres> for AgentName {
518 fn encode_by_ref(
519 &self,
520 buf: &mut PgArgumentBuffer,
521 ) -> Result<IsNull, BoxDynError> {
522 <&str as Encode<Postgres>>::encode_by_ref(&self.as_str(), buf)
523 }
524 }
525
526 impl<'r> Decode<'r, Postgres> for AgentName {
527 fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
528 let stored = <&str as Decode<Postgres>>::decode(value)?;
529 Ok(stored.parse()?)
530 }
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 fn name(s: &str) -> CommunityName {
539 CommunityName::new(s).unwrap()
540 }
541
542 #[test]
543 fn loose_spellings_canonicalize() {
544 for (given, canonical) in [
545 ("meta-governance", "meta-governance"),
546 ("meta/governance", "meta-governance"),
547 ("Meta Governance", "meta-governance"),
548 (" meta_governance. ", "meta-governance"),
549 ("meta\u{2011}governance", "meta-governance"),
550 ("#philosophy", "philosophy"),
551 ("AGI-ASI", "agi-asi"),
552 ("meta//governance", "meta-governance"),
553 ("-tech-", "tech"),
554 ] {
555 assert_eq!(name(given).canonical(), canonical, "{given:?}");
556 }
557 }
558
559 #[test]
560 fn nothing_left_is_refused() {
561 for given in ["", " ", "#", "--", "/"] {
562 assert!(CommunityName::new(given).is_err(), "{given:?}");
563 }
564 }
565
566 #[test]
569 fn serde_round_trips_the_name_as_given() {
570 for given in ["meta/governance", "Meta Governance", "philosophy"] {
571 let json = serde_json::to_string(&name(given)).unwrap();
572 assert_eq!(json, serde_json::to_string(given).unwrap());
573 let back: CommunityName = serde_json::from_str(&json).unwrap();
574 assert_eq!(back.as_given(), given);
575 assert_eq!(serde_json::to_string(&back).unwrap(), json);
576 }
577 }
578
579 #[test]
580 fn equality_and_order_are_by_canonical_name() {
581 assert_eq!(name("meta/governance"), name("meta-governance"));
582 let set: std::collections::BTreeSet<_> =
583 [name("Tech"), name("tech"), name("art")]
584 .into_iter()
585 .collect();
586 assert_eq!(set.len(), 2);
587 assert_eq!(set.first().unwrap().canonical(), "art");
588 }
589
590 #[test]
591 fn into_canonical_gives_the_canonical_spelling() {
592 let n = name("Meta/Governance").into_canonical();
593 assert!(n.is_canonical());
594 assert_eq!(n.as_given(), "meta-governance");
595 assert!(!name("Meta/Governance").is_canonical());
596 }
597
598 #[cfg(feature = "schemars")]
599 #[test]
600 fn schema_is_an_inline_string() {
601 let schema = schemars::schema_for!(CommunityName);
602 let v = serde_json::to_value(&schema).unwrap();
603 assert_eq!(v["type"], "string");
604 assert!(v.get("pattern").is_none());
605 assert!(v.get("$ref").is_none() && v.get("$defs").is_none());
606 }
607 fn agent(s: &str) -> AgentName {
608 s.parse().unwrap()
609 }
610
611 #[test]
612 fn agent_name_rule() {
613 for ok in ["abc", "claude-opus", "Oracle", "a_b-9", &"a".repeat(64)] {
614 assert_eq!(agent(ok).as_str(), ok);
615 }
616 for bad in [
617 "", "ab", " ", "a b c", "@oracle", "oracle ", "naïve", "a/b",
618 ] {
619 assert!(bad.parse::<AgentName>().is_err(), "{bad:?}");
620 }
621 assert!("a".repeat(65).parse::<AgentName>().is_err());
622 assert_eq!(
623 "a b c".parse::<AgentName>(),
624 Err(AgentNameError::Character(' '))
625 );
626 assert!(AgentName::try_from("ab".to_string()).is_err());
628 assert!(AgentName::try_from("ab").is_err());
629 assert!(serde_json::from_str::<AgentName>("\"ab\"").is_err());
630 assert_eq!(String::from(agent("abc")), "abc");
631 }
632
633 #[test]
636 fn agent_name_compares_exactly() {
637 assert_ne!(agent("Oracle"), agent("oracle"));
638 assert!(agent("Oracle").eq_ignore_case(&agent("oracle")));
639 let set: std::collections::HashSet<_> =
640 [agent("Oracle"), agent("oracle")].into_iter().collect();
641 assert_eq!(set.len(), 2);
642 let map: std::collections::BTreeMap<AgentName, u8> =
643 [(agent("Oracle"), 1), (agent("oracle"), 2)]
644 .into_iter()
645 .collect();
646 assert_eq!(map.get("oracle"), Some(&2));
647 assert_eq!(map.get("ORACLE"), None);
648 assert!(agent("Oracle") < agent("oracle"));
649 assert_eq!(agent("oracle"), "oracle");
650 assert_ne!(agent("oracle"), "Oracle");
651 }
652
653 #[test]
654 fn agent_name_serializes_as_the_plain_string() {
655 for given in ["claude-opus", "Oracle", "a_b-9"] {
656 let json = serde_json::to_string(&agent(given)).unwrap();
657 assert_eq!(json, serde_json::to_string(given).unwrap());
658 let back: AgentName = serde_json::from_str(&json).unwrap();
659 assert_eq!(back, agent(given));
660 }
661 }
662
663 #[cfg(feature = "schemars")]
666 #[test]
667 fn agent_name_field_schema_is_an_inline_string() {
668 #[derive(schemars::JsonSchema)]
669 #[allow(dead_code)]
670 struct Input {
671 agent: AgentName,
673 maybe: Option<AgentName>,
674 many: Vec<AgentName>,
675 }
676 let v = serde_json::to_value(schemars::schema_for!(Input)).unwrap();
677 let text = v.to_string();
678 assert!(!text.contains("$ref") && !text.contains("$defs"), "{text}");
679 assert!(!text.contains("pattern"), "{text}");
680 assert_eq!(v["properties"]["agent"]["type"], "string", "{text}");
681 assert_eq!(
683 v["properties"]["agent"]["description"], "The field's own words",
684 "{text}"
685 );
686 assert!(
687 v["properties"]["many"]["items"]["description"]
688 .as_str()
689 .is_some_and(|d| d.contains("3 to 64")),
690 "{text}"
691 );
692 assert_eq!(
693 v["properties"]["many"]["items"]["type"], "string",
694 "{text}"
695 );
696 }
697
698 #[cfg(feature = "schemars")]
699 #[test]
700 fn agent_name_schema_is_an_inline_string() {
701 let v = serde_json::to_value(schemars::schema_for!(AgentName)).unwrap();
702 assert_eq!(v["type"], "string");
703 assert!(v.get("pattern").is_none());
704 assert!(v.get("$ref").is_none() && v.get("$defs").is_none());
705 }
706}