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: ®ex::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/// `&` decodes last so `&lt;` yields `<`, 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: ®ex::Captures| decode_numeric_entity(caps, 16));
76 let num_decoded =
77 HTML_DEC_ENTITY.replace_all(&hex_decoded, |caps: ®ex::Captures| decode_numeric_entity(caps, 10));
78 // Named references next, with `&` last so `&lt;` decodes to
79 // `<` (single pass) rather than `<` (double decode).
80 num_decoded
81 .replace("<", "<")
82 .replace(">", ">")
83 .replace(""", "\"")
84 .replace("'", "'")
85 .replace("'", "'")
86 .replace("'", "'")
87 .replace("/", "/")
88 .replace(" ", " ")
89 .replace("…", "…")
90 .replace("’", "’")
91 .replace("‘", "‘")
92 .replace("”", "”")
93 .replace("“", "“")
94 .replace("–", "–")
95 .replace("—", "—")
96 .replace("©", "©")
97 .replace("®", "®")
98 .replace("×", "×")
99 .replace("·", "·")
100 .replace("&", "&")
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}