Skip to main content

known_types_github/
handle.rs

1// This is free and unencumbered software released into the public domain.
2
3#[cfg(not(feature = "alloc"))]
4compile_error!("this module requires the 'alloc' feature");
5
6use alloc::string::String;
7use core::str::FromStr;
8use derive_more::{AsRef, Display};
9pub use known_types::handle::ParseHandleError;
10use known_types::handle::{validate_ascii, validate_length};
11
12/// A GitHub handle (aka username).
13///
14/// Contains 1–39 ASCII letters, digits, or single interior hyphens. Enterprise
15/// Managed Users may additionally have an `_` followed by a 3–8 character
16/// alphanumeric enterprise shortcode. GitHub's current and legacy-compatible
17/// ceiling is 39 characters (30 for some data-residency managed users).
18/// Parsing drops one optional `@` and preserves spelling; equality, ordering,
19/// and hashing ignore ASCII case.
20///
21/// See <https://docs.github.com/en/enterprise-cloud@latest/admin/managing-iam/iam-configuration-reference/username-considerations-for-external-authentication>.
22///
23/// ```
24/// use known_types_github::GithubHandle;
25///
26/// let managed: GithubHandle = "@The-Octocat_octo".parse()?;
27/// assert_eq!(managed.as_str(), "The-Octocat_octo");
28/// assert!("octo--cat".parse::<GithubHandle>().is_err());
29/// # Ok::<(), known_types_github::ParseHandleError>(())
30/// ```
31///
32/// See the [shared handle contract](known_types::handle) for conversion and
33/// integration behavior. With `async-graphql`, the scalar is named `GithubHandle`.
34#[derive(AsRef, Clone, Debug, Display, Eq)]
35pub struct GithubHandle(String);
36
37known_types::impl_handle!(GithubHandle, 1, 39, "GithubHandle");
38known_types::impl_handle_comparison!(GithubHandle);
39
40impl GithubHandle {
41    fn comparison_key(&self) -> unicase::Ascii<&str> {
42        unicase::Ascii::new(self.as_str())
43    }
44}
45
46impl FromStr for GithubHandle {
47    type Err = ParseHandleError;
48
49    fn from_str(input: &str) -> Result<Self, Self::Err> {
50        let input = input.strip_prefix('@').unwrap_or(input);
51        validate_length(input, Self::MIN_LENGTH, Self::MAX_LENGTH)?;
52        let name = match input.split_once('_') {
53            Some((name, shortcode)) => {
54                validate_length(shortcode, 3, 8)?;
55                validate_ascii(shortcode, "")?;
56                name
57            }
58            None => input,
59        };
60        validate_ascii(name, "-")?;
61        if name.is_empty() || name.starts_with('-') || name.ends_with('-') || name.contains("--") {
62            return Err(ParseHandleError::InvalidFormat);
63        }
64        Ok(Self(input.into()))
65    }
66}
67
68#[test]
69fn test_github_handle_syntax() {
70    for (input, stored) in [
71        ("@Octo-Cat", "Octo-Cat"),
72        ("a", "a"),
73        ("123", "123"),
74        ("@The-Octocat_octo", "The-Octocat_octo"),
75        ("octo_admin", "octo_admin"),
76    ] {
77        assert_eq!(
78            input
79                .parse::<GithubHandle>()
80                .expect("valid handle")
81                .as_str(),
82            stored
83        );
84    }
85    for input in [
86        "@",
87        "@@Octocat",
88        "-octocat",
89        "octocat-",
90        "octo--cat",
91        "octo.cat",
92        "octo cat",
93        "octöcat",
94        "_octo",
95        "octo_",
96        "octo_ab",
97        "octo_abcdefghi",
98        "octo_abc_def",
99        "octo_ab-c",
100        "octocat\n",
101    ] {
102        assert!(input.parse::<GithubHandle>().is_err(), "accepted {input:?}");
103    }
104}