1use async_trait::async_trait;
43use serde::Deserialize;
44use serde_json::Value;
45use std::error::Error;
46
47use crate::tools::Tool;
48
49const APP_USER_AGENT: &str = concat!(
51 env!("CARGO_PKG_NAME"),
52 "/",
53 env!("CARGO_PKG_VERSION"),
54 " https://github.com/Abraxas-365/langchain-rust"
55);
56
57#[derive(Debug, Clone)]
59pub struct WikipediaQueryOptions {
60 pub top_k_results: usize,
62 pub max_doc_content_length: usize,
64 pub lang: String,
68}
69
70impl Default for WikipediaQueryOptions {
71 fn default() -> Self {
72 Self {
73 top_k_results: 3,
74 max_doc_content_length: 4000,
75 lang: "en".to_string(),
76 }
77 }
78}
79
80#[derive(Debug, Deserialize)]
82struct WikipediaSearchResponse {
83 query: SearchQuery,
84}
85
86#[derive(Debug, Deserialize)]
87struct SearchQuery {
88 search: Vec<SearchResult>,
89}
90
91#[derive(Debug, Deserialize)]
92struct SearchResult {
93 title: String,
94}
95
96#[derive(Debug, Deserialize)]
97struct WikipediaPageResponse {
98 query: PageQuery,
99}
100
101#[derive(Debug, Deserialize)]
102struct PageQuery {
103 pages: std::collections::HashMap<String, PageContent>,
104}
105
106#[derive(Debug, Deserialize)]
107struct PageContent {
108 title: String,
109 extract: Option<String>,
110}
111
112pub struct WikipediaQuery {
138 pub options: WikipediaQueryOptions,
139 client: reqwest::Client,
140 base_url: Option<String>,
142}
143
144impl WikipediaQuery {
145 pub fn new(options: WikipediaQueryOptions) -> Self {
151 let client = reqwest::Client::builder()
152 .user_agent(APP_USER_AGENT)
153 .build()
154 .expect("failed to build reqwest client");
155
156 Self {
157 options,
158 client,
159 base_url: None,
160 }
161 }
162
163 pub fn with_lang(lang: impl Into<String>) -> Self {
169 Self::new(WikipediaQueryOptions {
170 lang: lang.into(),
171 ..Default::default()
172 })
173 }
174
175 pub fn with_top_k_results(mut self, top_k: usize) -> Self {
177 self.options.top_k_results = top_k;
178 self
179 }
180
181 pub fn with_max_doc_content_length(mut self, max_len: usize) -> Self {
183 self.options.max_doc_content_length = max_len;
184 self
185 }
186
187 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
191 self.base_url = Some(base_url.into());
192 self
193 }
194
195 fn get_api_url(&self) -> Result<String, Box<dyn Error>> {
197 let origin = match &self.base_url {
198 Some(base) => base.trim_end_matches('/').to_string(),
199 None => {
200 let lang = &self.options.lang;
201 let valid =
202 !lang.is_empty() && lang.chars().all(|c| c.is_ascii_lowercase() || c == '-');
203 if !valid {
204 return Err(format!(
205 "Invalid Wikipedia language code '{}': must match [a-z-]+",
206 lang
207 )
208 .into());
209 }
210 format!("https://{}.wikipedia.org", lang)
211 }
212 };
213 Ok(format!("{}/w/api.php", origin))
214 }
215
216 async fn search(&self, query: &str) -> Result<Vec<String>, Box<dyn Error>> {
218 let api_url = self.get_api_url()?;
219 let params = [
220 ("action", "query"),
221 ("list", "search"),
222 ("srsearch", query),
223 ("format", "json"),
224 ("srlimit", &self.options.top_k_results.to_string()),
225 ];
226
227 let response = self
228 .client
229 .get(&api_url)
230 .query(¶ms)
231 .send()
232 .await?
233 .error_for_status()?
234 .json::<WikipediaSearchResponse>()
235 .await?;
236
237 Ok(response.query.search.into_iter().map(|r| r.title).collect())
238 }
239
240 async fn fetch_page(&self, title: &str) -> Result<String, Box<dyn Error>> {
242 let api_url = self.get_api_url()?;
243 let params = [
244 ("action", "query"),
245 ("prop", "extracts"),
246 ("titles", title),
247 ("format", "json"),
248 ("explaintext", "true"),
249 ("exintro", "true"),
250 ];
251
252 let response = self
253 .client
254 .get(&api_url)
255 .query(¶ms)
256 .send()
257 .await?
258 .error_for_status()?
259 .json::<WikipediaPageResponse>()
260 .await?;
261
262 match response.query.pages.values().next() {
263 Some(page) => {
264 let extract = page.extract.as_deref().unwrap_or("");
265 let truncated: String = extract
266 .chars()
267 .take(self.options.max_doc_content_length)
268 .collect();
269 Ok(format!("Page: {}\nSummary: {}", page.title, truncated))
270 }
271 None => Err("Page not found".into()),
272 }
273 }
274}
275
276impl Default for WikipediaQuery {
277 fn default() -> Self {
278 Self::new(WikipediaQueryOptions::default())
279 }
280}
281
282#[async_trait]
283impl Tool for WikipediaQuery {
284 fn name(&self) -> String {
285 "wikipedia-api".to_string()
286 }
287
288 fn description(&self) -> String {
289 "A wrapper around Wikipedia. \
290 Useful for when you need to answer general questions about \
291 people, places, companies, facts, historical events, or other subjects. \
292 Input should be a search query."
293 .to_string()
294 }
295
296 async fn run(&self, input: Value) -> Result<String, Box<dyn Error>> {
297 let query = match input {
298 Value::String(s) => s,
299 Value::Object(mut map) => map
300 .remove("input")
301 .and_then(|v| v.as_str().map(String::from))
302 .ok_or("Invalid input format: expected 'input' field")?,
303 _ => return Err("Input must be a string or object with 'input' field".into()),
304 };
305
306 if query.trim().is_empty() {
307 return Err("Query cannot be empty".into());
308 }
309
310 let titles = self.search(&query).await?;
312
313 if titles.is_empty() {
314 return Ok(format!("No results found for query: {}", query));
315 }
316
317 let mut results = Vec::new();
319 for title in &titles {
320 if let Ok(content) = self.fetch_page(title).await {
321 results.push(content);
322 }
323 }
324
325 if results.is_empty() {
326 return Ok("No page content could be retrieved".to_string());
327 }
328
329 Ok(results.join("\n\n"))
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use mockito::Matcher;
337 use serde_json::json;
338
339 #[test]
340 fn test_default_options() {
341 let options = WikipediaQueryOptions::default();
342 assert_eq!(options.top_k_results, 3);
343 assert_eq!(options.max_doc_content_length, 4000);
344 assert_eq!(options.lang, "en");
345 }
346
347 #[test]
348 fn test_custom_options() {
349 let options = WikipediaQueryOptions {
350 top_k_results: 5,
351 max_doc_content_length: 2000,
352 lang: "es".to_string(),
353 };
354 assert_eq!(options.top_k_results, 5);
355 assert_eq!(options.max_doc_content_length, 2000);
356 assert_eq!(options.lang, "es");
357 }
358
359 #[test]
360 fn test_tool_name() {
361 let wiki = WikipediaQuery::default();
362 assert_eq!(wiki.name(), "wikipedia-api");
363 }
364
365 #[test]
366 fn test_tool_description() {
367 let wiki = WikipediaQuery::default();
368 let desc = wiki.description();
369 assert!(desc.contains("Wikipedia"));
370 assert!(desc.contains("search query"));
371 }
372
373 #[test]
374 fn test_builder_pattern() {
375 let wiki = WikipediaQuery::default()
376 .with_top_k_results(5)
377 .with_max_doc_content_length(2000);
378
379 assert_eq!(wiki.options.top_k_results, 5);
380 assert_eq!(wiki.options.max_doc_content_length, 2000);
381 }
382
383 #[test]
384 fn test_with_lang() {
385 let wiki = WikipediaQuery::with_lang("fr");
386 assert_eq!(wiki.options.lang, "fr");
387 assert_eq!(
388 wiki.get_api_url().unwrap(),
389 "https://fr.wikipedia.org/w/api.php"
390 );
391 }
392
393 #[test]
394 fn test_api_url_construction() {
395 let wiki_en = WikipediaQuery::default();
396 assert_eq!(
397 wiki_en.get_api_url().unwrap(),
398 "https://en.wikipedia.org/w/api.php"
399 );
400
401 let wiki_es = WikipediaQuery::with_lang("es");
402 assert_eq!(
403 wiki_es.get_api_url().unwrap(),
404 "https://es.wikipedia.org/w/api.php"
405 );
406
407 let wiki_hyphen = WikipediaQuery::with_lang("zh-yue");
408 assert_eq!(
409 wiki_hyphen.get_api_url().unwrap(),
410 "https://zh-yue.wikipedia.org/w/api.php"
411 );
412 }
413
414 #[test]
415 fn test_invalid_lang_rejected() {
416 for bad in ["", "EN", "en.evil.com", "en/", "e n", "ja_JP"] {
417 let wiki = WikipediaQuery::with_lang(bad);
418 let err = wiki.get_api_url().unwrap_err().to_string();
419 assert!(err.contains("Invalid Wikipedia language code"), "{bad:?}");
420 }
421 }
422
423 #[test]
424 fn test_base_url_override() {
425 let wiki = WikipediaQuery::default().with_base_url("http://127.0.0.1:9999/");
426 assert_eq!(
427 wiki.get_api_url().unwrap(),
428 "http://127.0.0.1:9999/w/api.php"
429 );
430 }
431
432 #[tokio::test]
433 async fn test_empty_query_error() {
434 let wiki = WikipediaQuery::default();
435 let result = wiki.run(json!("")).await;
436 assert!(result.is_err());
437 assert!(result.unwrap_err().to_string().contains("empty"));
438 }
439
440 #[tokio::test]
441 async fn test_invalid_input_format() {
442 let wiki = WikipediaQuery::default();
443 let result = wiki.run(json!(123)).await;
444 assert!(result.is_err());
445 }
446
447 #[tokio::test]
448 async fn test_invalid_lang_errors_at_run() {
449 let wiki = WikipediaQuery::with_lang("en.evil.com");
450 let err = wiki.run(json!("anything")).await.unwrap_err().to_string();
451 assert!(err.contains("Invalid Wikipedia language code"));
452 }
453
454 fn search_body(titles: &[&str]) -> String {
457 json!({
458 "query": {
459 "search": titles.iter().map(|t| json!({"title": t})).collect::<Vec<_>>()
460 }
461 })
462 .to_string()
463 }
464
465 fn page_body(title: &str, extract: &str) -> String {
466 json!({
467 "query": {
468 "pages": {
469 "1": {"title": title, "extract": extract}
470 }
471 }
472 })
473 .to_string()
474 }
475
476 fn mock_search(
477 server: &mut mockito::ServerGuard,
478 query: &str,
479 titles: &[&str],
480 ) -> mockito::Mock {
481 server
482 .mock("GET", "/w/api.php")
483 .match_query(Matcher::AllOf(vec![
484 Matcher::UrlEncoded("action".into(), "query".into()),
485 Matcher::UrlEncoded("list".into(), "search".into()),
486 Matcher::UrlEncoded("srsearch".into(), query.into()),
487 ]))
488 .with_header("content-type", "application/json")
489 .with_body(search_body(titles))
490 .create()
491 }
492
493 fn mock_page(server: &mut mockito::ServerGuard, title: &str, extract: &str) -> mockito::Mock {
494 server
495 .mock("GET", "/w/api.php")
496 .match_query(Matcher::AllOf(vec![
497 Matcher::UrlEncoded("prop".into(), "extracts".into()),
498 Matcher::UrlEncoded("titles".into(), title.into()),
499 ]))
500 .with_header("content-type", "application/json")
501 .with_body(page_body(title, extract))
502 .create()
503 }
504
505 #[tokio::test]
506 async fn test_run_against_mock_server() {
507 let mut server = mockito::Server::new_async().await;
508 let search = mock_search(
509 &mut server,
510 "rust",
511 &["Rust (programming language)", "Rust"],
512 );
513 let page1 = mock_page(
514 &mut server,
515 "Rust (programming language)",
516 "Rust is a systems language.",
517 );
518 let page2 = mock_page(&mut server, "Rust", "Rust is an iron oxide.");
519
520 let wiki = WikipediaQuery::default().with_base_url(server.url());
521 let out = wiki.run(json!("rust")).await.unwrap();
522
523 search.assert();
524 page1.assert();
525 page2.assert();
526 assert_eq!(
527 out,
528 "Page: Rust (programming language)\nSummary: Rust is a systems language.\n\n\
529 Page: Rust\nSummary: Rust is an iron oxide."
530 );
531 }
532
533 #[tokio::test]
534 async fn test_object_input_against_mock_server() {
535 let mut server = mockito::Server::new_async().await;
536 let _search = mock_search(&mut server, "ferris", &["Ferris"]);
537 let _page = mock_page(&mut server, "Ferris", "Ferris is a crab.");
538
539 let wiki = WikipediaQuery::default().with_base_url(server.url());
540 let out = wiki.run(json!({"input": "ferris"})).await.unwrap();
541 assert_eq!(out, "Page: Ferris\nSummary: Ferris is a crab.");
542 }
543
544 #[tokio::test]
545 async fn test_no_results_from_mock_server() {
546 let mut server = mockito::Server::new_async().await;
547 let _search = mock_search(&mut server, "nothing", &[]);
548
549 let wiki = WikipediaQuery::default().with_base_url(server.url());
550 let out = wiki.run(json!("nothing")).await.unwrap();
551 assert_eq!(out, "No results found for query: nothing");
552 }
553
554 #[tokio::test]
555 async fn test_truncation_counts_chars_not_bytes() {
556 let mut server = mockito::Server::new_async().await;
557 let _search = mock_search(&mut server, "日本", &["日本"]);
558 let _page = mock_page(&mut server, "日本", "日本は島国です。");
560
561 let wiki = WikipediaQuery::default()
562 .with_base_url(server.url())
563 .with_max_doc_content_length(5);
564 let out = wiki.run(json!("日本")).await.unwrap();
565 assert_eq!(out, "Page: 日本\nSummary: 日本は島国");
566 }
567
568 #[tokio::test]
569 async fn test_zero_max_length_does_not_panic() {
570 let mut server = mockito::Server::new_async().await;
571 let _search = mock_search(&mut server, "x", &["X"]);
572 let _page = mock_page(&mut server, "X", "some text");
573
574 let wiki = WikipediaQuery::default()
575 .with_base_url(server.url())
576 .with_max_doc_content_length(0);
577 let out = wiki.run(json!("x")).await.unwrap();
578 assert_eq!(out, "Page: X\nSummary: ");
579 }
580
581 #[tokio::test]
582 async fn test_search_http_error_is_propagated() {
583 let mut server = mockito::Server::new_async().await;
584 let _search = server
585 .mock("GET", "/w/api.php")
586 .match_query(Matcher::UrlEncoded("list".into(), "search".into()))
587 .with_status(503)
588 .with_body("<html>Service Unavailable</html>")
589 .create();
590
591 let wiki = WikipediaQuery::default().with_base_url(server.url());
592 let err = wiki.run(json!("rust")).await.unwrap_err().to_string();
593 assert!(err.contains("503"), "unexpected error: {err}");
594 }
595
596 #[tokio::test]
597 async fn test_failed_page_fetch_is_skipped() {
598 let mut server = mockito::Server::new_async().await;
599 let _search = mock_search(&mut server, "mixed", &["Good", "Bad"]);
600 let _good = mock_page(&mut server, "Good", "fine");
601 let _bad = server
602 .mock("GET", "/w/api.php")
603 .match_query(Matcher::AllOf(vec![
604 Matcher::UrlEncoded("prop".into(), "extracts".into()),
605 Matcher::UrlEncoded("titles".into(), "Bad".into()),
606 ]))
607 .with_status(500)
608 .create();
609
610 let wiki = WikipediaQuery::default().with_base_url(server.url());
611 let out = wiki.run(json!("mixed")).await.unwrap();
612 assert_eq!(out, "Page: Good\nSummary: fine");
613 }
614
615 #[tokio::test]
616 async fn test_all_page_fetches_failing_returns_message() {
617 let mut server = mockito::Server::new_async().await;
618 let _search = mock_search(&mut server, "broken", &["Bad"]);
619 let _bad = server
620 .mock("GET", "/w/api.php")
621 .match_query(Matcher::UrlEncoded("prop".into(), "extracts".into()))
622 .with_status(500)
623 .create();
624
625 let wiki = WikipediaQuery::default().with_base_url(server.url());
626 let out = wiki.run(json!("broken")).await.unwrap();
627 assert_eq!(out, "No page content could be retrieved");
628 }
629
630 #[tokio::test]
632 #[ignore] async fn test_search_integration() {
634 let wiki = WikipediaQuery::default();
635 let titles = wiki.search("Rust programming language").await.unwrap();
636 assert!(!titles.is_empty());
637 assert!(titles.len() <= 3); }
639
640 #[tokio::test]
641 #[ignore] async fn test_fetch_page_integration() {
643 let wiki = WikipediaQuery::default();
644 let content = wiki
645 .fetch_page("Rust (programming language)")
646 .await
647 .unwrap();
648 assert!(content.contains("Page:"));
649 assert!(content.contains("Summary:"));
650 }
651
652 #[tokio::test]
653 #[ignore] async fn test_run_integration() {
655 let wiki = WikipediaQuery::default();
656 let result = wiki.run(json!("Rust programming language")).await.unwrap();
657 assert!(result.contains("Page:"));
658 assert!(result.contains("Summary:"));
659 }
660
661 #[tokio::test]
662 #[ignore] async fn test_spanish_wikipedia() {
664 let wiki = WikipediaQuery::with_lang("es");
665 let result = wiki.run(json!("Rust lenguaje programación")).await.unwrap();
666 assert!(result.contains("Page:"));
667 }
668
669 #[tokio::test]
670 #[ignore] async fn test_content_truncation() {
672 let wiki = WikipediaQuery::default().with_max_doc_content_length(100);
673 let result = wiki.run(json!("United States")).await.unwrap();
674 for line in result.lines() {
676 if let Some(summary) = line.strip_prefix("Summary: ") {
677 assert!(summary.chars().count() <= 100);
678 }
679 }
680 }
681
682 #[tokio::test]
683 #[ignore] async fn test_top_k_results() {
685 let wiki = WikipediaQuery::default().with_top_k_results(2);
686 let result = wiki.run(json!("programming")).await.unwrap();
687 let page_count = result.matches("Page:").count();
689 assert!(page_count <= 2);
690 }
691
692 #[tokio::test]
693 #[ignore] async fn test_nonexistent_page() {
695 let wiki = WikipediaQuery::default();
696 let result = wiki.run(json!("xyzabc123nonexistentpage999")).await;
697 assert!(result.is_ok());
699 }
700}