1use std::borrow::Cow;
28use std::cmp::Ordering;
29use std::fmt;
30use std::hash::{Hash, Hasher};
31
32#[derive(Clone)]
35pub struct CommunityName {
36 given: String,
37 canonical: String,
38}
39
40#[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 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 pub fn as_given(&self) -> &str {
64 &self.given
65 }
66
67 pub fn canonical(&self) -> &str {
69 &self.canonical
70 }
71
72 pub fn is_canonical(&self) -> bool {
74 self.given == self.canonical
75 }
76
77 pub fn into_canonical(self) -> Self {
79 Self {
80 given: self.canonical.clone(),
81 canonical: self.canonical,
82 }
83 }
84}
85
86fn 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
139impl std::borrow::Borrow<str> for CommunityName {
142 fn borrow(&self) -> &str {
143 &self.canonical
144 }
145}
146
147impl 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
171impl 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#[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#[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 #[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}