OXYGENHUB
◆ v0.9.2 oxygenhub / crates / hub / src / social.rs
3.5 KiB · Rust · last changed Accounts elsewhere on a profile: Bluesky, Mastodon, GitHub 1 month ago source blame history raw
1
//! Accounts elsewhere on a profile (hub spec §6, oxygenhub#105): a
2
//! Bluesky handle, a Mastodon address and a GitHub username, checked and
3
//! normalised on the way in, and turned into links on the way out.
4
5
/// A Bluesky handle: a domain such as `andy.bsky.social`, with or
6
/// without a leading `@`. `None` when it is not one.
7
pub fn bluesky(input: &str) -> Option<String> {
8
    let h = input.trim().trim_start_matches('@').trim_end_matches('/');
9
    let h = h.strip_prefix("https://bsky.app/profile/").unwrap_or(h).to_ascii_lowercase();
10
    (!h.is_empty() && h.len() <= 253 && h.contains('.') && h.split('.').all(label_ok)).then_some(h)
11
}
12
13
/// A Mastodon address as `user@instance`, from `@user@instance`,
14
/// `user@instance` or `https://instance/@user`.
15
pub fn mastodon(input: &str) -> Option<String> {
16
    let s = input.trim().trim_end_matches('/');
17
    let (user, instance) = if let Some(rest) = s.strip_prefix("https://") {
18
        let (instance, user) = rest.split_once("/@")?;
19
        (user, instance)
20
    } else {
21
        s.trim_start_matches('@').split_once('@')?
22
    };
23
    let user_ok = !user.is_empty() && user.len() <= 64 && user.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.');
24
    let instance = instance.to_ascii_lowercase();
25
    let instance_ok = instance.contains('.') && instance.len() <= 253 && instance.split('.').all(label_ok);
26
    (user_ok && instance_ok).then(|| format!("{user}@{instance}"))
27
}
28
29
/// A GitHub username: 1 to 39 letters, digits and dashes, from the name
30
/// or a profile URL.
31
pub fn github(input: &str) -> Option<String> {
32
    let u = input.trim().trim_start_matches('@').trim_end_matches('/');
33
    let u = u.strip_prefix("https://github.com/").unwrap_or(u);
34
    let ok = !u.is_empty()
35
        && u.len() <= 39
36
        && u.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
37
        && !u.starts_with('-')
38
        && !u.ends_with('-');
39
    ok.then(|| u.to_string())
40
}
41
42
fn label_ok(l: &str) -> bool {
43
    !l.is_empty() && l.len() <= 63 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') && !l.starts_with('-') && !l.ends_with('-')
44
}
45
46
pub fn bluesky_url(handle: &str) -> String {
47
    format!("https://bsky.app/profile/{handle}")
48
}
49
50
/// `user@instance` → `https://instance/@user`.
51
pub fn mastodon_url(address: &str) -> String {
52
    match address.split_once('@') {
53
        Some((user, instance)) => format!("https://{instance}/@{user}"),
54
        None => String::new(),
55
    }
56
}
57
58
pub fn github_url(user: &str) -> String {
59
    format!("https://github.com/{user}")
60
}
61
62
#[cfg(test)]
63
mod tests {
64
    use super::*;
65
66
    #[test]
67
    fn handles_are_checked_and_normalised() {
68
        assert_eq!(bluesky("@Andy.bsky.social").as_deref(), Some("andy.bsky.social"));
69
        assert_eq!(bluesky("https://bsky.app/profile/andy.example.org/").as_deref(), Some("andy.example.org"));
70
        assert_eq!(bluesky("andy"), None, "a handle is a domain");
71
        assert_eq!(bluesky("an dy.bsky.social"), None);
72
        assert_eq!(mastodon("@[email protected]").as_deref(), Some("[email protected]"));
73
        assert_eq!(mastodon("https://Fosstodon.org/@andy").as_deref(), Some("[email protected]"));
74
        assert_eq!(mastodon("andy"), None);
75
        assert_eq!(mastodon("andy@nodot"), None);
76
        assert_eq!(github("https://github.com/andymccall/").as_deref(), Some("andymccall"));
77
        assert_eq!(github("@andy-mccall").as_deref(), Some("andy-mccall"));
78
        assert_eq!(github("-andy"), None);
79
        assert_eq!(github("andy mccall"), None);
80
        assert_eq!(mastodon_url("[email protected]"), "https://fosstodon.org/@andy");
81
    }
82
}