Skip to main content

synaptic_tools/
duckduckgo.rs

1//! DuckDuckGo Instant Answer search tool.
2//!
3//! Uses the free DuckDuckGo Instant Answer API — no API key required.
4//! Returns top results from DuckDuckGo instant answer, related topics, and web results.
5
6use async_trait::async_trait;
7use serde_json::{json, Value};
8use synaptic_core::{SynapticError, Tool};
9
10/// A search tool powered by the DuckDuckGo Instant Answer API.
11///
12/// No API key is required. Results include the abstract (featured snippet),
13/// related topics, and answer when available.
14///
15/// # Example
16///
17/// ```rust,ignore
18/// use synaptic_tools::DuckDuckGoTool;
19/// use synaptic_core::Tool;
20///
21/// let tool = DuckDuckGoTool::new();
22/// let result = tool.call(serde_json::json!({"query": "Rust programming"})).await?;
23/// ```
24pub struct DuckDuckGoTool {
25    client: reqwest::Client,
26    /// Maximum number of related topics to include in results (default: 5).
27    max_results: usize,
28}
29
30impl Default for DuckDuckGoTool {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl DuckDuckGoTool {
37    pub fn new() -> Self {
38        Self {
39            client: reqwest::Client::new(),
40            max_results: 5,
41        }
42    }
43
44    pub fn with_max_results(mut self, max_results: usize) -> Self {
45        self.max_results = max_results;
46        self
47    }
48}
49
50#[async_trait]
51impl Tool for DuckDuckGoTool {
52    fn name(&self) -> &'static str {
53        "duckduckgo_search"
54    }
55
56    fn description(&self) -> &'static str {
57        "Search the web using DuckDuckGo. Returns instant answers, featured snippets, \
58         and related topics. No API key required."
59    }
60
61    fn parameters(&self) -> Option<Value> {
62        Some(json!({
63            "type": "object",
64            "properties": {
65                "query": {
66                    "type": "string",
67                    "description": "The search query"
68                }
69            },
70            "required": ["query"]
71        }))
72    }
73
74    async fn call(&self, args: Value) -> Result<Value, SynapticError> {
75        let query = args["query"]
76            .as_str()
77            .ok_or_else(|| SynapticError::Tool("missing 'query' parameter".to_string()))?;
78
79        let encoded_query = urlencoding::encode(query);
80        let url = format!(
81            "https://api.duckduckgo.com/?q={encoded_query}&format=json&no_html=1&skip_disambig=1&no_redirect=1"
82        );
83
84        let response = self
85            .client
86            .get(&url)
87            .header("User-Agent", "synaptic-agent/0.2")
88            .send()
89            .await
90            .map_err(|e| SynapticError::Tool(format!("DuckDuckGo request failed: {e}")))?;
91
92        if !response.status().is_success() {
93            let status = response.status().as_u16();
94            return Err(SynapticError::Tool(format!(
95                "DuckDuckGo API error: HTTP {status}"
96            )));
97        }
98
99        let body: Value = response
100            .json()
101            .await
102            .map_err(|e| SynapticError::Tool(format!("DuckDuckGo parse error: {e}")))?;
103
104        let mut results = Vec::new();
105
106        if let Some(abstract_text) = body["Abstract"].as_str() {
107            if !abstract_text.is_empty() {
108                results.push(json!({
109                    "type": "abstract",
110                    "title": body["Heading"].as_str().unwrap_or(""),
111                    "snippet": abstract_text,
112                    "url": body["AbstractURL"].as_str().unwrap_or(""),
113                    "source": body["AbstractSource"].as_str().unwrap_or(""),
114                }));
115            }
116        }
117
118        if let Some(answer) = body["Answer"].as_str() {
119            if !answer.is_empty() {
120                results.push(json!({
121                    "type": "answer",
122                    "snippet": answer,
123                    "answer_type": body["AnswerType"].as_str().unwrap_or(""),
124                }));
125            }
126        }
127
128        if let Some(topics) = body["RelatedTopics"].as_array() {
129            let mut count = 0;
130            for topic in topics {
131                if count >= self.max_results {
132                    break;
133                }
134                if let Some(text) = topic["Text"].as_str() {
135                    if !text.is_empty() {
136                        results.push(json!({
137                            "type": "related",
138                            "snippet": text,
139                            "url": topic["FirstURL"].as_str().unwrap_or(""),
140                        }));
141                        count += 1;
142                    }
143                }
144            }
145        }
146
147        if results.is_empty() {
148            return Ok(json!({
149                "query": query,
150                "results": [],
151                "message": "No results found. Try a more specific query.",
152            }));
153        }
154
155        Ok(json!({
156            "query": query,
157            "results": results,
158        }))
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn tool_metadata() {
168        let tool = DuckDuckGoTool::new();
169        assert_eq!(tool.name(), "duckduckgo_search");
170        assert!(!tool.description().is_empty());
171    }
172
173    #[test]
174    fn tool_schema() {
175        let tool = DuckDuckGoTool::new();
176        let schema = tool.parameters().unwrap();
177        assert_eq!(schema["type"], "object");
178        assert!(schema["properties"]["query"].is_object());
179    }
180
181    #[tokio::test]
182    async fn missing_query_returns_error() {
183        let tool = DuckDuckGoTool::new();
184        let result = tool.call(json!({})).await;
185        assert!(result.is_err());
186    }
187}