Skip to main content

steam_user/services/
trade.rs

1//! Trade service for Steam Community.
2
3use std::sync::OnceLock;
4
5use chrono::{FixedOffset, TimeZone};
6use regex::Regex;
7use scraper::{Html, Selector};
8use serde_json::json;
9use steamid::SteamID;
10
11use crate::{
12    client::SteamUser,
13    endpoint::steam_endpoint,
14    error::SteamUserError,
15    types::{ParsedTradeURL, TradeOffer, TradeOfferAsset, TradeOfferItem, TradeOfferItems, TradeOfferPartner, TradeOfferResult, TradeOfferStatus, TradeOfferSummary, TradeOffersResponse},
16};
17
18// Selectors used by `get_trade_offer` are static — hoisted to module-level
19// `OnceLock<Selector>` so they compile exactly once instead of per trade
20// offer (the page can list dozens at a time).
21static SEL_TRADE_URL: OnceLock<Selector> = OnceLock::new();
22fn sel_trade_url() -> &'static Selector {
23    SEL_TRADE_URL.get_or_init(|| Selector::parse("#trade_offer_access_url").expect("valid CSS selector"))
24}
25
26static SEL_SUBPAGE: OnceLock<Selector> = OnceLock::new();
27fn sel_subpage() -> &'static Selector {
28    SEL_SUBPAGE.get_or_init(|| Selector::parse(".profile_subpage_selector > a").expect("valid CSS selector"))
29}
30
31static SEL_ESCROW: OnceLock<Selector> = OnceLock::new();
32fn sel_escrow() -> &'static Selector {
33    SEL_ESCROW.get_or_init(|| Selector::parse(".trade_offers_escrow_explanation > .title").expect("valid CSS selector"))
34}
35
36static SEL_TRADEOFFER: OnceLock<Selector> = OnceLock::new();
37fn sel_tradeoffer() -> &'static Selector {
38    SEL_TRADEOFFER.get_or_init(|| Selector::parse(".profile_leftcol > .tradeoffer").expect("valid CSS selector"))
39}
40
41static SEL_PLAYER_AVATAR: OnceLock<Selector> = OnceLock::new();
42fn sel_player_avatar() -> &'static Selector {
43    SEL_PLAYER_AVATAR.get_or_init(|| Selector::parse(".playerAvatar").expect("valid CSS selector"))
44}
45
46static SEL_IMG: OnceLock<Selector> = OnceLock::new();
47fn sel_img() -> &'static Selector {
48    SEL_IMG.get_or_init(|| Selector::parse("img").expect("valid CSS selector"))
49}
50
51static SEL_A: OnceLock<Selector> = OnceLock::new();
52fn sel_a() -> &'static Selector {
53    SEL_A.get_or_init(|| Selector::parse("a").expect("valid CSS selector"))
54}
55
56static SEL_TRADEOFFER_HEADER: OnceLock<Selector> = OnceLock::new();
57fn sel_tradeoffer_header() -> &'static Selector {
58    SEL_TRADEOFFER_HEADER.get_or_init(|| Selector::parse(".tradeoffer_header").expect("valid CSS selector"))
59}
60
61static SEL_ITEMS_CTN: OnceLock<Selector> = OnceLock::new();
62fn sel_items_ctn() -> &'static Selector {
63    SEL_ITEMS_CTN.get_or_init(|| Selector::parse(".tradeoffer_items_ctn").expect("valid CSS selector"))
64}
65
66static SEL_PRIMARY_ITEMS: OnceLock<Selector> = OnceLock::new();
67fn sel_primary_items() -> &'static Selector {
68    SEL_PRIMARY_ITEMS.get_or_init(|| Selector::parse(".tradeoffer_items_ctn .tradeoffer_items.primary").expect("valid CSS selector"))
69}
70
71static SEL_SECONDARY_ITEMS: OnceLock<Selector> = OnceLock::new();
72fn sel_secondary_items() -> &'static Selector {
73    SEL_SECONDARY_ITEMS.get_or_init(|| Selector::parse(".tradeoffer_items_ctn .tradeoffer_items.secondary").expect("valid CSS selector"))
74}
75
76static SEL_AVATAR_LINK: OnceLock<Selector> = OnceLock::new();
77fn sel_avatar_link() -> &'static Selector {
78    SEL_AVATAR_LINK.get_or_init(|| Selector::parse("a.tradeoffer_avatar").expect("valid CSS selector"))
79}
80
81static SEL_TRADE_ITEM: OnceLock<Selector> = OnceLock::new();
82fn sel_trade_item() -> &'static Selector {
83    SEL_TRADE_ITEM.get_or_init(|| Selector::parse(".tradeoffer_item_list > .trade_item").expect("valid CSS selector"))
84}
85
86static SEL_ITEMS_BANNER: OnceLock<Selector> = OnceLock::new();
87fn sel_items_banner() -> &'static Selector {
88    SEL_ITEMS_BANNER.get_or_init(|| Selector::parse(".tradeoffer_items_banner").expect("valid CSS selector"))
89}
90
91static RE_HTML_TAGS: OnceLock<Regex> = OnceLock::new();
92fn re_html_tags() -> &'static Regex {
93    RE_HTML_TAGS.get_or_init(|| Regex::new(r"<[^>]+>").expect("valid regex"))
94}
95
96impl SteamUser {
97    /// Retrieves the authenticated user's unique trade offer URL.
98    ///
99    /// This URL allows other users to send you trade offers without being on
100    /// your friends list. The URL is scraped from the user's trade privacy
101    /// settings page.
102    #[steam_endpoint(GET, host = Community, path = "/my/tradeoffers/privacy", kind = Read)]
103    pub async fn get_trade_url(&self) -> Result<Option<String>, SteamUserError> {
104        let response = self.get_path("/my/tradeoffers/privacy").send().await?.text().await?;
105
106        let html = Html::parse_document(&response);
107
108        Ok(html.select(sel_trade_url()).next().and_then(|el| el.value().attr("value").map(|v| v.to_string())))
109    }
110
111    /// Retrieves a list of active trade offers (incoming and outgoing) from the
112    /// trade offers page.
113    ///
114    /// Scrapes `https://steamcommunity.com/my/tradeoffers/` to extract offer IDs, status, and item summaries.
115    #[steam_endpoint(GET, host = Community, path = "/my/tradeoffers/", kind = Read)]
116    pub async fn get_trade_offer(&self) -> Result<TradeOffersResponse, SteamUserError> {
117        let response = self.get_with_manual_redirects("https://steamcommunity.com/my/tradeoffers/").await?;
118
119        let html = Html::parse_document(&response);
120
121        let mut incoming_offers = TradeOfferSummary { link: String::new(), count: 0 };
122        let mut sent_offers = TradeOfferSummary { link: String::new(), count: 0 };
123        let mut trade_hold_count = 0;
124
125        for el in html.select(sel_subpage()) {
126            let text = el.text().collect::<String>();
127            let link = el.value().attr("href").unwrap_or("").trim_end_matches('/').to_string();
128            let mut count = 0;
129            if let (Some(start), Some(end)) = (text.find('('), text.find(')')) {
130                count = text[start + 1..end].trim().parse().unwrap_or(0);
131            }
132
133            if link.ends_with("tradeoffers") {
134                incoming_offers = TradeOfferSummary { link, count };
135            } else if link.ends_with("tradeoffers/sent") {
136                sent_offers = TradeOfferSummary { link, count };
137            }
138        }
139
140        if let Some(el) = html.select(sel_escrow()).next() {
141            let text = el.text().collect::<String>().trim().to_string();
142            if text.ends_with("trade on hold") || text.ends_with("trades on hold") {
143                trade_hold_count = text.split(' ').next().and_then(|s| s.parse().ok()).unwrap_or(0);
144            }
145        }
146
147        let mut trade_offers = Vec::new();
148        for el in html.select(sel_tradeoffer()) {
149            let id_str = el.value().attr("id").unwrap_or("");
150            if !id_str.starts_with("tradeofferid_") {
151                continue;
152            }
153            let tradeofferid = id_str["tradeofferid_".len()..].parse().unwrap_or(0);
154            if tradeofferid == 0 {
155                continue;
156            }
157
158            let html_content = el.html();
159            let partner_data = if let Some(start) = html_content.find("ReportTradeScam(") {
160                let rest = &html_content[start + "ReportTradeScam(".len()..];
161                if let Some(end) = rest.find(");") {
162                    let part = rest[..end].trim();
163                    let parts: Vec<String> = part
164                        .split(',')
165                        .map(|s| {
166                            // Decode HTML entities first, then trim quotes/whitespace
167                            let decoded = s.trim().replace("&quot;", "\"").replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&#39;", "'");
168                            decoded.trim_matches(|c: char| c == '\'' || c == '"').trim().to_string()
169                        })
170                        .collect();
171                    if parts.len() >= 2 {
172                        // Steam embeds the partner SteamID64 as a decimal
173                        // string. Parse to SteamID at this boundary; drop the
174                        // offer if it doesn't parse so we never carry a
175                        // bogus ID forward.
176                        let steam_id = parts[0].parse::<SteamID>().ok();
177                        let name = decode_js_unicode_escapes(&parts[1]);
178                        Some((steam_id, name))
179                    } else {
180                        None
181                    }
182                } else {
183                    None
184                }
185            } else {
186                None
187            };
188
189            let (partner_steam_id, partner_name) = partner_data.unwrap_or_default();
190
191            let player_avatar = el.select(sel_player_avatar()).next();
192            let avatar_url = player_avatar.and_then(|a| a.select(sel_img()).next()).and_then(|i| i.value().attr("src")).unwrap_or("");
193            let avatar_hash = if let Some(pos) = avatar_url.rfind('/') {
194                let filename = &avatar_url[pos + 1..];
195                if let Some(dot_pos) = filename.find('.') {
196                    filename[..dot_pos].to_string()
197                } else {
198                    filename.to_string()
199                }
200            } else {
201                String::new()
202            };
203
204            let link = player_avatar.and_then(|a| a.select(sel_a()).next()).and_then(|l| l.value().attr("href")).unwrap_or("").to_string();
205            let header = el.select(sel_tradeoffer_header()).next().map(|h| h.text().collect::<String>().trim().to_string()).unwrap_or_default();
206            let active = el.select(sel_items_ctn()).next().map(|c| c.value().has_class("active", scraper::CaseSensitivity::AsciiCaseInsensitive)).unwrap_or(false);
207
208            let primary_items_ctn = el.select(sel_primary_items()).next();
209            let secondary_items_ctn = el.select(sel_secondary_items()).next();
210
211            let primary_steam_id = primary_items_ctn.and_then(|c| c.select(sel_avatar_link()).next()).and_then(|a| a.value().attr("data-miniprofile")).and_then(|m| m.parse::<u32>().ok()).map(SteamID::from_individual_account_id);
212
213            let secondary_steam_id = secondary_items_ctn.and_then(|c| c.select(sel_avatar_link()).next()).and_then(|a| a.value().attr("data-miniprofile")).and_then(|m| m.parse::<u32>().ok()).map(SteamID::from_individual_account_id);
214
215            let parse_items = |ctn: Option<scraper::ElementRef>| {
216                let mut items = Vec::new();
217                if let Some(ctn) = ctn {
218                    for item_el in ctn.select(sel_trade_item()) {
219                        let economy_item = item_el.value().attr("data-economy-item").unwrap_or("").to_string();
220                        let img = item_el.select(sel_img()).next().and_then(|i| i.value().attr("src")).unwrap_or("").to_string();
221                        // Extract high-res image from srcset (2x variant)
222                        let img_hi = item_el
223                            .select(sel_img())
224                            .next()
225                            .and_then(|i| i.value().attr("srcset"))
226                            .and_then(|srcset| {
227                                srcset.split(',').find_map(|entry| {
228                                    let entry = entry.trim();
229                                    if entry.ends_with("2x") {
230                                        Some(entry.trim_end_matches("2x").trim().to_string())
231                                    } else {
232                                        None
233                                    }
234                                })
235                            })
236                            .unwrap_or_default();
237                        let missing = item_el.value().has_class("missing", scraper::CaseSensitivity::AsciiCaseInsensitive);
238                        items.push(TradeOfferItem { economy_item, img, img_hi, missing });
239                    }
240                }
241                items
242            };
243
244            // Parse banner text and status
245            let banner_el = el.select(sel_items_banner()).next();
246            let banner_text = banner_el.map(|b| b.text().collect::<String>().trim().to_string()).unwrap_or_default();
247            let status = if active {
248                TradeOfferStatus::Active
249            } else if banner_el.map(|b| b.value().has_class("accepted", scraper::CaseSensitivity::AsciiCaseInsensitive)).unwrap_or(false) {
250                TradeOfferStatus::Accepted
251            } else if banner_text.contains("Unavailable") || banner_text.contains("unavailable") {
252                TradeOfferStatus::Unavailable
253            } else if banner_text.contains("Counter") || banner_text.contains("counter") {
254                TradeOfferStatus::Countered
255            } else {
256                TradeOfferStatus::Inactive
257            };
258
259            let banner_timestamp = parse_steam_banner_timestamp(&banner_text);
260
261            trade_offers.push(TradeOffer {
262                tradeofferid,
263                partner: TradeOfferPartner { steamid: partner_steam_id, name: partner_name, avatar_hash, link, header },
264                active,
265                primary: TradeOfferItems { steamid: primary_steam_id, items: parse_items(primary_items_ctn) },
266                secondary: TradeOfferItems { steamid: secondary_steam_id, items: parse_items(secondary_items_ctn) },
267                banner_text,
268                banner_timestamp,
269                status,
270            });
271        }
272
273        Ok(TradeOffersResponse { incoming_offers, sent_offers, trade_hold_count, trade_offers })
274    }
275
276    /// Sends a new trade offer to a partner using their trade URL.
277    ///
278    /// # Arguments
279    ///
280    /// * `trade_url` - The full trade offer URL of the partner.
281    /// * `my_assets` - A list of [`TradeOfferAsset`] from your inventory to
282    ///   give.
283    /// * `their_assets` - A list of [`TradeOfferAsset`] from their inventory to
284    ///   receive.
285    /// * `message` - An optional message to include with the trade offer.
286    #[steam_endpoint(POST, host = Community, path = "/tradeoffer/new/send", kind = Write)]
287    pub async fn send_trade_offer(&self, trade_url: &str, my_assets: Vec<TradeOfferAsset>, their_assets: Vec<TradeOfferAsset>, message: &str) -> Result<TradeOfferResult, SteamUserError> {
288        let parsed = self.parse_trade_url(trade_url).ok_or_else(|| SteamUserError::Other("Invalid trade URL".into()))?;
289
290        let json_tradeoffer = json!({
291            "newversion": true,
292            "version": 3,
293            "me": {
294                "assets": my_assets,
295                "currency": [],
296                "ready": false,
297            },
298            "them": {
299                "assets": their_assets,
300                "currency": [],
301                "ready": false,
302            }
303        });
304
305        let mut form = Vec::new();
306        form.push(("serverid", "1".to_string()));
307        form.push(("partner", parsed.steam_id.steam_id64().to_string()));
308        form.push(("tradeoffermessage", message.to_string()));
309        form.push(("json_tradeoffer", json_tradeoffer.to_string()));
310        form.push(("captcha", "".to_string()));
311        form.push(("trade_offer_create_params", json!({ "trade_offer_access_token": parsed.token }).to_string()));
312
313        let response = self.post_path("/tradeoffer/new/send").header(reqwest::header::REFERER, trade_url).form(&form).send().await?.text().await?;
314
315        // Check for strError first — Steam returns this on failure instead of
316        // tradeofferid
317        let raw: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
318        if let Some(str_error) = raw.get("strError").and_then(|v| v.as_str()) {
319            // Strip HTML tags for a clean error message
320            let clean = str_error.replace("<br>", "\n").replace("<br/>", "\n");
321            let clean = re_html_tags().replace_all(&clean, "").to_string();
322            return Err(SteamUserError::Other(clean.trim().to_string()));
323        }
324
325        let result: TradeOfferResult = serde_json::from_value(raw).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
326        Ok(result)
327    }
328
329    /// Accepts an incoming trade offer.
330    ///
331    /// # Arguments
332    ///
333    /// * `trade_offer_id` - The unique ID of the trade offer to accept.
334    /// * `partner_steam_id` - Optional Steam ID of the partner. If not
335    ///   provided, it will be scraped from the offer page.
336    #[steam_endpoint(POST, host = Community, path = "/tradeoffer/{trade_offer_id}/accept", kind = Write)]
337    pub async fn accept_trade_offer(&self, trade_offer_id: u64, partner_steam_id: Option<String>) -> Result<String, SteamUserError> {
338        let partner_id = if let Some(id) = partner_steam_id {
339            id
340        } else {
341            let page = self.get_path(format!("/tradeoffer/{}", trade_offer_id)).send().await?.text().await?;
342            if let Some(start) = page.find("var g_ulTradePartnerSteamID = '") {
343                let rest = &page[start + "var g_ulTradePartnerSteamID = '".len()..];
344                if let Some(end) = rest.find("';") {
345                    rest[..end].to_string()
346                } else {
347                    return Err(SteamUserError::MalformedResponse("Failed to extract partner Steam ID".into()));
348                }
349            } else {
350                return Err(SteamUserError::MalformedResponse("Failed to extract partner Steam ID".into()));
351            }
352        };
353
354        let form = vec![("serverid", "1".to_string()), ("tradeofferid", trade_offer_id.to_string()), ("partner", partner_id), ("captcha", String::new())];
355
356        let referer = format!("https://steamcommunity.com/tradeoffer/{}/", trade_offer_id);
357        let response = self.post_path(format!("/tradeoffer/{}/accept", trade_offer_id)).header(reqwest::header::REFERER, &referer).form(&form).send().await?.text().await?;
358
359        let data: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
360        if let Some(trade_id) = data.get("tradeid").and_then(|v| v.as_str()) {
361            Ok(trade_id.to_string())
362        } else {
363            Err(SteamUserError::Other(format!("Unexpected response: {}", response)))
364        }
365    }
366
367    /// Declines or cancels a Steam trade offer.
368    ///
369    /// # Arguments
370    ///
371    /// * `trade_offer_id` - The unique ID of the trade offer to decline.
372    #[steam_endpoint(POST, host = Community, path = "/tradeoffer/{trade_offer_id}/decline", kind = Write)]
373    pub async fn decline_trade_offer(&self, trade_offer_id: u64) -> Result<(), SteamUserError> {
374        let referer = format!("https://steamcommunity.com/tradeoffer/{}/", trade_offer_id);
375        let response = self.post_path(format!("/tradeoffer/{}/decline", trade_offer_id)).header(reqwest::header::REFERER, &referer).form(&([] as [(&str, &str); 0])).send().await?.text().await?;
376
377        let data: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
378        let success = data.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false);
379
380        if success || data.get("tradeofferid").is_some() || data.get("tradeoffer_id").is_some() {
381            Ok(())
382        } else {
383            Err(SteamUserError::Other(format!("Failed to decline trade offer: {}", response)))
384        }
385    }
386
387    /// Parses a Steam trade offer URL and extracts key partner information.
388    pub fn parse_trade_url(&self, trade_url: &str) -> Option<ParsedTradeURL> {
389        let url = url::Url::parse(trade_url).ok()?;
390        let partner = url.query_pairs().find(|(k, _)| k == "partner")?.1.to_string();
391        let token = url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| v.to_string());
392
393        let account_id = partner.parse::<u32>().ok()?;
394        let steam_id = SteamID::from_individual_account_id(account_id);
395
396        Some(ParsedTradeURL { account_id: partner, steam_id, token })
397    }
398}
399
400/// Parse a Steam banner timestamp string like "Trade Accepted 8 Mar, 2026 @
401/// 3:46am" and return a UTC Unix timestamp (seconds).
402///
403/// Steam renders these timestamps server-side using the account's configured
404/// timezone, which is typically US Pacific Time (UTC-7 PDT / UTC-8 PST).
405/// We assume UTC-7 (PDT) by default.
406fn parse_steam_banner_timestamp(banner_text: &str) -> Option<i64> {
407    // Look for the `@` separator — format is: "<prefix> <day> <Mon>, <year> @
408    // <hour>:<min><am/pm>"
409    let at_pos = banner_text.find('@');
410    if at_pos.is_none() {
411        tracing::debug!("[TradeOffer] No '@' in banner text: {:?}", banner_text);
412        return None;
413    }
414    let at_pos = at_pos?;
415    let time_part = banner_text[at_pos + 1..].trim(); // "5:20am" or "11:20pm"
416    let date_part = banner_text[..at_pos].trim(); // "Trade Accepted 15 Mar, 2026"
417
418    // Extract the date portion: find the first digit to get "<day> <Mon>, <year>"
419    let date_start = date_part.find(|c: char| c.is_ascii_digit())?;
420    let date_str = date_part[date_start..].trim(); // "15 Mar, 2026"
421
422    // Parse time: "5:20am" -> (hour, minute)
423    // Handle possible trailing text after am/pm (e.g., from extra banner content)
424    let time_token = time_part.split_whitespace().next().unwrap_or(time_part);
425    let am_pm = if time_token.ends_with("am") {
426        "AM"
427    } else if time_token.ends_with("pm") {
428        "PM"
429    } else {
430        tracing::debug!("[TradeOffer] Cannot determine AM/PM from time_token: {:?} (full time_part: {:?})", time_token, time_part);
431        return None;
432    };
433    let time_digits = time_token.trim_end_matches(|c: char| c.is_ascii_alphabetic());
434    let mut time_split = time_digits.split(':');
435    let mut hour: u32 = time_split.next()?.trim().parse().ok()?;
436    let minute: u32 = time_split.next()?.trim().parse().ok()?;
437
438    // Convert 12-hour to 24-hour
439    if am_pm == "AM" && hour == 12 {
440        hour = 0;
441    }
442    if am_pm == "PM" && hour != 12 {
443        hour += 12;
444    }
445
446    // Parse date: "15 Mar, 2026"
447    let date_str = date_str.replace(',', "");
448    let mut parts = date_str.split_whitespace();
449    let day: u32 = parts.next()?.parse().ok()?;
450    let month_str = parts.next()?;
451    let year: i32 = parts.next()?.parse().ok()?;
452
453    let month = match month_str {
454        "Jan" => 1,
455        "Feb" => 2,
456        "Mar" => 3,
457        "Apr" => 4,
458        "May" => 5,
459        "Jun" => 6,
460        "Jul" => 7,
461        "Aug" => 8,
462        "Sep" => 9,
463        "Oct" => 10,
464        "Nov" => 11,
465        "Dec" => 12,
466        _ => {
467            tracing::debug!("[TradeOffer] Unknown month: {:?}", month_str);
468            return None;
469        }
470    };
471
472    // Steam typically uses US Pacific Time (UTC-7 PDT)
473    let steam_offset = FixedOffset::west_opt(7 * 3600)?;
474    let naive = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, 0)?;
475    let dt = steam_offset.from_local_datetime(&naive).single()?;
476    let ts = dt.timestamp();
477    tracing::debug!("[TradeOffer] Parsed banner timestamp: {:?} -> UTC {}", banner_text, ts);
478    Some(ts)
479}
480
481/// Decode JavaScript unicode escape sequences (e.g., `\u01b0\u01a1ng` →
482/// `ương`).
483fn decode_js_unicode_escapes(s: &str) -> String {
484    let mut result = String::with_capacity(s.len());
485    let mut chars = s.chars().peekable();
486    while let Some(ch) = chars.next() {
487        if ch == '\\' && chars.peek() == Some(&'u') {
488            chars.next(); // consume 'u'
489            let hex: String = chars.by_ref().take(4).collect();
490            if hex.len() == 4 {
491                if let Ok(code) = u32::from_str_radix(&hex, 16) {
492                    // Handle surrogate pairs (\uD800-\uDFFF)
493                    if (0xD800..=0xDBFF).contains(&code) {
494                        // High surrogate — look for low surrogate
495                        let mut low_chars = chars.clone();
496                        if low_chars.next() == Some('\\') && low_chars.next() == Some('u') {
497                            let low_hex: String = low_chars.by_ref().take(4).collect();
498                            if let Ok(low_code) = u32::from_str_radix(&low_hex, 16) {
499                                if (0xDC00..=0xDFFF).contains(&low_code) {
500                                    let cp = 0x10000 + ((code - 0xD800) << 10) + (low_code - 0xDC00);
501                                    if let Some(c) = char::from_u32(cp) {
502                                        result.push(c);
503                                        // Advance the real iterator past the low surrogate
504                                        for _ in 0..6 {
505                                            chars.next();
506                                        }
507                                        continue;
508                                    }
509                                }
510                            }
511                        }
512                        // Fallback: emit replacement char if surrogate pair is broken
513                        result.push(char::REPLACEMENT_CHARACTER);
514                    } else if let Some(c) = char::from_u32(code) {
515                        result.push(c);
516                    } else {
517                        result.push(char::REPLACEMENT_CHARACTER);
518                    }
519                } else {
520                    result.push_str("\\u");
521                    result.push_str(&hex);
522                }
523            } else {
524                result.push_str("\\u");
525                result.push_str(&hex);
526            }
527        } else {
528            result.push(ch);
529        }
530    }
531    result
532}