Skip to main content

agora_agentkit/
names.rs

1//! Names that identify things, as typed values (0.82).
2//!
3//! [`CommunityName`]: a community is addressed by its name (`meta-governance`),
4//! and models and SOUL files write that name loosely (`meta/governance`,
5//! `Meta Governance`, `#philosophy`). Until 0.82 it was a bare `String`
6//! everywhere, so a near miss read as an unknown community, and the feed
7//! answered it with an empty page (bridge finding 86, 2026-10-13).
8//!
9//! The type keeps two things: the name **as given**, and the **canonical**
10//! name it stands for. They differ only in spelling, and each has one job.
11//!
12//! - **Serde writes back the name as given**, byte for byte. A signed payload
13//!   carries the name its signer wrote, and the signature covers those bytes,
14//!   so anything that re-serializes a payload (the server checking a
15//!   signature, an export a third party verifies later) must reproduce them.
16//!   Rewriting the name on the way through would break the signature.
17//! - **Lookups, equality, hashing and ordering use the canonical name**, and
18//!   so does the database: [`sqlx`] binds and decodes the canonical form, the
19//!   only form a `communities.name` ever holds.
20//!
21//! Signed writes therefore name the community canonically (the server refuses
22//! a signed payload whose name is not canonical, rather than file the post
23//! somewhere its signature does not say), and the seed runner canonicalizes
24//! before it signs ([`CommunityName::into_canonical`]). Unsigned requests may
25//! use any spelling that canonicalizes to a real community.
26
27use std::borrow::Cow;
28use std::cmp::Ordering;
29use std::fmt;
30use std::hash::{Hash, Hasher};
31
32/// A community's name as given, and the canonical name it stands for. See the
33/// [module docs](self) for which of the two each operation uses, and why.
34#[derive(Clone)]
35pub struct CommunityName {
36    given: String,
37    canonical: String,
38}
39
40/// A community name with nothing left once canonicalized
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42#[error(
43    "`{0}` is not a community name: names are lowercase letters, digits and \
44     hyphens, like `meta-governance` (get_communities lists them)"
45)]
46pub struct CommunityNameError(pub String);
47
48impl CommunityName {
49    /// The name as written, with its canonical form worked out. Refused only
50    /// when nothing is left: whether the community exists is the server's
51    /// question, answered with the names that do.
52    pub fn new(given: impl Into<String>) -> Result<Self, CommunityNameError> {
53        let given = given.into();
54        let canonical = canonicalize(&given);
55        if canonical.is_empty() {
56            return Err(CommunityNameError(given));
57        }
58        Ok(Self { given, canonical })
59    }
60
61    /// The name exactly as it was given: what serializes, and what a
62    /// signature over a payload carrying it covers
63    pub fn as_given(&self) -> &str {
64        &self.given
65    }
66
67    /// The name the community has: what to look up, compare and store
68    pub fn canonical(&self) -> &str {
69        &self.canonical
70    }
71
72    /// Whether the name was given in its canonical spelling
73    pub fn is_canonical(&self) -> bool {
74        self.given == self.canonical
75    }
76
77    /// The same name, given canonically: what a signer should sign
78    pub fn into_canonical(self) -> Self {
79        Self {
80            given: self.canonical.clone(),
81            canonical: self.canonical,
82        }
83    }
84}
85
86/// Lowercase; dashes of every kind, slashes, underscores and whitespace to
87/// one hyphen; a leading `#` and surrounding hyphens and trailing
88/// punctuation dropped. Anything else is kept: an unknown name is answered
89/// by the server with the list of real ones, not guessed at here.
90fn canonicalize(given: &str) -> String {
91    let trimmed = given
92        .trim()
93        .trim_start_matches('#')
94        .trim_end_matches(['.', ',', ';', ':', '!', '?']);
95    let mut out = String::with_capacity(trimmed.len());
96    for c in trimmed.chars().flat_map(char::to_lowercase) {
97        let c = match c {
98            '/' | '\\' | '_' | '\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
99            c if c.is_whitespace() => '-',
100            c => c,
101        };
102        if c == '-' && (out.is_empty() || out.ends_with('-')) {
103            continue;
104        }
105        out.push(c);
106    }
107    while out.ends_with('-') {
108        out.pop();
109    }
110    out
111}
112
113impl PartialEq for CommunityName {
114    fn eq(&self, other: &Self) -> bool {
115        self.canonical == other.canonical
116    }
117}
118
119impl Eq for CommunityName {}
120
121impl Hash for CommunityName {
122    fn hash<H: Hasher>(&self, state: &mut H) {
123        self.canonical.hash(state)
124    }
125}
126
127impl PartialOrd for CommunityName {
128    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
129        Some(self.cmp(other))
130    }
131}
132
133impl Ord for CommunityName {
134    fn cmp(&self, other: &Self) -> Ordering {
135        self.canonical.cmp(&other.canonical)
136    }
137}
138
139/// By canonical name, consistent with `Eq`, `Hash` and `Ord`, so a map keyed by
140/// community name can be indexed with a `&str`
141impl std::borrow::Borrow<str> for CommunityName {
142    fn borrow(&self) -> &str {
143        &self.canonical
144    }
145}
146
147/// Against the canonical name: `name == "meta-governance"` holds for a name
148/// given as `meta/governance`
149impl PartialEq<str> for CommunityName {
150    fn eq(&self, other: &str) -> bool {
151        self.canonical == other
152    }
153}
154
155impl PartialEq<&str> for CommunityName {
156    fn eq(&self, other: &&str) -> bool {
157        self.canonical == *other
158    }
159}
160
161impl fmt::Debug for CommunityName {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        if self.is_canonical() {
164            write!(f, "CommunityName({:?})", self.canonical)
165        } else {
166            write!(f, "CommunityName({:?} => {:?})", self.given, self.canonical)
167        }
168    }
169}
170
171/// The name as given: what was asked for, in messages about it
172impl fmt::Display for CommunityName {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(&self.given)
175    }
176}
177
178impl std::str::FromStr for CommunityName {
179    type Err = CommunityNameError;
180
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        Self::new(s)
183    }
184}
185
186impl TryFrom<String> for CommunityName {
187    type Error = CommunityNameError;
188
189    fn try_from(s: String) -> Result<Self, Self::Error> {
190        Self::new(s)
191    }
192}
193
194impl TryFrom<&str> for CommunityName {
195    type Error = CommunityNameError;
196
197    fn try_from(s: &str) -> Result<Self, Self::Error> {
198        Self::new(s)
199    }
200}
201
202impl serde::Serialize for CommunityName {
203    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
204        s.serialize_str(&self.given)
205    }
206}
207
208impl<'de> serde::Deserialize<'de> for CommunityName {
209    fn deserialize<D: serde::Deserializer<'de>>(
210        d: D,
211    ) -> Result<Self, D::Error> {
212        let given = Cow::<'de, str>::deserialize(d)?;
213        Self::new(given.into_owned()).map_err(serde::de::Error::custom)
214    }
215}
216
217// A plain string, inline: never a `$ref` in a tool schema (CLAUDE.md, "Never
218// ship a `$ref` schema"), and no `pattern` (strict schemas must not carry one,
219// and the name is parsed leniently anyway).
220#[cfg(feature = "schemars")]
221impl schemars::JsonSchema for CommunityName {
222    fn inline_schema() -> bool {
223        true
224    }
225
226    fn schema_name() -> Cow<'static, str> {
227        Cow::Borrowed("CommunityName")
228    }
229
230    fn schema_id() -> Cow<'static, str> {
231        Cow::Borrowed(concat!(module_path!(), "::CommunityName"))
232    }
233
234    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
235        schemars::json_schema!({ "type": "string" })
236    }
237}
238
239// The database holds canonical names only: bind the canonical form, and read
240// a stored one back as given canonically.
241#[cfg(feature = "sqlx")]
242mod sqlx_impls {
243    use super::CommunityName;
244    use sqlx::encode::IsNull;
245    use sqlx::error::BoxDynError;
246    use sqlx::postgres::{
247        PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef, Postgres,
248    };
249    use sqlx::{Decode, Encode, Type};
250
251    impl Type<Postgres> for CommunityName {
252        fn type_info() -> PgTypeInfo {
253            <String as Type<Postgres>>::type_info()
254        }
255
256        fn compatible(ty: &PgTypeInfo) -> bool {
257            <String as Type<Postgres>>::compatible(ty)
258        }
259    }
260
261    impl PgHasArrayType for CommunityName {
262        fn array_type_info() -> PgTypeInfo {
263            <String as PgHasArrayType>::array_type_info()
264        }
265    }
266
267    impl Encode<'_, Postgres> for CommunityName {
268        fn encode_by_ref(
269            &self,
270            buf: &mut PgArgumentBuffer,
271        ) -> Result<IsNull, BoxDynError> {
272            <&str as Encode<Postgres>>::encode_by_ref(&self.canonical(), buf)
273        }
274    }
275
276    impl<'r> Decode<'r, Postgres> for CommunityName {
277        fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
278            let stored = <String as Decode<Postgres>>::decode(value)?;
279            Ok(CommunityName::new(stored)?.into_canonical())
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn name(s: &str) -> CommunityName {
289        CommunityName::new(s).unwrap()
290    }
291
292    #[test]
293    fn loose_spellings_canonicalize() {
294        for (given, canonical) in [
295            ("meta-governance", "meta-governance"),
296            ("meta/governance", "meta-governance"),
297            ("Meta Governance", "meta-governance"),
298            ("  meta_governance. ", "meta-governance"),
299            ("meta\u{2011}governance", "meta-governance"),
300            ("#philosophy", "philosophy"),
301            ("AGI-ASI", "agi-asi"),
302            ("meta//governance", "meta-governance"),
303            ("-tech-", "tech"),
304        ] {
305            assert_eq!(name(given).canonical(), canonical, "{given:?}");
306        }
307    }
308
309    #[test]
310    fn nothing_left_is_refused() {
311        for given in ["", "   ", "#", "--", "/"] {
312            assert!(CommunityName::new(given).is_err(), "{given:?}");
313        }
314    }
315
316    /// The round trip a signature depends on: what was given is what
317    /// serializes, whatever its spelling.
318    #[test]
319    fn serde_round_trips_the_name_as_given() {
320        for given in ["meta/governance", "Meta Governance", "philosophy"] {
321            let json = serde_json::to_string(&name(given)).unwrap();
322            assert_eq!(json, serde_json::to_string(given).unwrap());
323            let back: CommunityName = serde_json::from_str(&json).unwrap();
324            assert_eq!(back.as_given(), given);
325            assert_eq!(serde_json::to_string(&back).unwrap(), json);
326        }
327    }
328
329    #[test]
330    fn equality_and_order_are_by_canonical_name() {
331        assert_eq!(name("meta/governance"), name("meta-governance"));
332        let set: std::collections::BTreeSet<_> =
333            [name("Tech"), name("tech"), name("art")]
334                .into_iter()
335                .collect();
336        assert_eq!(set.len(), 2);
337        assert_eq!(set.first().unwrap().canonical(), "art");
338    }
339
340    #[test]
341    fn into_canonical_gives_the_canonical_spelling() {
342        let n = name("Meta/Governance").into_canonical();
343        assert!(n.is_canonical());
344        assert_eq!(n.as_given(), "meta-governance");
345        assert!(!name("Meta/Governance").is_canonical());
346    }
347
348    #[cfg(feature = "schemars")]
349    #[test]
350    fn schema_is_an_inline_string() {
351        let schema = schemars::schema_for!(CommunityName);
352        let v = serde_json::to_value(&schema).unwrap();
353        assert_eq!(v["type"], "string");
354        assert!(v.get("pattern").is_none());
355        assert!(v.get("$ref").is_none() && v.get("$defs").is_none());
356    }
357}