Skip to main content

known_types_x/
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/// An X handle (aka username).
13///
14/// Contains 1–20 ASCII letters, digits, or underscores. `MAX_LENGTH` is the
15/// compatibility ceiling: current X usernames are limited to 15 characters,
16/// but legacy accounts include handles longer than that, such as
17/// `@richardrushfield` (16 characters). Parsing drops one optional `@`.
18/// Spelling is preserved, while equality, ordering, and hashing ignore ASCII
19/// case.
20///
21/// # Legacy compatibility
22///
23/// [X's current help page] specifies 15 characters. Twitter's archived [2010]
24/// and [2016] help pages also describe that limit, but the 2010 page explicitly
25/// preserves longer "early bird" usernames without publishing their hard upper
26/// bound. This type uses the historically reported 20-character compatibility
27/// ceiling and covers the known 16-character [`richardrushfield`] account.
28/// Longer inputs are rejected, never truncated.
29///
30/// ```
31/// use known_types_x::XHandle;
32///
33/// let legacy: XHandle = "@richardrushfield".parse()?;
34/// assert_eq!(legacy.as_str(), "richardrushfield");
35/// assert!(legacy.as_str().len() > XHandle::CURRENT_MAX_LENGTH);
36/// # Ok::<(), known_types_x::ParseHandleError>(())
37/// ```
38///
39/// See the [shared handle contract](known_types::handle) and
40/// [crate-level integration recipes](crate). With `async-graphql`, the scalar
41/// is named `XHandle`.
42///
43/// [X's current help page]: https://help.x.com/en/managing-your-account/x-username-rules
44/// [2010]: https://web.archive.org/web/20100718125730/http://support.twitter.com/entries/14609-how-to-change-your-username
45/// [2016]: https://web.archive.org/web/20161203051256/https://support.twitter.com/articles/14609
46/// [`richardrushfield`]: https://x.com/richardrushfield
47#[derive(AsRef, Clone, Debug, Display, Eq)]
48pub struct XHandle(String);
49
50known_types::impl_handle!(XHandle, 1, 20, "XHandle");
51known_types::impl_handle_comparison!(XHandle);
52
53impl XHandle {
54    /// Current X username maximum for new registrations and edits.
55    pub const CURRENT_MAX_LENGTH: usize = 15;
56    /// Historical compatibility maximum used by legacy Twitter accounts.
57    pub const HISTORICAL_MAX_LENGTH: usize = 20;
58
59    fn comparison_key(&self) -> unicase::Ascii<&str> {
60        unicase::Ascii::new(self.as_str())
61    }
62}
63
64impl FromStr for XHandle {
65    type Err = ParseHandleError;
66
67    fn from_str(input: &str) -> Result<Self, Self::Err> {
68        let input = input.strip_prefix('@').unwrap_or(input);
69        validate_length(input, Self::MIN_LENGTH, Self::MAX_LENGTH)?;
70        validate_ascii(input, "_")?;
71        Ok(Self(input.into()))
72    }
73}
74
75#[cfg(feature = "libsql")]
76impl Into<libsql::Value> for XHandle {
77    fn into(self) -> libsql::Value {
78        libsql::Value::Text(self.0)
79    }
80}
81
82#[cfg(feature = "libsql")]
83impl Into<libsql::Value> for &XHandle {
84    fn into(self) -> libsql::Value {
85        libsql::Value::Text(self.0.clone())
86    }
87}
88
89#[test]
90fn test_x_handle_syntax() {
91    for (input, stored) in [
92        ("@PlayItAgainSam", "PlayItAgainSam"),
93        ("@a", "a"),
94        ("_", "_"),
95        ("123", "123"),
96        ("@_Some_User_", "_Some_User_"),
97        ("abcdefghijklmno", "abcdefghijklmno"),
98        ("@richardrushfield", "richardrushfield"),
99        ("abcdefghijklmnopqrst", "abcdefghijklmnopqrst"),
100    ] {
101        assert_eq!(
102            input.parse::<XHandle>().expect("valid handle").as_str(),
103            stored
104        );
105    }
106    for input in [
107        "@",
108        "@@alice",
109        "alice@",
110        " alice",
111        "alice ",
112        "a-b",
113        "a.b",
114        "a/b",
115        "a\0b",
116        "álîce",
117        "abcdefghijklmnopqrstu",
118    ] {
119        assert!(input.parse::<XHandle>().is_err(), "accepted {input:?}");
120    }
121}