Skip to main content

vtcode_core/tools/web_fetch/
classify_helpers.rs

1//! Shared error classification helpers used by `web_fetch`, `web_search`,
2//! and `defuddle_fetch`.
3//!
4//! Each tool produces a different response shape, so the helpers here
5//! focus on the lower-level primitives: pulling an HTTP status code out
6//! of a reqwest error string, mapping a status / keyword to a
7//! category, and decoding HTML entities in scraped text. The per-tool
8//! `next_action` text lives next to the tool's own response builder.
9
10use regex::Regex;
11use std::sync::{LazyLock, OnceLock};
12
13/// Pull an HTTP status code out of a reqwest error string.
14///
15/// reqwest error messages take a few shapes:
16///
17/// - `error sending request for url (https://example.com/): ...`
18/// - `HTTP status server error (503 Service Unavailable) for url (...)`
19/// - `... status: 403 Forbidden ...` (custom errors that include a status)
20/// - `request timed out after 30s` (no status; caller should treat as
21///   network error)
22///
23/// A regex that matches either the `HTTP ... <3-digit>` shape (the
24/// reqwest server-error format) or the `status: <3-digit>` shape
25/// (custom errors that surface the status code) is the most robust
26/// shape we can support without depending on reqwest's internals.
27pub fn extract_http_status(message: &str) -> Option<u16> {
28    static RE: OnceLock<Regex> = OnceLock::new();
29    let re = RE.get_or_init(|| {
30        // Two alternations:
31        //   1. `\bhttp` (case-insensitive) followed by non-digits and a
32        //      3-digit number — matches "HTTP status server error
33        //      (503 ...", "http 500 ...", etc.
34        //   2. `status:` (case-insensitive) followed by a 3-digit
35        //      number — matches "error: status: 403 Forbidden", custom
36        //      error chains, and so on.
37        // The non-digit gap on the first branch is bounded at 64 chars
38        // to keep the match cheap while still covering the longest
39        // realistic reqwest format.
40        Regex::new(r"(?i)\bhttp[^0-9]{0,64}(\d{3})\b|\bstatus:\s*(\d{3})\b").expect("valid status regex")
41    });
42    let caps = re.captures(message)?;
43    // The matched number is in either capture group 1 or 2 depending
44    // on which alternation fired.
45    caps.get(1).or_else(|| caps.get(2)).and_then(|m| m.as_str().parse().ok())
46}
47
48static HTML_DEC_ENTITY: LazyLock<Regex> =
49    LazyLock::new(|| Regex::new(r"&#([0-9]{1,7});").expect("valid decimal entity regex"));
50static HTML_HEX_ENTITY: LazyLock<Regex> =
51    LazyLock::new(|| Regex::new(r"(?i)&#x([0-9a-f]{1,6});").expect("valid hex entity regex"));
52
53/// Decode one numeric character reference (`caps[1]` in `radix`); keep the
54/// original text when the digits are invalid or not a Unicode scalar value.
55fn decode_numeric_entity(caps: &regex::Captures, radix: u32) -> String {
56    let parsed = caps.get(1).and_then(|m| {
57        if radix == 16 {
58            u32::from_str_radix(m.as_str(), 16).ok()
59        } else {
60            m.as_str().parse::<u32>().ok()
61        }
62    });
63    parsed
64        .and_then(char::from_u32)
65        .map(|ch| ch.to_string())
66        .unwrap_or_else(|| caps.get(0).map(|m| m.as_str()).unwrap_or_default().to_string())
67}
68
69/// Decode HTML character references (numeric decimal/hex plus common named
70/// entities) in scraped text. Invalid code points keep their original text.
71/// `&amp;` decodes last so `&amp;lt;` yields `&lt;`, not `<`.
72pub(crate) fn decode_html_entities(input: &str) -> String {
73    // Numeric references first (decimal + hex); invalid code points keep
74    // their original text rather than panicking or emitting replacement.
75    let hex_decoded = HTML_HEX_ENTITY.replace_all(input, |caps: &regex::Captures| decode_numeric_entity(caps, 16));
76    let num_decoded =
77        HTML_DEC_ENTITY.replace_all(&hex_decoded, |caps: &regex::Captures| decode_numeric_entity(caps, 10));
78    // Named references next, with `&amp;` last so `&amp;lt;` decodes to
79    // `&lt;` (single pass) rather than `<` (double decode).
80    num_decoded
81        .replace("&lt;", "<")
82        .replace("&gt;", ">")
83        .replace("&quot;", "\"")
84        .replace("&#39;", "'")
85        .replace("&apos;", "'")
86        .replace("&#x27;", "'")
87        .replace("&#x2F;", "/")
88        .replace("&nbsp;", " ")
89        .replace("&hellip;", "…")
90        .replace("&rsquo;", "’")
91        .replace("&lsquo;", "‘")
92        .replace("&rdquo;", "”")
93        .replace("&ldquo;", "“")
94        .replace("&ndash;", "–")
95        .replace("&mdash;", "—")
96        .replace("&copy;", "©")
97        .replace("&reg;", "®")
98        .replace("&times;", "×")
99        .replace("&middot;", "·")
100        .replace("&amp;", "&")
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn extract_status_handles_common_shapes() {
109        assert_eq!(
110            extract_http_status("HTTP status server error (503 Service Unavailable) for url (https://example.com/)"),
111            Some(503)
112        );
113        assert_eq!(extract_http_status("error sending request: status: 403 Forbidden"), Some(403));
114        // Bare `<num>` without an `http` / `status:` prefix is not a
115        // status code; the parser should not match. (Real reqwest error
116        // chains always include a sentinel word before the code.)
117        assert_eq!(extract_http_status("server returned 404"), None);
118        assert_eq!(extract_http_status("request timed out after 30s"), None);
119        assert_eq!(extract_http_status("no error here"), None);
120    }
121
122    #[test]
123    fn extract_status_ignores_non_three_digit_numbers() {
124        // The regex requires exactly 3 digits. Larger numbers that happen
125        // to appear in error text (e.g. a timeout duration, a chunk
126        // size) must not be misread.
127        assert_eq!(extract_http_status("timed out after 30000ms"), None);
128        assert_eq!(extract_http_status("response body 1234 bytes"), None);
129    }
130
131    #[test]
132    fn extract_status_handles_status_with_spaces() {
133        // `status:` followed by whitespace and a 3-digit code.
134        assert_eq!(extract_http_status("error: status:    429 Too Many Requests"), Some(429));
135    }
136}