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}