synaptic_tools/
duckduckgo.rs1use async_trait::async_trait;
7use serde_json::{json, Value};
8use synaptic_core::{SynapticError, Tool};
9
10pub struct DuckDuckGoTool {
25 client: reqwest::Client,
26 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}