use std::time::Duration;
use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};
const MAX_BODY_BYTES: usize = 64 * 1024;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_TIMEOUT_SECS: u64 = 120;
fn head(s: &str, cap: usize) -> String {
if s.len() <= cap {
return s.to_string();
}
let mut end = cap;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…[truncated]…", &s[..end])
}
pub fn net_tool_defs() -> Vec<Value> {
vec![
json!({
"name": "http_request",
"description": "Fetch a URL or call an HTTP API. Defaults to GET. \
Returns the response status and a (size-capped) body. \
Runs from the host, so it works even when the \
filesystem/shell are sandboxed offline.",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "Absolute http(s) URL." },
"method": { "type": "string", "description": "HTTP method (default GET)." },
"headers": { "type": "object", "description": "Optional request headers." },
"body": { "type": "string", "description": "Optional request body (for POST/PUT/…)." },
"timeout_secs": { "type": "integer", "description": "Wall-clock limit (default 30, max 120)." }
},
"required": ["url"]
}
}),
json!({
"name": "web_search",
"description": "Search the web for current facts and return a short list \
of results (title, url, snippet). Use when you need \
information you don't already have.",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "What to search for." },
"max_results": { "type": "integer", "description": "How many results to return (default 5)." }
},
"required": ["query"]
}
}),
]
}
pub struct NetTools {
client: reqwest::Client,
}
impl Default for NetTools {
fn default() -> Self {
Self::new()
}
}
impl NetTools {
pub fn new() -> Self {
let client = reqwest::Client::builder()
.user_agent("car-assistant/1.0")
.build()
.unwrap_or_default();
Self { client }
}
async fn http_request(&self, params: &Value) -> Result<Value, String> {
let url = params
.get("url")
.and_then(Value::as_str)
.ok_or("http_request requires a 'url' string")?;
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("url must be an absolute http(s) URL".into());
}
let method = params
.get("method")
.and_then(Value::as_str)
.unwrap_or("GET")
.to_uppercase();
let m = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|_| format!("invalid HTTP method '{method}'"))?;
let secs = params
.get("timeout_secs")
.and_then(Value::as_u64)
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(1, MAX_TIMEOUT_SECS);
let mut req = self
.client
.request(m, url)
.timeout(Duration::from_secs(secs));
if let Some(headers) = params.get("headers").and_then(Value::as_object) {
for (k, v) in headers {
if let Some(vs) = v.as_str() {
req = req.header(k, vs);
}
}
}
if let Some(body) = params.get("body").and_then(Value::as_str) {
req = req.body(body.to_string());
}
let resp = req.send().await.map_err(|e| format!("request failed: {e}"))?;
let status = resp.status().as_u16();
let text = resp
.text()
.await
.map_err(|e| format!("failed to read response body: {e}"))?;
Ok(json!({
"status": status,
"body": head(&text, MAX_BODY_BYTES),
}))
}
async fn web_search(&self, params: &Value) -> Result<Value, String> {
let query = params
.get("query")
.and_then(Value::as_str)
.ok_or("web_search requires a 'query' string")?;
let max = params
.get("max_results")
.and_then(Value::as_u64)
.unwrap_or(5)
.clamp(1, 15) as usize;
if let Ok(html) = self.fetch_ddg_html(query).await {
let results = parse_ddg_html(&html, max);
if !results.is_empty() {
return Ok(json!({ "query": query, "results": results }));
}
}
let url = format!(
"https://api.duckduckgo.com/?q={}&format=json&no_html=1&no_redirect=1&t=car-assistant",
urlencode(query)
);
let v: Value = self
.client
.get(&url)
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.send()
.await
.map_err(|e| format!("search failed: {e}"))?
.json()
.await
.map_err(|e| format!("failed to parse search response: {e}"))?;
let mut results = Vec::new();
if let Some(topics) = v.get("RelatedTopics").and_then(Value::as_array) {
collect_topics(topics, &mut results, max);
}
let abstract_text = v
.get("AbstractText")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from);
Ok(json!({
"query": query,
"abstract": abstract_text,
"abstract_source": v.get("AbstractURL").and_then(Value::as_str),
"results": results,
"note": if results.is_empty() && abstract_text.is_none() {
"No results; consider a direct http_request to a source."
} else { "" },
}))
}
async fn fetch_ddg_html(&self, query: &str) -> Result<String, String> {
let url = format!("https://html.duckduckgo.com/html/?q={}", urlencode(query));
let resp = self
.client
.get(&url)
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.send()
.await
.map_err(|e| format!("search failed: {e}"))?;
resp.text().await.map_err(|e| e.to_string())
}
}
fn parse_ddg_html(html: &str, max: usize) -> Vec<Value> {
let mut out = Vec::new();
for seg in html.split("class=\"result__a\"").skip(1) {
if out.len() >= max {
break;
}
let Some(href) = attr_after(seg, "href=\"") else {
continue;
};
let url = decode_uddg(&href);
if url.is_empty() {
continue;
}
let title = inner_text(seg);
let snippet = seg
.split_once("class=\"result__snippet\"")
.map(|(_, rest)| inner_text(rest))
.unwrap_or_default();
out.push(json!({ "title": title, "url": url, "snippet": snippet }));
}
out
}
fn attr_after(s: &str, marker: &str) -> Option<String> {
let start = s.find(marker)? + marker.len();
let end = s[start..].find('"')? + start;
Some(s[start..end].to_string())
}
fn inner_text(s: &str) -> String {
let after = s.find('>').map(|i| &s[i + 1..]).unwrap_or(s);
let raw = after.split('<').next().unwrap_or("");
unescape_entities(raw).split_whitespace().collect::<Vec<_>>().join(" ")
}
fn decode_uddg(href: &str) -> String {
let normalized = href.replace("&", "&");
if let Some(idx) = normalized.find("uddg=") {
let rest = &normalized[idx + 5..];
let enc = rest.split('&').next().unwrap_or("");
return percent_decode(enc);
}
if let Some(stripped) = normalized.strip_prefix("//") {
return format!("https://{stripped}");
}
normalized
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
b'+' => {
out.push(b' ');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn unescape_entities(s: &str) -> String {
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
}
fn collect_topics(topics: &[Value], out: &mut Vec<Value>, max: usize) {
for t in topics {
if out.len() >= max {
return;
}
if let Some(sub) = t.get("Topics").and_then(Value::as_array) {
collect_topics(sub, out, max);
continue;
}
let text = t.get("Text").and_then(Value::as_str).unwrap_or("");
let url = t.get("FirstURL").and_then(Value::as_str).unwrap_or("");
if text.is_empty() && url.is_empty() {
continue;
}
let title = text.split(" - ").next().unwrap_or(text);
out.push(json!({ "title": title, "url": url, "snippet": text }));
}
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[async_trait]
impl ToolExecutor for NetTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"http_request" => self.http_request(params).await,
"web_search" => self.web_search(params).await,
other => Err(format!("unknown tool: '{other}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn head_truncates_on_char_boundary() {
let s = "ééé"; let t = head(s, 3);
assert!(t.starts_with('é') && t.ends_with("…[truncated]…"));
}
#[test]
fn urlencode_escapes_spaces_and_specials() {
assert_eq!(urlencode("a b&c"), "a%20b%26c");
assert_eq!(urlencode("plain-text_1.0~"), "plain-text_1.0~");
}
#[test]
fn net_tool_defs_advertises_both() {
let names: Vec<String> = net_tool_defs()
.iter()
.filter_map(|d| d["name"].as_str().map(String::from))
.collect();
assert!(names.contains(&"http_request".to_string()));
assert!(names.contains(&"web_search".to_string()));
}
#[tokio::test]
async fn http_request_rejects_non_http_url() {
let nt = NetTools::new();
let err = nt
.execute("http_request", &json!({ "url": "file:///etc/passwd" }))
.await
.unwrap_err();
assert!(err.contains("absolute http(s)"), "{err}");
}
#[tokio::test]
async fn unknown_tool_falls_through() {
let nt = NetTools::new();
let err = nt.execute("teleport", &json!({})).await.unwrap_err();
assert!(err.starts_with("unknown tool"), "{err}");
}
#[test]
fn percent_decode_handles_encoded_urls() {
assert_eq!(
percent_decode("https%3A%2F%2Fexample.com%2Fa%20b"),
"https://example.com/a b"
);
}
#[test]
fn decode_uddg_extracts_destination() {
let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Frust-lang.org%2F&rut=abc";
assert_eq!(decode_uddg(href), "https://rust-lang.org/");
}
#[test]
fn parse_ddg_html_extracts_results() {
let html = r##"
<div class="result">
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fstd%2F&rut=x">The Rust Standard Library</a>
<a class="result__snippet" href="#">Documentation for the Rust standard library.</a>
</div>
<div class="result">
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F">crates.io</a>
<a class="result__snippet" href="#">The Rust package registry.</a>
</div>
"##;
let results = parse_ddg_html(html, 5);
assert_eq!(results.len(), 2);
assert_eq!(results[0]["url"], "https://doc.rust-lang.org/std/");
assert_eq!(results[0]["title"], "The Rust Standard Library");
assert!(results[0]["snippet"]
.as_str()
.unwrap()
.contains("standard library"));
assert_eq!(results[1]["url"], "https://crates.io/");
assert_eq!(parse_ddg_html(html, 1).len(), 1);
}
}