//! Accounts elsewhere on a profile (hub spec §6, oxygenhub#105): a //! Bluesky handle, a Mastodon address and a GitHub username, checked and //! normalised on the way in, and turned into links on the way out. /// A Bluesky handle: a domain such as `andy.bsky.social`, with or /// without a leading `@`. `None` when it is not one. pub fn bluesky(input: &str) -> Option { let h = input.trim().trim_start_matches('@').trim_end_matches('/'); let h = h.strip_prefix("https://bsky.app/profile/").unwrap_or(h).to_ascii_lowercase(); (!h.is_empty() && h.len() <= 253 && h.contains('.') && h.split('.').all(label_ok)).then_some(h) } /// A Mastodon address as `user@instance`, from `@user@instance`, /// `user@instance` or `https://instance/@user`. pub fn mastodon(input: &str) -> Option { let s = input.trim().trim_end_matches('/'); let (user, instance) = if let Some(rest) = s.strip_prefix("https://") { let (instance, user) = rest.split_once("/@")?; (user, instance) } else { s.trim_start_matches('@').split_once('@')? }; let user_ok = !user.is_empty() && user.len() <= 64 && user.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.'); let instance = instance.to_ascii_lowercase(); let instance_ok = instance.contains('.') && instance.len() <= 253 && instance.split('.').all(label_ok); (user_ok && instance_ok).then(|| format!("{user}@{instance}")) } /// A GitHub username: 1 to 39 letters, digits and dashes, from the name /// or a profile URL. pub fn github(input: &str) -> Option { let u = input.trim().trim_start_matches('@').trim_end_matches('/'); let u = u.strip_prefix("https://github.com/").unwrap_or(u); let ok = !u.is_empty() && u.len() <= 39 && u.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') && !u.starts_with('-') && !u.ends_with('-'); ok.then(|| u.to_string()) } fn label_ok(l: &str) -> bool { !l.is_empty() && l.len() <= 63 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') && !l.starts_with('-') && !l.ends_with('-') } pub fn bluesky_url(handle: &str) -> String { format!("https://bsky.app/profile/{handle}") } /// `user@instance` → `https://instance/@user`. pub fn mastodon_url(address: &str) -> String { match address.split_once('@') { Some((user, instance)) => format!("https://{instance}/@{user}"), None => String::new(), } } pub fn github_url(user: &str) -> String { format!("https://github.com/{user}") } #[cfg(test)] mod tests { use super::*; #[test] fn handles_are_checked_and_normalised() { assert_eq!(bluesky("@Andy.bsky.social").as_deref(), Some("andy.bsky.social")); assert_eq!(bluesky("https://bsky.app/profile/andy.example.org/").as_deref(), Some("andy.example.org")); assert_eq!(bluesky("andy"), None, "a handle is a domain"); assert_eq!(bluesky("an dy.bsky.social"), None); assert_eq!(mastodon("@andy@mastodon.social").as_deref(), Some("andy@mastodon.social")); assert_eq!(mastodon("https://Fosstodon.org/@andy").as_deref(), Some("andy@fosstodon.org")); assert_eq!(mastodon("andy"), None); assert_eq!(mastodon("andy@nodot"), None); assert_eq!(github("https://github.com/andymccall/").as_deref(), Some("andymccall")); assert_eq!(github("@andy-mccall").as_deref(), Some("andy-mccall")); assert_eq!(github("-andy"), None); assert_eq!(github("andy mccall"), None); assert_eq!(mastodon_url("andy@fosstodon.org"), "https://fosstodon.org/@andy"); } }