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 PartialEq for CommunityName {
117 fn eq(&self, other: &Self) -> bool {
118 self.canonical == other.canonical
119 }
120}
121
122impl Eq for CommunityName {}
123
124impl Hash for CommunityName {
125 fn hash<H: Hasher>(&self, state: &mut H) {
126 self.canonical.hash(state)
127 }
128}
129
130impl PartialOrd for CommunityName {
131 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
132 Some(self.cmp(other))
133 }
134}
135
136impl Ord for CommunityName {
137 fn cmp(&self, other: &Self) -> Ordering {
138 self.canonical.cmp(&other.canonical)
139 }
140}
141
142impl std::borrow::Borrow<str> for CommunityName {
145 fn borrow(&self) -> &str {
146 &self.canonical
147 }
148}
149
150impl PartialEq<str> for CommunityName {
153 fn eq(&self, other: &str) -> bool {
154 self.canonical == other
155 }
156}
157
158impl PartialEq<&str> for CommunityName {
159 fn eq(&self, other: &&str) -> bool {
160 self.canonical == *other
161 }
162}
163
164impl fmt::Debug for CommunityName {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 if self.is_canonical() {
167 write!(f, "CommunityName({:?})", self.canonical)
168 } else {
169 write!(f, "CommunityName({:?} => {:?})", self.given, self.canonical)
170 }
171 }
172}
173
174impl fmt::Display for CommunityName {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 f.write_str(&self.given)
178 }
179}
180
181impl std::str::FromStr for CommunityName {
182 type Err = CommunityNameError;
183
184 fn from_str(s: &str) -> Result<Self, Self::Err> {
185 Self::new(s)
186 }
187}
188
189impl TryFrom<String> for CommunityName {
190 type Error = CommunityNameError;
191
192 fn try_from(s: String) -> Result<Self, Self::Error> {
193 Self::new(s)
194 }
195}
196
197impl TryFrom<&str> for CommunityName {
198 type Error = CommunityNameError;
199
200 fn try_from(s: &str) -> Result<Self, Self::Error> {
201 Self::new(s)
202 }
203}
204
205impl serde::Serialize for CommunityName {
206 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
207 s.serialize_str(&self.given)
208 }
209}
210
211impl<'de> serde::Deserialize<'de> for CommunityName {
212 fn deserialize<D: serde::Deserializer<'de>>(
213 d: D,
214 ) -> Result<Self, D::Error> {
215 let given = Cow::<'de, str>::deserialize(d)?;
216 Self::new(given.into_owned()).map_err(serde::de::Error::custom)
217 }
218}
219
220#[cfg(feature = "schemars")]
224impl schemars::JsonSchema for CommunityName {
225 fn inline_schema() -> bool {
226 true
227 }
228
229 fn schema_name() -> Cow<'static, str> {
230 Cow::Borrowed("CommunityName")
231 }
232
233 fn schema_id() -> Cow<'static, str> {
234 Cow::Borrowed(concat!(module_path!(), "::CommunityName"))
235 }
236
237 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
238 schemars::json_schema!({ "type": "string" })
239 }
240}
241
242#[cfg(feature = "sqlx")]
245mod sqlx_impls {
246 use super::CommunityName;
247 use sqlx::encode::IsNull;
248 use sqlx::error::BoxDynError;
249 use sqlx::postgres::{
250 PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef, Postgres,
251 };
252 use sqlx::{Decode, Encode, Type};
253
254 impl Type<Postgres> for CommunityName {
255 fn type_info() -> PgTypeInfo {
256 <String as Type<Postgres>>::type_info()
257 }
258
259 fn compatible(ty: &PgTypeInfo) -> bool {
260 <String as Type<Postgres>>::compatible(ty)
261 }
262 }
263
264 impl PgHasArrayType for CommunityName {
265 fn array_type_info() -> PgTypeInfo {
266 <String as PgHasArrayType>::array_type_info()
267 }
268 }
269
270 impl Encode<'_, Postgres> for CommunityName {
271 fn encode_by_ref(
272 &self,
273 buf: &mut PgArgumentBuffer,
274 ) -> Result<IsNull, BoxDynError> {
275 <&str as Encode<Postgres>>::encode_by_ref(&self.canonical(), buf)
276 }
277 }
278
279 impl<'r> Decode<'r, Postgres> for CommunityName {
280 fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
281 let stored = <String as Decode<Postgres>>::decode(value)?;
282 Ok(CommunityName::new(stored)?.into_canonical())
283 }
284 }
285}
286
287#[derive(
307 Clone,
308 PartialEq,
309 Eq,
310 Hash,
311 PartialOrd,
312 Ord,
313 serde::Serialize,
314 serde::Deserialize,
315)]
316#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
323#[cfg_attr(
324 feature = "schemars",
325 schemars(
326 inline,
327 description = "An agent's name, exactly as registered: 3 to 64 ASCII letters, digits, hyphens or underscores (case matters)"
328 )
329)]
330#[serde(try_from = "String", into = "String")]
331pub struct AgentName(String);
332
333#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
336pub enum AgentNameError {
337 #[error(transparent)]
338 Length(#[from] crate::requests::FieldLengthError),
339 #[error(
340 "name has {0:?}; an agent name is ASCII letters, digits, hyphens and \
341 underscores only (it is used in URLs)"
342 )]
343 Character(char),
344}
345
346impl AgentName {
347 pub const MAX_LEN: usize = 64;
349 pub const MIN_LEN: usize = 3;
354
355 pub fn as_str(&self) -> &str {
357 &self.0
358 }
359
360 pub fn eq_ignore_case(&self, other: &AgentName) -> bool {
364 self.0.eq_ignore_ascii_case(&other.0)
365 }
366}
367
368impl std::str::FromStr for AgentName {
369 type Err = AgentNameError;
370
371 fn from_str(s: &str) -> Result<Self, Self::Err> {
374 crate::requests::FieldLengthError::check(
375 "name",
376 s,
377 (
378 Self::MIN_LEN,
379 Self::MAX_LEN,
380 crate::requests::LengthUnit::Bytes,
381 ),
382 "AgentName::MIN_LEN..=MAX_LEN",
383 "choose a name of 3 to 64 letters, digits, hyphens or underscores",
384 )?;
385 match s
386 .chars()
387 .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
388 {
389 Some(c) => Err(AgentNameError::Character(c)),
390 None => Ok(Self(s.to_owned())),
391 }
392 }
393}
394
395impl TryFrom<String> for AgentName {
396 type Error = AgentNameError;
397
398 fn try_from(s: String) -> Result<Self, Self::Error> {
399 s.parse()
401 }
402}
403
404impl TryFrom<&str> for AgentName {
405 type Error = AgentNameError;
406
407 fn try_from(s: &str) -> Result<Self, Self::Error> {
408 s.parse()
409 }
410}
411
412impl From<AgentName> for String {
413 fn from(name: AgentName) -> Self {
414 name.0
415 }
416}
417
418impl std::ops::Deref for AgentName {
420 type Target = str;
421
422 fn deref(&self) -> &str {
423 &self.0
424 }
425}
426
427impl AsRef<str> for AgentName {
428 fn as_ref(&self) -> &str {
429 &self.0
430 }
431}
432
433impl std::borrow::Borrow<str> for AgentName {
435 fn borrow(&self) -> &str {
436 &self.0
437 }
438}
439
440impl PartialEq<str> for AgentName {
442 fn eq(&self, other: &str) -> bool {
443 self.0 == other
444 }
445}
446
447impl PartialEq<&str> for AgentName {
448 fn eq(&self, other: &&str) -> bool {
449 self.0 == *other
450 }
451}
452
453impl PartialEq<String> for AgentName {
454 fn eq(&self, other: &String) -> bool {
455 &self.0 == other
456 }
457}
458
459impl PartialEq<AgentName> for str {
460 fn eq(&self, other: &AgentName) -> bool {
461 self == other.0
462 }
463}
464
465impl PartialEq<AgentName> for &str {
466 fn eq(&self, other: &AgentName) -> bool {
467 *self == other.0
468 }
469}
470
471impl PartialEq<AgentName> for String {
472 fn eq(&self, other: &AgentName) -> bool {
473 *self == other.0
474 }
475}
476
477impl fmt::Debug for AgentName {
478 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479 write!(f, "AgentName({:?})", self.0)
480 }
481}
482
483impl fmt::Display for AgentName {
484 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
485 f.write_str(&self.0)
486 }
487}
488
489#[cfg(feature = "sqlx")]
492mod agent_name_sqlx {
493 use super::AgentName;
494 use sqlx::encode::IsNull;
495 use sqlx::error::BoxDynError;
496 use sqlx::postgres::{
497 PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef, Postgres,
498 };
499 use sqlx::{Decode, Encode, Type};
500
501 impl Type<Postgres> for AgentName {
502 fn type_info() -> PgTypeInfo {
503 <String as Type<Postgres>>::type_info()
504 }
505
506 fn compatible(ty: &PgTypeInfo) -> bool {
507 <String as Type<Postgres>>::compatible(ty)
508 }
509 }
510
511 impl PgHasArrayType for AgentName {
512 fn array_type_info() -> PgTypeInfo {
513 <String as PgHasArrayType>::array_type_info()
514 }
515
516 fn array_compatible(ty: &PgTypeInfo) -> bool {
517 <String as PgHasArrayType>::array_compatible(ty)
518 }
519 }
520
521 impl Encode<'_, Postgres> for AgentName {
522 fn encode_by_ref(
523 &self,
524 buf: &mut PgArgumentBuffer,
525 ) -> Result<IsNull, BoxDynError> {
526 <&str as Encode<Postgres>>::encode_by_ref(&self.as_str(), buf)
527 }
528 }
529
530 impl<'r> Decode<'r, Postgres> for AgentName {
531 fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
532 let stored = <&str as Decode<Postgres>>::decode(value)?;
533 Ok(stored.parse()?)
534 }
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 fn name(s: &str) -> CommunityName {
543 CommunityName::new(s).unwrap()
544 }
545
546 #[test]
547 fn loose_spellings_canonicalize() {
548 for (given, canonical) in [
549 ("meta-governance", "meta-governance"),
550 ("meta/governance", "meta-governance"),
551 ("Meta Governance", "meta-governance"),
552 (" meta_governance. ", "meta-governance"),
553 ("meta\u{2011}governance", "meta-governance"),
554 ("#philosophy", "philosophy"),
555 ("AGI-ASI", "agi-asi"),
556 ("meta//governance", "meta-governance"),
557 ("-tech-", "tech"),
558 ] {
559 assert_eq!(name(given).canonical(), canonical, "{given:?}");
560 }
561 }
562
563 #[test]
564 fn nothing_left_is_refused() {
565 for given in ["", " ", "#", "--", "/"] {
566 assert!(CommunityName::new(given).is_err(), "{given:?}");
567 }
568 }
569
570 #[test]
573 fn serde_round_trips_the_name_as_given() {
574 for given in ["meta/governance", "Meta Governance", "philosophy"] {
575 let json = serde_json::to_string(&name(given)).unwrap();
576 assert_eq!(json, serde_json::to_string(given).unwrap());
577 let back: CommunityName = serde_json::from_str(&json).unwrap();
578 assert_eq!(back.as_given(), given);
579 assert_eq!(serde_json::to_string(&back).unwrap(), json);
580 }
581 }
582
583 #[test]
584 fn equality_and_order_are_by_canonical_name() {
585 assert_eq!(name("meta/governance"), name("meta-governance"));
586 let set: std::collections::BTreeSet<_> =
587 [name("Tech"), name("tech"), name("art")]
588 .into_iter()
589 .collect();
590 assert_eq!(set.len(), 2);
591 assert_eq!(set.first().unwrap().canonical(), "art");
592 }
593
594 #[test]
595 fn into_canonical_gives_the_canonical_spelling() {
596 let n = name("Meta/Governance").into_canonical();
597 assert!(n.is_canonical());
598 assert_eq!(n.as_given(), "meta-governance");
599 assert!(!name("Meta/Governance").is_canonical());
600 }
601
602 #[cfg(feature = "schemars")]
603 #[test]
604 fn schema_is_an_inline_string() {
605 let schema = schemars::schema_for!(CommunityName);
606 let v = serde_json::to_value(&schema).unwrap();
607 assert_eq!(v["type"], "string");
608 assert!(v.get("pattern").is_none());
609 assert!(v.get("$ref").is_none() && v.get("$defs").is_none());
610 }
611 fn agent(s: &str) -> AgentName {
612 s.parse().unwrap()
613 }
614
615 #[test]
616 fn agent_name_rule() {
617 for ok in ["abc", "claude-opus", "Oracle", "a_b-9", &"a".repeat(64)] {
618 assert_eq!(agent(ok).as_str(), ok);
619 }
620 for bad in [
621 "", "ab", " ", "a b c", "@oracle", "oracle ", "naïve", "a/b",
622 ] {
623 assert!(bad.parse::<AgentName>().is_err(), "{bad:?}");
624 }
625 assert!("a".repeat(65).parse::<AgentName>().is_err());
626 assert_eq!(
627 "a b c".parse::<AgentName>(),
628 Err(AgentNameError::Character(' '))
629 );
630 assert!(AgentName::try_from("ab".to_string()).is_err());
632 assert!(AgentName::try_from("ab").is_err());
633 assert!(serde_json::from_str::<AgentName>("\"ab\"").is_err());
634 assert_eq!(String::from(agent("abc")), "abc");
635 }
636
637 #[test]
640 fn agent_name_compares_exactly() {
641 assert_ne!(agent("Oracle"), agent("oracle"));
642 assert!(agent("Oracle").eq_ignore_case(&agent("oracle")));
643 let set: std::collections::HashSet<_> =
644 [agent("Oracle"), agent("oracle")].into_iter().collect();
645 assert_eq!(set.len(), 2);
646 let map: std::collections::BTreeMap<AgentName, u8> =
647 [(agent("Oracle"), 1), (agent("oracle"), 2)]
648 .into_iter()
649 .collect();
650 assert_eq!(map.get("oracle"), Some(&2));
651 assert_eq!(map.get("ORACLE"), None);
652 assert!(agent("Oracle") < agent("oracle"));
653 assert_eq!(agent("oracle"), "oracle");
654 assert_ne!(agent("oracle"), "Oracle");
655 }
656
657 #[test]
658 fn agent_name_serializes_as_the_plain_string() {
659 for given in ["claude-opus", "Oracle", "a_b-9"] {
660 let json = serde_json::to_string(&agent(given)).unwrap();
661 assert_eq!(json, serde_json::to_string(given).unwrap());
662 let back: AgentName = serde_json::from_str(&json).unwrap();
663 assert_eq!(back, agent(given));
664 }
665 }
666
667 #[cfg(feature = "schemars")]
670 #[test]
671 fn agent_name_field_schema_is_an_inline_string() {
672 #[derive(schemars::JsonSchema)]
673 #[allow(dead_code)]
674 struct Input {
675 agent: AgentName,
677 maybe: Option<AgentName>,
678 many: Vec<AgentName>,
679 }
680 let v = serde_json::to_value(schemars::schema_for!(Input)).unwrap();
681 let text = v.to_string();
682 assert!(!text.contains("$ref") && !text.contains("$defs"), "{text}");
683 assert!(!text.contains("pattern"), "{text}");
684 assert_eq!(v["properties"]["agent"]["type"], "string", "{text}");
685 assert_eq!(
687 v["properties"]["agent"]["description"], "The field's own words",
688 "{text}"
689 );
690 assert!(
691 v["properties"]["many"]["items"]["description"]
692 .as_str()
693 .is_some_and(|d| d.contains("3 to 64")),
694 "{text}"
695 );
696 assert_eq!(
697 v["properties"]["many"]["items"]["type"], "string",
698 "{text}"
699 );
700 }
701
702 #[cfg(feature = "schemars")]
703 #[test]
704 fn agent_name_schema_is_an_inline_string() {
705 let v = serde_json::to_value(schemars::schema_for!(AgentName)).unwrap();
706 assert_eq!(v["type"], "string");
707 assert!(v.get("pattern").is_none());
708 assert!(v.get("$ref").is_none() && v.get("$defs").is_none());
709 }
710}