1use super::traits::Tool;
20use crate::config::constants::tools;
21use anyhow::{Context, Result, anyhow};
22use astral_html::Token;
23use async_trait::async_trait;
24use reqwest::header::{ACCEPT, HeaderMap, HeaderValue, USER_AGENT};
25use serde::Deserialize;
26use serde_json::{Value, json};
27use std::collections::HashMap;
28use std::sync::{Arc, Mutex};
29use std::time::{Duration, Instant};
30use url::Url;
31use vtcode_config::{WebSearchConfig, WebSearchProvider};
32
33const MAX_TIMEOUT_SECS: u64 = 60;
34const MAX_RESULTS_CAP: usize = 20;
35const MAX_TITLE_CHARS: usize = 200;
36const MAX_SNIPPET_CHARS: usize = 400;
37
38const BROWSER_USER_AGENT: &str =
41 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
42
43pub(crate) const WEB_SEARCH_DESCRIPTION: &str = "Searches the web for a query and returns a ranked list of results (title, url, snippet) inline. Accepts: { query: string, max_results?: number }. Provider is set in vtcode.toml ([tools.web_search] provider): \"duckduckgo\" (default; keyless HTML endpoint, best-effort, may be rate-limited) or \"youcom\" (You.com Search API; requires YDC_API_KEY). Results are cached for a few minutes to avoid repeat hits. Use web_fetch on the most promising result URL to read full content. Returns { query, provider, count, cached, results: [{ title, url, snippet }] }.";
44
45#[derive(Debug, Deserialize)]
46#[serde(deny_unknown_fields)]
47struct WebSearchArgs {
48 query: String,
50 #[serde(default)]
51 max_results: Option<usize>,
52}
53
54pub struct SearchResult {
55 pub title: String,
56 pub url: String,
57 pub snippet: String,
58}
59
60impl SearchResult {
61 pub fn new(title: String, url: String, snippet: String) -> Self {
62 Self { title, url, snippet }
63 }
64}
65
66#[derive(Default)]
70struct SessionState {
71 last_request_at: Option<Instant>,
72 requests_made: u32,
73 cache: HashMap<String, CachedResults>,
74}
75
76struct CachedResults {
77 stored_at: Instant,
78 payload: Value,
79}
80
81impl SessionState {
82 fn cache_get(&self, key: &str, ttl: Duration) -> Option<Value> {
83 let entry = self.cache.get(key)?;
84 if entry.stored_at.elapsed() > ttl {
85 return None;
86 }
87 Some(entry.payload.clone())
88 }
89
90 fn cache_put(&mut self, key: String, payload: Value) {
91 self.cache.insert(key, CachedResults { stored_at: Instant::now(), payload });
92 }
93}
94
95#[derive(Clone, Default)]
99pub struct WebSearchTool {
100 config: Arc<Mutex<WebSearchConfig>>,
101 state: Arc<Mutex<SessionState>>,
102}
103
104impl WebSearchTool {
105 pub fn new() -> Self {
106 Self {
107 config: Arc::new(Mutex::new(WebSearchConfig::default())),
108 state: Arc::new(Mutex::new(SessionState::default())),
109 }
110 }
111
112 pub fn with_config(config: WebSearchConfig) -> Self {
116 Self {
117 config: Arc::new(Mutex::new(config)),
118 state: Arc::new(Mutex::new(SessionState::default())),
119 }
120 }
121
122 pub fn set_config(&self, config: WebSearchConfig) {
124 if let Ok(mut guard) = self.config.lock() {
125 *guard = config;
126 }
127 }
128
129 pub fn reset(&self) {
131 if let Ok(mut guard) = self.state.lock() {
132 *guard = SessionState::default();
133 }
134 }
135
136 fn snapshot_config(&self) -> WebSearchConfig {
137 self.config.lock().map(|guard| guard.clone()).unwrap_or_default()
138 }
139
140 async fn run(&self, raw_args: Value) -> Result<Value> {
141 let args: WebSearchArgs =
142 serde_json::from_value(raw_args).context("Invalid arguments for web_search. Provide a 'query' string.")?;
143
144 let query = args.query.trim().to_string();
145 if query.is_empty() {
146 return Err(anyhow!("web_search requires a non-empty 'query'"));
147 }
148
149 let snapshot = self.snapshot_config();
150 let default_max = snapshot.max_results.clamp(1, MAX_RESULTS_CAP);
151 let max_results = args.max_results.unwrap_or(default_max).clamp(1, MAX_RESULTS_CAP);
152 let cooldown = Duration::from_millis(snapshot.cooldown_ms);
153 let cache_ttl = Duration::from_secs(snapshot.cache_ttl_secs);
154 let session_cap = snapshot.session_max_requests;
155
156 let cache_key = format!("{max_results}::{query}");
162
163 if let Some(cached) = self
165 .state
166 .lock()
167 .unwrap_or_else(|e| e.into_inner())
168 .cache_get(&cache_key, cache_ttl)
169 {
170 return Ok(mark_cached(cached));
171 }
172
173 {
175 let state = self.state.lock().map_err(|e| anyhow!("web_search state lock poisoned: {e}"))?;
176 if state.requests_made >= session_cap {
177 return Ok(session_cap_reached_response(&query, session_cap));
178 }
179 if let Some(last) = state.last_request_at {
180 let elapsed = last.elapsed();
181 if elapsed < cooldown {
182 return Ok(cooldown_response(
184 &query,
185 cooldown.checked_sub(elapsed).expect("elapsed < cooldown checked above"),
186 ));
187 }
188 }
189 }
190
191 let provider = match snapshot.provider {
195 WebSearchProvider::Youcom => "youcom",
196 WebSearchProvider::Auto | WebSearchProvider::Duckduckgo => "duckduckgo",
197 };
198 if provider == "youcom" && std::env::var(YOUCOM_API_KEY_ENV).is_err() {
199 return Ok(json!({
200 "error": format!(
201 "You.com provider selected but {YOUCOM_API_KEY_ENV} is not set. Set it to a key from https://you.com/platform/api-keys, or switch [tools.web_search] provider back to \"duckduckgo\"."
202 ),
203 "query": query,
204 "provider": provider,
205 "error_type": "setup_error",
206 "next_action": "Set the YDC_API_KEY environment variable, then retry.",
207 }));
208 }
209
210 let results = match snapshot.provider {
211 WebSearchProvider::Youcom => youcom_search(&query, max_results, snapshot.timeout_secs).await,
212 WebSearchProvider::Auto | WebSearchProvider::Duckduckgo => {
213 duckduckgo_search(&query, max_results, snapshot.timeout_secs).await
214 }
215 };
216 let provider_name = provider;
217
218 if let Ok(mut state) = self.state.lock() {
222 state.last_request_at = Some(Instant::now());
223 state.requests_made = state.requests_made.saturating_add(1);
224 }
225
226 match results {
227 Ok(results) if results.is_empty() => {
228 let payload = json!({
229 "query": query,
230 "provider": provider_name,
231 "count": 0,
232 "results": [],
233 "warning": if provider_name == "youcom" {
234 "No results were returned. The query may have matched nothing; try a different query."
235 } else {
236 "No results were returned. DuckDuckGo may have rate-limited the request or matched nothing. Try a different query, or wait a few seconds and try again."
237 }
238 });
239 self.cache_put(&cache_key, &payload);
240 Ok(payload)
241 }
242 Ok(results) => {
243 let payload = json!({
244 "query": query,
245 "provider": provider_name,
246 "count": results.len(),
247 "results": results
248 .into_iter()
249 .map(|r| json!({ "title": r.title, "url": r.url, "snippet": r.snippet }))
250 .collect::<Vec<_>>(),
251 });
252 self.cache_put(&cache_key, &payload);
253 Ok(payload)
254 }
255 Err(e) => {
256 let (error_type, next_action) = classify_search_error(&e.to_string(), provider_name);
260 Ok(json!({
261 "error": format!("web_search failed: {e}"),
262 "query": query,
263 "provider": provider_name,
264 "error_type": error_type,
265 "next_action": next_action,
266 }))
267 }
268 }
269 }
270
271 fn cache_put(&self, key: &str, payload: &Value) {
272 if let Ok(mut state) = self.state.lock() {
273 state.cache_put(key.to_string(), payload.clone());
274 }
275 }
276}
277
278fn classify_search_error(message: &str, provider: &str) -> (&'static str, &'static str) {
284 let lower = message.to_lowercase();
285 if let Some(status) = crate::tools::web_fetch::classify_helpers::extract_http_status(&lower) {
288 if (500..=599).contains(&status) {
289 return (
290 "upstream_error",
291 "The search service is currently unavailable. Retry after a short delay, or use web_fetch on a known URL as a fallback.",
292 );
293 }
294 }
295 if message.contains("HTTP 202") || lower.contains("anti-bot") || lower.contains("challenge") {
296 (
297 "antiban_blocked",
298 "DuckDuckGo declined this request (likely an anti-bot challenge for this network). An immediate retry from this network will hit the same block; pick a result URL from this session's earlier searches and use web_fetch on it instead, or ask the user to confirm a different search provider.",
299 )
300 } else if lower.contains("timeout") || lower.contains("timed out") {
301 if provider == "youcom" {
302 (
303 "network_error",
304 "You.com timed out. Retry after a short delay, or use web_fetch on a known URL as a fallback.",
305 )
306 } else {
307 (
308 "network_error",
309 "DuckDuckGo timed out. Retry after a short delay, or use web_fetch on a known URL as a fallback.",
310 )
311 }
312 } else {
313 (
314 "network_error",
315 "Wait a few seconds and retry, or use web_fetch directly if you already know a relevant URL.",
316 )
317 }
318}
319
320fn mark_cached(mut payload: Value) -> Value {
321 if let Value::Object(map) = &mut payload {
322 map.insert("cached".to_string(), Value::Bool(true));
323 }
324 payload
325}
326
327fn cooldown_response(query: &str, wait: Duration) -> Value {
328 json!({
329 "error": "web_search cooldown active",
330 "query": query,
331 "retry_after_ms": wait.as_millis() as u64,
332 "next_action": format!("Wait at least {} ms before the next web search to avoid being rate-limited.", wait.as_millis())
333 })
334}
335
336fn session_cap_reached_response(query: &str, cap: u32) -> Value {
337 json!({
338 "error": "web_search session request cap reached",
339 "query": query,
340 "session_max_requests": cap,
341 "next_action": format!("This session has used its {cap} web searches. Use web_fetch on a known URL or restart the session to search again.")
342 })
343}
344
345fn build_client(timeout_secs: u64) -> Result<reqwest::Client> {
348 reqwest::Client::builder()
349 .timeout(Duration::from_secs(timeout_secs.min(MAX_TIMEOUT_SECS)))
350 .redirect(reqwest::redirect::Policy::limited(3))
351 .build()
352 .context("failed to build HTTP client for web_search")
353}
354
355fn build_youcom_client(timeout_secs: u64) -> Result<reqwest::Client> {
361 reqwest::Client::builder()
362 .timeout(Duration::from_secs(timeout_secs.min(MAX_TIMEOUT_SECS)))
363 .redirect(reqwest::redirect::Policy::none())
364 .build()
365 .context("failed to build HTTP client for web_search")
366}
367
368async fn duckduckgo_search(query: &str, max_results: usize, timeout_secs: u64) -> Result<Vec<SearchResult>> {
379 let client = build_client(timeout_secs)?;
380 let mut headers = HeaderMap::new();
381 headers.insert(USER_AGENT, HeaderValue::from_static(BROWSER_USER_AGENT));
382 headers.insert(ACCEPT, HeaderValue::from_static("text/html,application/xhtml+xml"));
383
384 let response = client
386 .post("https://html.duckduckgo.com/html/")
387 .headers(headers)
388 .form(&[("q", query)])
389 .send()
390 .await
391 .context("DuckDuckGo request failed")?;
392
393 let status = response.status();
394 if status.as_u16() == 202 || !status.is_success() {
399 return Err(anyhow!(
400 "DuckDuckGo declined the request (HTTP {status}), likely an anti-bot challenge for this network. Wait a few seconds and retry."
401 ));
402 }
403
404 let body = read_capped_ddg_body(response).await?;
405
406 Ok(parse_duckduckgo_html(&body, max_results))
407}
408
409const MAX_DDG_BODY_BYTES: usize = 1_000_000;
413
414async fn read_capped_ddg_body(response: reqwest::Response) -> Result<String> {
418 let bytes = response.bytes().await.context("failed to read DuckDuckGo response body")?;
419 Ok(capped_body_text(&bytes))
420}
421
422fn capped_body_text(bytes: &[u8]) -> String {
426 let end = bytes.len().min(MAX_DDG_BODY_BYTES);
427 String::from_utf8_lossy(&bytes[..end]).into_owned()
428}
429
430pub fn parse_duckduckgo_html(body: &str, max_results: usize) -> Vec<SearchResult> {
438 let mut results = Vec::new();
439 let mut state = DdgSection::Idle;
440 for token in astral_html::Reader::new(body) {
441 if results.len() >= max_results {
442 break;
443 }
444 match token {
445 Token::StartTag(tag) => {
446 if tag.name == "a" && has_ddg_class(&tag, "result__a") {
447 if let DdgSection::AfterTitle { url, title } = std::mem::replace(&mut state, DdgSection::Idle) {
450 push_ddg_result(&mut results, url, title, String::new(), max_results);
451 if results.len() >= max_results {
452 break;
453 }
454 }
455 if !tag.self_closing {
456 if let Some(href) = ddg_attr(&tag, "href") {
457 state = DdgSection::InTitle { href: href.to_string(), buf: String::new() };
458 }
459 }
460 } else if matches!(state, DdgSection::AfterTitle { .. }) && has_ddg_class(&tag, "result__snippet") {
461 if let DdgSection::AfterTitle { url, title } = std::mem::replace(&mut state, DdgSection::Idle) {
462 if tag.self_closing {
463 push_ddg_result(&mut results, url, title, String::new(), max_results);
464 } else {
465 state = DdgSection::InSnippet {
466 url,
467 title,
468 name: tag.name.to_string(),
469 buf: String::new(),
470 };
471 }
472 }
473 }
474 }
477 Token::Text(text) => match &mut state {
478 DdgSection::InTitle { buf, .. } | DdgSection::InSnippet { buf, .. } => {
479 buf.push_str(&text);
480 }
481 DdgSection::Idle | DdgSection::AfterTitle { .. } => {}
482 },
483 Token::EndTag(tag) => {
484 if tag.name == "a" && matches!(state, DdgSection::InTitle { .. }) {
485 if let DdgSection::InTitle { href, buf } = std::mem::replace(&mut state, DdgSection::Idle) {
486 let title = buf.trim().to_string();
487 if !title.is_empty() {
488 if let Some(url) = normalize_ddg_url(&href) {
489 state = DdgSection::AfterTitle { url, title };
490 }
491 }
492 }
493 } else if matches!(state, DdgSection::InSnippet { .. }) {
494 let done = if let DdgSection::InSnippet { name, .. } = &state {
495 tag.name == *name
496 } else {
497 false
498 };
499 if done {
500 if let DdgSection::InSnippet { url, title, buf, .. } =
501 std::mem::replace(&mut state, DdgSection::Idle)
502 {
503 push_ddg_result(&mut results, url, title, buf, max_results);
504 }
505 }
506 }
507 }
508 _ => {}
509 }
510 }
511 if let DdgSection::AfterTitle { url, title } = state {
513 push_ddg_result(&mut results, url, title, String::new(), max_results);
514 }
515 results
516}
517
518enum DdgSection {
521 Idle,
522 InTitle {
523 href: String,
524 buf: String,
525 },
526 AfterTitle {
527 url: String,
528 title: String,
529 },
530 InSnippet {
531 url: String,
532 title: String,
533 name: String,
534 buf: String,
535 },
536}
537
538fn ddg_attr(tag: &astral_html::Tag<'_>, name: &str) -> Option<String> {
541 tag.attributes
542 .iter()
543 .find(|attr| attr.name == name)
544 .map(|attr| attr.value().to_string())
545}
546
547fn has_ddg_class(tag: &astral_html::Tag<'_>, class: &str) -> bool {
550 tag.attributes
551 .iter()
552 .find(|attr| attr.name == "class")
553 .is_some_and(|attr| attr.value().split_whitespace().any(|token| token.eq_ignore_ascii_case(class)))
554}
555
556fn push_ddg_result(results: &mut Vec<SearchResult>, url: String, title: String, snippet: String, max_results: usize) {
560 if results.len() >= max_results {
561 return;
562 }
563 results.push(SearchResult {
564 title: truncate_chars(&title, MAX_TITLE_CHARS),
565 url,
566 snippet: truncate_chars(&snippet, MAX_SNIPPET_CHARS),
567 });
568}
569
570fn normalize_ddg_url(href: &str) -> Option<String> {
575 let absolute = if let Some(stripped) = href.strip_prefix("//") {
576 format!("https://{stripped}")
577 } else {
578 href.to_string()
579 };
580
581 let parsed = Url::parse(&absolute).ok()?;
582 if let Some((_, target)) = parsed.query_pairs().find(|(k, _)| k == "uddg") {
583 let target = target.into_owned();
584 return validate_result_url(&target);
585 }
586 validate_result_url(&absolute)
587}
588
589fn validate_result_url(url: &str) -> Option<String> {
591 let parsed = Url::parse(url).ok()?;
592 match parsed.scheme() {
593 "http" | "https" => {}
594 _ => return None,
595 }
596 if parsed.host_str().is_none_or(str::is_empty) {
597 return None;
598 }
599 Some(url.to_string())
600}
601
602const YOUCOM_API_KEY_ENV: &str = "YDC_API_KEY";
608
609const YOUCOM_SEARCH_URL: &str = "https://ydc-index.io/v1/search";
612
613#[derive(Debug, Deserialize)]
614struct YoucomSearchResponse {
615 #[serde(default)]
616 results: Option<YoucomResults>,
617}
618
619#[derive(Debug, Deserialize)]
620struct YoucomResults {
621 #[serde(default)]
622 web: Option<Vec<YoucomWebResult>>,
623}
624
625#[derive(Debug, Deserialize)]
626struct YoucomWebResult {
627 #[serde(default)]
628 url: Option<String>,
629 #[serde(default)]
630 title: Option<String>,
631 #[serde(default)]
633 description: Option<String>,
634 #[serde(default)]
636 snippets: Option<Vec<String>>,
637}
638
639async fn youcom_search(query: &str, max_results: usize, timeout_secs: u64) -> Result<Vec<SearchResult>> {
643 let api_key = std::env::var(YOUCOM_API_KEY_ENV).map_err(|source| {
644 anyhow!(
645 "You.com provider selected but {YOUCOM_API_KEY_ENV} is not set ({source}). Set it to a key from https://you.com/platform/api-keys, or switch [tools.web_search] provider back to \"duckduckgo\"."
646 )
647 })?;
648
649 let client = build_youcom_client(timeout_secs)?;
652 let response = client
653 .post(YOUCOM_SEARCH_URL)
654 .header("X-API-Key", &api_key)
655 .json(&serde_json::json!({ "query": query, "count": max_results }))
658 .send()
659 .await
660 .context("You.com request failed")?;
661
662 let status = response.status();
663 if !status.is_success() {
664 if status.is_redirection() {
668 return Err(anyhow!(
669 "You.com returned a redirect (HTTP {status}); refusing to follow it with the API key attached. Check the endpoint configuration."
670 ));
671 }
672 return Err(anyhow!(
673 "You.com declined the request (HTTP {status}). Check that {YOUCOM_API_KEY_ENV} is valid, then retry."
674 ));
675 }
676
677 let body = response.text().await.context("failed to read You.com response body")?;
678 let parsed: YoucomSearchResponse =
679 serde_json::from_str(&body).with_context(|| "failed to parse You.com search response")?;
680
681 Ok(parse_youcom_results(parsed, max_results))
682}
683
684fn parse_youcom_results(response: YoucomSearchResponse, max_results: usize) -> Vec<SearchResult> {
688 let mut results = Vec::new();
689 for hit in parsed_web_hits(response) {
690 if results.len() >= max_results {
691 break;
692 }
693 let Some(url) = hit.url.as_deref().and_then(validate_result_url) else {
694 continue;
695 };
696 let title = hit.title.as_deref().unwrap_or_default().trim();
697 if title.is_empty() {
698 continue;
699 }
700 let snippet = hit
702 .description
703 .as_deref()
704 .map(str::trim)
705 .filter(|s| !s.is_empty())
706 .or_else(|| hit.snippets.as_deref().and_then(|s| s.first()).map(|s| s.trim()))
707 .unwrap_or_default();
708 results.push(SearchResult {
709 title: truncate_chars(title, MAX_TITLE_CHARS),
710 url,
711 snippet: truncate_chars(snippet, MAX_SNIPPET_CHARS),
712 });
713 }
714 results
715}
716
717fn parsed_web_hits(response: YoucomSearchResponse) -> impl Iterator<Item = YoucomWebResult> {
718 response.results.into_iter().flat_map(|r| r.web.into_iter()).flatten()
719}
720
721fn truncate_chars(input: &str, max_chars: usize) -> String {
726 vtcode_commons::formatting::truncate_text(input.trim(), max_chars, "…")
727}
728
729#[async_trait]
730impl Tool for WebSearchTool {
731 async fn execute(&self, args: Value) -> Result<Value> {
732 self.run(args).await
733 }
734
735 fn name(&self) -> &str {
736 tools::WEB_SEARCH
737 }
738
739 fn description(&self) -> &str {
740 WEB_SEARCH_DESCRIPTION
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use crate::tools::web_fetch::classify_helpers::decode_html_entities;
748
749 #[test]
750 fn normalize_ddg_url_extracts_uddg_target() {
751 let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fgithub.com%2Fvinhnx&rut=abc";
752 assert_eq!(normalize_ddg_url(href).as_deref(), Some("https://github.com/vinhnx"));
753 }
754
755 #[test]
756 fn normalize_ddg_url_passes_through_direct_https() {
757 let href = "https://example.com/page";
758 assert_eq!(normalize_ddg_url(href).as_deref(), Some("https://example.com/page"));
759 }
760
761 #[test]
762 fn validate_result_url_rejects_non_http_schemes() {
763 assert!(validate_result_url("javascript:alert(1)").is_none());
764 assert!(validate_result_url("data:text/html,hi").is_none());
765 assert!(validate_result_url("file:///etc/passwd").is_none());
766 assert!(validate_result_url("https://example.com").is_some());
767 }
768
769 #[test]
770 fn missing_query_is_rejected() {
771 let tool = WebSearchTool::new();
772 let result = tokio::runtime::Runtime::new()
773 .unwrap()
774 .block_on(tool.run(json!({ "max_results": 5 })));
775 assert!(result.is_err());
776 }
777
778 #[test]
779 fn pattern_is_rejected_as_an_unknown_field() {
780 let result = serde_json::from_value::<WebSearchArgs>(json!({
781 "query": "vinhnx",
782 "pattern": "legacy"
783 }));
784 assert!(result.is_err());
785 }
786
787 #[test]
788 fn canonical_query_and_max_results_are_accepted() {
789 let args = serde_json::from_value::<WebSearchArgs>(json!({
790 "query": "vinhnx",
791 "max_results": 5
792 }))
793 .expect("canonical web_search arguments");
794 assert_eq!(args.query, "vinhnx");
795 assert_eq!(args.max_results, Some(5));
796 }
797
798 #[test]
799 fn truncate_chars_appends_ellipsis() {
800 assert_eq!(truncate_chars("hello world", 5), "hello…");
801 assert_eq!(truncate_chars("hi", 5), "hi");
802 }
803
804 const DDG_FIXTURE: &str = r#"
808 <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgithub.com%2Fvinhnx&rut=abc">vinhnx (vinhnguyenxuan) · GitHub</a>
809 <a class="result__snippet" href="https://github.com/vinhnx">Personal GitHub profile of Vinh Nguyen Xuan.</a>
810 <a class="result__a" href="https://example.com/page">Example Page</a>
811 <a class="result__snippet" href="https://example.com/page">An example page used in tests.</a>
812 <a class="result__a" href="javascript:alert(1)">Should be skipped</a>
813 "#;
814
815 #[test]
816 fn parse_duckduckgo_html_extracts_results_from_fixture() {
817 let results = parse_duckduckgo_html(DDG_FIXTURE, 10);
818 assert_eq!(results.len(), 2);
819
820 assert_eq!(results[0].title, "vinhnx (vinhnguyenxuan) · GitHub");
821 assert_eq!(results[0].url, "https://github.com/vinhnx");
822 assert_eq!(results[0].snippet, "Personal GitHub profile of Vinh Nguyen Xuan.");
823
824 assert_eq!(results[1].title, "Example Page");
825 assert_eq!(results[1].url, "https://example.com/page");
826 assert_eq!(results[1].snippet, "An example page used in tests.");
827 }
828
829 #[test]
830 fn parse_duckduckgo_html_respects_max_results() {
831 let results = parse_duckduckgo_html(DDG_FIXTURE, 1);
832 assert_eq!(results.len(), 1);
833 assert_eq!(results[0].url, "https://github.com/vinhnx");
834 }
835
836 #[test]
837 fn parse_duckduckgo_html_returns_empty_for_challenge_page() {
838 let challenge = r#"<html><body>Anomaly detected.</body></html>"#;
843 assert!(parse_duckduckgo_html(challenge, 10).is_empty());
844 }
845
846 #[test]
847 fn parse_accepts_href_before_class_and_single_quotes() {
848 let body = r#"
851 <a href='https://example.com/b-first' class='result__a'>B first</a>
852 <a class="result__snippet">Snippet B.</a>
853 <a href="https://example.com/a-second" class="result__a extra">A second</a>
854 <a class="result__snippet">Snippet A.</a>
855 "#;
856 let results = parse_duckduckgo_html(body, 10);
857 assert_eq!(results.len(), 2);
858 assert_eq!(results[0].url, "https://example.com/b-first");
859 assert_eq!(results[0].snippet, "Snippet B.");
860 assert_eq!(results[1].url, "https://example.com/a-second");
861 assert_eq!(results[1].snippet, "Snippet A.");
862 }
863
864 #[test]
865 fn parse_accepts_uppercase_tags_and_classes() {
866 let body = r#"
867 <A CLASS="RESULT__A" HREF="https://example.com/upper">Upper Title</A>
868 <A CLASS="RESULT__SNIPPET">Upper snippet.</A>
869 "#;
870 let results = parse_duckduckgo_html(body, 10);
871 assert_eq!(results.len(), 1);
872 assert_eq!(results[0].title, "Upper Title");
873 assert_eq!(results[0].snippet, "Upper snippet.");
874 }
875
876 #[test]
877 fn parse_missing_first_snippet_does_not_shift_second() {
878 let body = r#"
881 <a class="result__a" href="https://example.com/no-snippet">No Snippet</a>
882 <a class="result__a" href="https://example.com/with-snippet">With Snippet</a>
883 <a class="result__snippet">Second snippet.</a>
884 "#;
885 let results = parse_duckduckgo_html(body, 10);
886 assert_eq!(results.len(), 2);
887 assert_eq!(results[0].snippet, "");
888 assert_eq!(results[1].snippet, "Second snippet.");
889 }
890
891 #[test]
892 fn parse_skipped_anchors_do_not_steal_snippets() {
893 let bad_first = r#"
896 <a class="result__a" href="javascript:alert(1)">Bad</a>
897 <a class="result__snippet">Bad snippet.</a>
898 <a class="result__a" href="https://example.com/good">Good</a>
899 <a class="result__snippet">Good snippet.</a>
900 "#;
901 let results = parse_duckduckgo_html(bad_first, 10);
902 assert_eq!(results.len(), 1);
903 assert_eq!(results[0].url, "https://example.com/good");
904 assert_eq!(results[0].snippet, "Good snippet.");
905
906 let good_first = r#"
907 <a class="result__a" href="https://example.com/good">Good</a>
908 <a class="result__snippet">Good snippet.</a>
909 <a class="result__a" href="javascript:alert(1)">Bad</a>
910 <a class="result__snippet">Bad snippet.</a>
911 "#;
912 let results = parse_duckduckgo_html(good_first, 10);
913 assert_eq!(results.len(), 1);
914 assert_eq!(results[0].snippet, "Good snippet.");
915 }
916
917 #[test]
918 fn parse_ignores_anchors_inside_script() {
919 let body = concat!(
922 "<script>var x = '<a class=\"result__a\" href=\"https://example.com/evil\">Evil</a>';</script>",
923 "<a class=\"result__a\" href=\"https://example.com/good\">Good</a>",
924 "<a class=\"result__snippet\">Good snippet.</a>",
925 );
926 let results = parse_duckduckgo_html(body, 10);
927 assert_eq!(results.len(), 1);
928 assert_eq!(results[0].url, "https://example.com/good");
929 assert_eq!(results[0].snippet, "Good snippet.");
930 }
931
932 #[test]
933 fn parse_collects_nested_formatting_text_in_title() {
934 let body = concat!(
935 "<a class=\"result__a\" href=\"https://example.com/fmt\">Hello <b>Bold</b> tail</a>",
936 "<a class=\"result__snippet\">S.</a>",
937 );
938 let results = parse_duckduckgo_html(body, 10);
939 assert_eq!(results.len(), 1);
940 assert_eq!(results[0].title, "Hello Bold tail");
941 }
942
943 #[test]
944 fn capped_body_text_passes_small_bodies_through() {
945 assert_eq!(capped_body_text(b"<html>hi</html>"), "<html>hi</html>");
946 assert_eq!(capped_body_text(b""), "");
947 }
948
949 #[test]
950 fn capped_body_text_truncates_huge_bodies_without_panicking_on_split_chars() {
951 let mut bytes = vec![b'a'; 999_999];
954 bytes.extend_from_slice("é".as_bytes());
955 let text = capped_body_text(&bytes);
956 assert!(text[..999_999].bytes().all(|b| b == b'a'));
957
958 let huge = vec![b'a'; MAX_DDG_BODY_BYTES + 5];
959 assert_eq!(capped_body_text(&huge).len(), MAX_DDG_BODY_BYTES);
960 }
961
962 #[test]
963 fn capped_body_text_is_lossy_not_panicky_on_invalid_utf8() {
964 let text = capped_body_text(b"\xff\xfe<html></html>");
965 assert!(text.contains("<html>"), "got: {text}");
966 }
967
968 #[test]
969 fn decode_numeric_hex_and_named_entities() {
970 assert_eq!(decode_html_entities("ABC"), "ABC");
971 assert_eq!(decode_html_entities("’“—"), "’“—");
972 assert_eq!(decode_html_entities("&lt;"), "<");
973 assert_eq!(decode_html_entities("�"), "�");
974 }
975
976 #[test]
977 fn parse_accepts_unquoted_href_and_td_closed_snippet() {
978 let body = r#"
979 <a class=result__a href=https://example.com/unquoted>Unquoted</a>
980 <td class="result__snippet">TD snippet.</td>
981 <abbr title="abbreviation">abbr</abbr>
982 <a class="result__a" href="https://example.com/second">Second</a>
983 <a class="result__snippet">Second snippet.</a>
984 "#;
985 let results = parse_duckduckgo_html(body, 10);
986 assert_eq!(results.len(), 2);
987 assert_eq!(results[0].url, "https://example.com/unquoted");
988 assert_eq!(results[0].snippet, "TD snippet.");
989 assert_eq!(results[1].url, "https://example.com/second");
990 assert_eq!(results[1].snippet, "Second snippet.");
991 }
992
993 #[test]
994 fn session_cap_short_circuits_with_structured_error() {
995 let config = WebSearchConfig {
996 provider: Default::default(),
997 max_results: 5,
998 timeout_secs: 20,
999 cooldown_ms: 0,
1000 cache_ttl_secs: 300,
1001 session_max_requests: 2,
1002 };
1003 let tool = WebSearchTool::with_config(config);
1004 {
1006 let mut state = tool.state.lock().unwrap();
1007 state.requests_made = 2;
1008 state.last_request_at = Some(Instant::now());
1009 }
1010 let payload = tokio::runtime::Runtime::new()
1011 .unwrap()
1012 .block_on(tool.run(json!({ "query": "rust" })))
1013 .expect("cap should be a structured JSON, not a runtime error");
1014 assert_eq!(payload["error"], "web_search session request cap reached");
1015 assert_eq!(payload["session_max_requests"], 2);
1016 }
1017
1018 #[test]
1019 fn cooldown_short_circuits_with_retry_after() {
1020 let config = WebSearchConfig {
1021 provider: Default::default(),
1022 max_results: 5,
1023 timeout_secs: 20,
1024 cooldown_ms: 5_000,
1025 cache_ttl_secs: 300,
1026 session_max_requests: 100,
1027 };
1028 let tool = WebSearchTool::with_config(config);
1029 {
1030 let mut state = tool.state.lock().unwrap();
1031 state.requests_made = 0;
1032 state.last_request_at = Some(Instant::now());
1033 }
1034 let payload = tokio::runtime::Runtime::new()
1035 .unwrap()
1036 .block_on(tool.run(json!({ "query": "rust" })))
1037 .expect("cooldown should be a structured JSON");
1038 assert_eq!(payload["error"], "web_search cooldown active");
1039 assert!(payload["retry_after_ms"].as_u64().unwrap() > 0);
1040 }
1041
1042 #[test]
1043 fn cache_serves_repeat_queries_without_network() {
1044 let tool = WebSearchTool::with_config(WebSearchConfig {
1046 provider: Default::default(),
1047 max_results: 5,
1048 ..WebSearchConfig::default()
1049 });
1050 let cached_payload = json!({
1051 "query": "rust",
1052 "provider": "duckduckgo",
1053 "count": 1,
1054 "results": [{
1055 "title": "Cached Result",
1056 "url": "https://example.com/cached",
1057 "snippet": "from cache"
1058 }]
1059 });
1060 {
1061 let mut state = tool.state.lock().unwrap();
1062 state.cache_put("5::rust".to_string(), cached_payload.clone());
1063 }
1064 let payload = tokio::runtime::Runtime::new()
1065 .unwrap()
1066 .block_on(tool.run(json!({ "query": "rust" })))
1067 .expect("cache hit must not error");
1068 assert_eq!(payload["cached"], json!(true));
1069 assert_eq!(payload["count"], 1);
1070 assert_eq!(payload["results"][0]["title"], "Cached Result");
1071 }
1072
1073 #[test]
1074 fn classify_search_error_flags_antiban_block() {
1075 let (kind, action) = classify_search_error(
1080 "DuckDuckGo declined the request (HTTP 202), likely an anti-bot challenge for this network.",
1081 "duckduckgo",
1082 );
1083 assert_eq!(kind, "antiban_blocked");
1084 assert!(action.contains("immediate retry"), "action should discourage immediate retry; got: {action}");
1085 }
1086
1087 #[test]
1088 fn classify_search_error_flags_timeout_as_network_error() {
1089 let (kind, action) = classify_search_error("request timed out after 20s", "duckduckgo");
1090 assert_eq!(kind, "network_error");
1091 assert!(
1092 action.contains("retry") || action.contains("web_fetch"),
1093 "action should suggest retry or web_fetch; got: {action}"
1094 );
1095 }
1096
1097 #[test]
1098 fn classify_search_error_uses_youcom_wording_for_youcom_timeouts() {
1099 let (kind, action) = classify_search_error("request timed out after 20s", "youcom");
1102 assert_eq!(kind, "network_error");
1103 assert!(action.contains("You.com"), "action should mention You.com for youcom failures; got: {action}");
1104 assert!(
1105 !action.contains("DuckDuckGo"),
1106 "action should not blame DuckDuckGo for a youcom failure; got: {action}"
1107 );
1108 }
1109
1110 fn youcom_fixture() -> YoucomSearchResponse {
1116 serde_json::from_str(
1117 r#"{
1118 "results": {
1119 "web": [
1120 {
1121 "title": "Rust Programming Language",
1122 "url": "https://www.rust-lang.org/",
1123 "description": "A language empowering everyone to build reliable software."
1124 },
1125 {
1126 "title": "Snippet-only hit",
1127 "url": "https://example.com/snips",
1128 "snippets": ["First snippet line."]
1129 },
1130 {
1131 "title": "No URL hit",
1132 "description": "Skipped because the URL is missing."
1133 },
1134 {
1135 "title": "javascript:alert(1)",
1136 "url": "javascript:alert(1)",
1137 "description": "Skipped by scheme validation."
1138 }
1139 ]
1140 }
1141 }"#,
1142 )
1143 .expect("fixture should parse")
1144 }
1145
1146 #[test]
1147 fn parse_youcom_results_maps_web_hits() {
1148 let results = parse_youcom_results(youcom_fixture(), 10);
1149 assert_eq!(results.len(), 2);
1150 assert_eq!(results[0].title, "Rust Programming Language");
1151 assert_eq!(results[0].url, "https://www.rust-lang.org/");
1152 assert!(results[0].snippet.contains("reliable software"));
1153 assert_eq!(results[1].snippet, "First snippet line.");
1155 }
1156
1157 #[test]
1158 fn parse_youcom_results_respects_max_results() {
1159 let results = parse_youcom_results(youcom_fixture(), 1);
1160 assert_eq!(results.len(), 1);
1161 assert_eq!(results[0].url, "https://www.rust-lang.org/");
1162 }
1163
1164 #[test]
1165 fn parse_youcom_results_handles_missing_results_block() {
1166 let response: YoucomSearchResponse =
1167 serde_json::from_str(r#"{"metadata": {}}"#).expect("empty envelope should parse");
1168 assert!(parse_youcom_results(response, 10).is_empty());
1169 }
1170
1171 #[test]
1172 fn youcom_provider_without_key_errors_before_network() {
1173 if std::env::var(YOUCOM_API_KEY_ENV).is_ok() {
1178 return; }
1180 let config = WebSearchConfig {
1181 provider: WebSearchProvider::Youcom,
1182 max_results: 5,
1183 timeout_secs: 20,
1184 cooldown_ms: 0,
1185 cache_ttl_secs: 300,
1186 session_max_requests: 100,
1187 };
1188 let tool = WebSearchTool::with_config(config);
1189 let payload = tokio::runtime::Runtime::new()
1190 .unwrap()
1191 .block_on(tool.run(json!({ "query": "rust" })))
1192 .expect("missing key should be a structured JSON, not a panic");
1193 assert!(payload["error"].as_str().unwrap().contains("YDC_API_KEY"));
1194
1195 let state = tool.state.lock().unwrap();
1199 assert_eq!(state.requests_made, 0, "missing-key setup error must not count as a request");
1200 assert!(state.last_request_at.is_none(), "missing-key setup error must not start the cooldown clock");
1201 }
1202
1203 #[test]
1204 fn youcom_client_disables_redirects() {
1205 let client = build_youcom_client(20).expect("client should build");
1208 let _ddg = build_client(20).expect("ddg client should build");
1213 drop(client);
1214 }
1215
1216 #[test]
1217 fn youcom_provider_payload_reports_youcom_provider() {
1218 let config = WebSearchConfig {
1222 provider: WebSearchProvider::Youcom,
1223 max_results: 5,
1224 ..WebSearchConfig::default()
1225 };
1226 let tool = WebSearchTool::with_config(config);
1227 let cached_payload = json!({
1228 "query": "rust",
1229 "provider": "youcom",
1230 "count": 1,
1231 "results": [{
1232 "title": "Cached You.com Result",
1233 "url": "https://example.com/you",
1234 "snippet": "from cache"
1235 }]
1236 });
1237 {
1238 let mut state = tool.state.lock().unwrap();
1239 state.cache_put("5::rust".to_string(), cached_payload);
1240 }
1241 let payload = tokio::runtime::Runtime::new()
1242 .unwrap()
1243 .block_on(tool.run(json!({ "query": "rust" })))
1244 .expect("cache hit must not error");
1245 assert_eq!(payload["provider"], json!("youcom"));
1246 assert_eq!(payload["results"][0]["title"], "Cached You.com Result");
1247 }
1248}