Skip to main content

sparrow/tools/
web_search.rs

1//! Web search tool — DuckDuckGo-based search with structured results.
2
3use std::process::Command;
4
5/// Search the web and return results.
6pub fn web_search(query: &str, max_results: usize) -> anyhow::Result<Vec<SearchResult>> {
7    // Try DuckDuckGo via curl (no API key needed)
8    let output = Command::new("curl")
9        .args([
10            "-s",
11            "-A",
12            "sparrow/0.5",
13            "--max-time",
14            "10",
15            &format!(
16                "https://api.duckduckgo.com/?q={}&format=json&no_html=1",
17                urlencoding(query)
18            ),
19        ])
20        .output()?;
21
22    if output.status.success() {
23        let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
24        return parse_duckduckgo(&json, max_results);
25    }
26
27    // Fallback: try SearXNG (self-hosted, no API key)
28    if let Ok(output) = Command::new("curl")
29        .args([
30            "-s",
31            "--max-time",
32            "10",
33            &format!(
34                "https://searx.be/search?q={}&format=json",
35                urlencoding(query)
36            ),
37        ])
38        .output()
39    {
40        if output.status.success() {
41            let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
42            return parse_searx(&json, max_results);
43        }
44    }
45
46    // Last resort: suggest manual search
47    Ok(vec![SearchResult {
48        title: format!("Search: {}", query),
49        url: format!("https://duckduckgo.com/?q={}", urlencoding(query)),
50        snippet: "No results. Open the URL in your browser to search manually.".into(),
51    }])
52}
53
54#[derive(Debug, Clone, serde::Serialize)]
55pub struct SearchResult {
56    pub title: String,
57    pub url: String,
58    pub snippet: String,
59}
60
61fn parse_duckduckgo(json: &serde_json::Value, max: usize) -> anyhow::Result<Vec<SearchResult>> {
62    let mut results = Vec::new();
63    if let Some(items) = json.get("RelatedTopics").and_then(|t| t.as_array()) {
64        for item in items.iter().take(max) {
65            if let (Some(text), Some(url)) = (
66                item.get("Text").and_then(|t| t.as_str()),
67                item.get("FirstURL").and_then(|u| u.as_str()),
68            ) {
69                results.push(SearchResult {
70                    title: text.chars().take(80).collect(),
71                    url: url.to_string(),
72                    snippet: text.to_string(),
73                });
74            }
75        }
76    }
77    Ok(results)
78}
79
80fn parse_searx(json: &serde_json::Value, max: usize) -> anyhow::Result<Vec<SearchResult>> {
81    let mut results = Vec::new();
82    if let Some(items) = json.get("results").and_then(|r| r.as_array()) {
83        for item in items.iter().take(max) {
84            results.push(SearchResult {
85                title: item
86                    .get("title")
87                    .and_then(|t| t.as_str())
88                    .unwrap_or("")
89                    .to_string(),
90                url: item
91                    .get("url")
92                    .and_then(|u| u.as_str())
93                    .unwrap_or("")
94                    .to_string(),
95                snippet: item
96                    .get("content")
97                    .or(item.get("snippet"))
98                    .and_then(|s| s.as_str())
99                    .unwrap_or("")
100                    .to_string(),
101            });
102        }
103    }
104    Ok(results)
105}
106
107fn urlencoding(s: &str) -> String {
108    s.replace(' ', "+")
109        .replace('&', "%26")
110        .replace('=', "%3D")
111        .replace('#', "%23")
112}