Skip to main content

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}