ddapi_rs/util/encoding.rs
1use std::borrow::Cow;
2use std::fmt::Write;
3
4const NON_ASCII_CHARACTER_THRESHOLD: u32 = 128;
5
6fn is_slugify2_symbol(c: char) -> bool {
7 matches!(
8 c,
9 '\t' | ' '
10 | '!'
11 | '"'
12 | '#'
13 | '$'
14 | '%'
15 | '&'
16 | '\''
17 | '('
18 | ')'
19 | '*'
20 | '-'
21 | '/'
22 | '<'
23 | '='
24 | '>'
25 | '?'
26 | '@'
27 | '['
28 | '\\'
29 | ']'
30 | '^'
31 | '_'
32 | '`'
33 | '{'
34 | '|'
35 | '}'
36 | ','
37 | '.'
38 | ':'
39 )
40}
41
42/// Converts a nickname to a URL-safe slug format for API requests
43///
44/// This function handles special characters and non-ASCII characters in nicknames
45/// by encoding them into a format that can be safely used in URLs. Characters that
46/// are not ASCII or are one of the slugify2 separator symbols are converted to their Unicode code points
47/// surrounded by hyphens.
48///
49/// # Arguments
50///
51/// * `nickname` - The player nickname to slugify
52///
53/// # Returns
54///
55/// Returns `Cow<'_, str>` - Borrowed if no conversion needed, Owned if conversion occurred
56///
57/// # Examples
58///
59/// ```
60/// use ddapi_rs::prelude::slugify2;
61///
62/// // ASCII-only nicknames without special symbols are returned as-is
63/// assert_eq!(slugify2("Player1"), "Player1");
64///
65/// // Special symbols and non-ASCII characters are encoded
66/// assert_eq!(slugify2("Player@"), "Player-64-");
67/// assert_eq!(slugify2("玩家"), "-29609--23478-");
68///
69/// // Mixed characters
70/// assert_eq!(slugify2("Test_Player"), "Test-95-Player");
71/// ```
72#[must_use]
73pub fn slugify2(nickname: &str) -> Cow<'_, str> {
74 let needs_processing = nickname
75 .chars()
76 .any(|c| is_slugify2_symbol(c) || (c as u32) >= NON_ASCII_CHARACTER_THRESHOLD);
77
78 if !needs_processing {
79 return Cow::Borrowed(nickname);
80 }
81
82 let mut result = String::with_capacity(nickname.len() * 4);
83
84 for c in nickname.chars() {
85 if is_slugify2_symbol(c) || (c as u32) >= NON_ASCII_CHARACTER_THRESHOLD {
86 let _ = write!(&mut result, "-{}-", c as u32);
87 } else {
88 result.push(c);
89 }
90 }
91
92 Cow::Owned(result)
93}
94
95/// Encodes a nickname for safe use in URLs
96///
97/// This function ensures that nicknames containing special characters, spaces,
98/// or non-ASCII characters are properly URL-encoded. ASCII nicknames without
99/// control characters are returned as-is for better performance.
100///
101/// # Arguments
102///
103/// * `nickname` - The player nickname to URL-encode
104///
105/// # Returns
106///
107/// Returns `Cow<'_, str>` -
108/// - `Cow::Borrowed` if the nickname is already URL-safe (ASCII without control characters)
109/// - `Cow::Owned` with URL-encoded string if encoding is required
110///
111/// # Examples
112///
113/// ```
114/// use ddapi_rs::prelude::encode;
115///
116/// // Safe ASCII nicknames are returned without changes
117/// assert_eq!(encode("Player1"), "Player1");
118/// assert_eq!(encode("abc_XYZ"), "abc_XYZ");
119///
120/// // Characters requiring encoding are properly handled
121/// assert_eq!(encode("Player Server"), "Player%20Server");
122/// assert_eq!(encode("Player@Server"), "Player%40Server");
123/// assert_eq!(encode("玩家"), "%E7%8E%A9%E5%AE%B6");
124/// assert_eq!(encode("emoji🎮"), "emoji%F0%9F%8E%AE");
125///
126/// // Special cases
127/// assert_eq!(encode(""), "");
128/// assert_eq!(encode("a b"), "a%20b");
129/// ```
130#[must_use]
131pub fn encode(nickname: &str) -> Cow<'_, str> {
132 // RFC 3986 unreserved characters: ALPHA / DIGIT / "-" / "." / "_" / "~"
133 // If already unreserved-only, return a borrow.
134 if nickname
135 .bytes()
136 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~'))
137 {
138 return Cow::Borrowed(nickname);
139 }
140
141 // Percent-encode UTF-8 bytes. This avoids pulling in a dependency for a tiny operation.
142 let mut out = String::with_capacity(nickname.len() * 3);
143 for &b in nickname.as_bytes() {
144 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
145 out.push(b as char);
146 } else {
147 out.push('%');
148 out.push(hex_upper(b >> 4));
149 out.push(hex_upper(b & 0x0f));
150 }
151 }
152 Cow::Owned(out)
153}
154
155#[inline]
156fn hex_upper(n: u8) -> char {
157 debug_assert!(n < 16);
158 match n {
159 0..=9 => (b'0' + n) as char,
160 10..=15 => (b'A' + (n - 10)) as char,
161 _ => unreachable!(),
162 }
163}