use async_trait::async_trait;
use reqwest::{Client, header::HeaderMap};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use crate::integration::HostIntegration;
use crate::tools::{Tool, ToolError, ToolResponse, Permission};
pub struct FetchTool {
client: Client,
}
#[derive(Debug, Deserialize)]
struct FetchParams {
url: String,
format: Option<String>,
timeout: Option<u64>,
headers: Option<HashMap<String, String>>,
follow_redirects: Option<bool>,
max_size: Option<usize>,
}
#[derive(Debug, Serialize)]
struct FetchMetadata {
final_url: String,
status_code: u16,
response_headers: HashMap<String, String>,
content_type: String,
content_length: usize,
response_time_ms: u64,
converted: bool,
original_format: String,
target_format: String,
}
impl FetchTool {
pub fn new() -> Result<Self, ToolError> {
let client = Client::builder()
.user_agent("coderlib/1.0")
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to create HTTP client: {}", e)))?;
Ok(Self { client })
}
async fn fetch_content(&self, params: &FetchParams) -> Result<(String, FetchMetadata), ToolError> {
let start_time = std::time::Instant::now();
let url = reqwest::Url::parse(¶ms.url)
.map_err(|e| ToolError::InvalidParameters(format!("Invalid URL: {}", e)))?;
let mut client_builder = Client::builder()
.user_agent("coderlib/1.0");
let timeout = params.timeout.unwrap_or(30).min(120);
client_builder = client_builder.timeout(Duration::from_secs(timeout));
if !params.follow_redirects.unwrap_or(true) {
client_builder = client_builder.redirect(reqwest::redirect::Policy::none());
}
let client = client_builder.build()
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to create HTTP client: {}", e)))?;
let mut request = client.get(url.clone());
if let Some(headers) = ¶ms.headers {
let mut header_map = HeaderMap::new();
for (key, value) in headers {
let header_name: reqwest::header::HeaderName = key.parse()
.map_err(|e| ToolError::InvalidParameters(format!("Invalid header name '{}': {}", key, e)))?;
let header_value: reqwest::header::HeaderValue = value.parse()
.map_err(|e| ToolError::InvalidParameters(format!("Invalid header value '{}': {}", value, e)))?;
header_map.insert(header_name, header_value);
}
request = request.headers(header_map);
}
let response = request.send().await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to fetch URL: {}", e)))?;
let response_time = start_time.elapsed();
let status_code = response.status().as_u16();
let final_url = response.url().to_string();
if !response.status().is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Request failed with status code: {} ({})",
status_code, response.status().canonical_reason().unwrap_or("Unknown")
)));
}
let mut response_headers = HashMap::new();
for (name, value) in response.headers() {
if let Ok(value_str) = value.to_str() {
response_headers.insert(name.to_string(), value_str.to_string());
}
}
let content_type = response.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("text/plain")
.to_string();
let max_size = params.max_size.unwrap_or(10 * 1024 * 1024); if let Some(content_length) = response.content_length() {
if content_length as usize > max_size {
return Err(ToolError::ExecutionFailed(format!(
"Response too large: {} bytes (max: {} bytes)",
content_length, max_size
)));
}
}
let bytes = response.bytes().await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read response body: {}", e)))?;
if bytes.len() > max_size {
return Err(ToolError::ExecutionFailed(format!(
"Response too large: {} bytes (max: {} bytes)",
bytes.len(), max_size
)));
}
let content = String::from_utf8_lossy(&bytes).to_string();
let original_format = self.detect_format(&content_type, &content);
let target_format = params.format.as_deref().unwrap_or("text");
let (converted_content, converted) = self.convert_format(&content, &original_format, target_format)?;
let metadata = FetchMetadata {
final_url,
status_code,
response_headers,
content_type,
content_length: bytes.len(),
response_time_ms: response_time.as_millis() as u64,
converted,
original_format,
target_format: target_format.to_string(),
};
Ok((converted_content, metadata))
}
fn detect_format(&self, content_type: &str, content: &str) -> String {
if content_type.contains("application/json") {
"json".to_string()
} else if content_type.contains("text/html") {
"html".to_string()
} else if content_type.contains("text/markdown") || content_type.contains("text/x-markdown") {
"markdown".to_string()
} else if content.trim_start().starts_with("<!DOCTYPE html") || content.trim_start().starts_with("<html") {
"html".to_string()
} else if content.trim_start().starts_with('{') || content.trim_start().starts_with('[') {
"json".to_string()
} else {
"text".to_string()
}
}
fn convert_format(&self, content: &str, from_format: &str, to_format: &str) -> Result<(String, bool), ToolError> {
if from_format == to_format {
return Ok((content.to_string(), false));
}
match (from_format, to_format) {
("html", "markdown") => {
self.html_to_markdown(content).map(|s| (s, true))
}
("html", "text") => {
self.html_to_text(content).map(|s| (s, true))
}
("json", "text") => {
self.json_to_text(content).map(|s| (s, true))
}
("markdown", "text") => {
self.markdown_to_text(content).map(|s| (s, true))
}
_ => {
Ok((content.to_string(), false))
}
}
}
fn html_to_markdown(&self, html: &str) -> Result<String, ToolError> {
let mut markdown = html.to_string();
markdown = markdown.replace("<br>", "\n");
markdown = markdown.replace("<br/>", "\n");
markdown = markdown.replace("<br />", "\n");
markdown = markdown.replace("<p>", "\n\n");
markdown = markdown.replace("</p>", "");
markdown = markdown.replace("<h1>", "# ");
markdown = markdown.replace("</h1>", "\n");
markdown = markdown.replace("<h2>", "## ");
markdown = markdown.replace("</h2>", "\n");
markdown = markdown.replace("<h3>", "### ");
markdown = markdown.replace("</h3>", "\n");
let re = regex::Regex::new(r"<[^>]*>").unwrap();
markdown = re.replace_all(&markdown, "").to_string();
let re = regex::Regex::new(r"\n\s*\n\s*\n").unwrap();
markdown = re.replace_all(&markdown, "\n\n").to_string();
Ok(markdown.trim().to_string())
}
fn html_to_text(&self, html: &str) -> Result<String, ToolError> {
let re = regex::Regex::new(r"<[^>]*>").unwrap();
let text = re.replace_all(html, "").to_string();
let text = text.replace("&", "&");
let text = text.replace("<", "<");
let text = text.replace(">", ">");
let text = text.replace(""", "\"");
let text = text.replace("'", "'");
let text = text.replace(" ", " ");
let re = regex::Regex::new(r"\s+").unwrap();
let text = re.replace_all(&text, " ").to_string();
Ok(text.trim().to_string())
}
fn json_to_text(&self, json: &str) -> Result<String, ToolError> {
match serde_json::from_str::<serde_json::Value>(json) {
Ok(value) => Ok(serde_json::to_string_pretty(&value)
.unwrap_or_else(|_| json.to_string())),
Err(_) => Ok(json.to_string()), }
}
fn markdown_to_text(&self, markdown: &str) -> Result<String, ToolError> {
let mut text = markdown.to_string();
let re = regex::Regex::new(r"^#{1,6}\s+").unwrap();
text = re.replace_all(&text, "").to_string();
let re = regex::Regex::new(r"\*\*([^*]+)\*\*").unwrap();
text = re.replace_all(&text, "$1").to_string();
let re = regex::Regex::new(r"\*([^*]+)\*").unwrap();
text = re.replace_all(&text, "$1").to_string();
let re = regex::Regex::new(r"`([^`]+)`").unwrap();
text = re.replace_all(&text, "$1").to_string();
let re = regex::Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap();
text = re.replace_all(&text, "$1").to_string();
Ok(text)
}
fn format_response(&self, content: &str, metadata: &FetchMetadata) -> String {
let mut response = String::new();
response.push_str(&format!("Successfully fetched content from: {}\n\n", metadata.final_url));
response.push_str(&format!("Status: {} {}\n", metadata.status_code,
if metadata.status_code == 200 { "OK" } else { "Success" }));
response.push_str(&format!("Content-Type: {}\n", metadata.content_type));
response.push_str(&format!("Content-Length: {} bytes\n", metadata.content_length));
response.push_str(&format!("Response Time: {}ms\n", metadata.response_time_ms));
if metadata.converted {
response.push_str(&format!("Format: {} → {}\n", metadata.original_format, metadata.target_format));
}
response.push_str("\n--- Content ---\n");
response.push_str(content);
response
}
}
impl Default for FetchTool {
fn default() -> Self {
Self::new().unwrap_or_else(|_| Self {
client: Client::new(),
})
}
}
#[async_trait]
impl Tool for FetchTool {
async fn execute(
&self,
parameters: serde_json::Value,
_host: &dyn HostIntegration,
) -> Result<ToolResponse, ToolError> {
let params: FetchParams = serde_json::from_value(parameters)
.map_err(|e| ToolError::InvalidParameters(format!("Invalid parameters: {}", e)))?;
if params.url.trim().is_empty() {
return Err(ToolError::InvalidParameters("url is required".to_string()));
}
let valid_formats = ["text", "markdown", "json", "html"];
if let Some(format) = ¶ms.format {
if !valid_formats.contains(&format.as_str()) {
return Err(ToolError::InvalidParameters(format!(
"Invalid format '{}'. Supported formats: {}",
format, valid_formats.join(", ")
)));
}
}
let (content, metadata) = self.fetch_content(¶ms).await?;
let response_content = self.format_response(&content, &metadata);
let metadata_json = serde_json::to_value(&metadata)
.unwrap_or(serde_json::Value::Null);
Ok(ToolResponse {
content: response_content,
success: true,
metadata: metadata_json,
affected_files: Vec::new(), })
}
fn name(&self) -> &str {
"fetch"
}
fn description(&self) -> &str {
"Fetch content from URLs with format conversion. Supports converting HTML to Markdown/text, JSON formatting, and more."
}
fn requires_permission(&self) -> Permission {
Permission::NetworkAccess }
fn parameter_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to fetch content from",
"format": "uri"
},
"format": {
"type": "string",
"description": "Response format: text, markdown, json, or html (default: text)",
"enum": ["text", "markdown", "json", "html"],
"default": "text"
},
"timeout": {
"type": "integer",
"description": "Request timeout in seconds (default: 30, max: 120)",
"default": 30,
"minimum": 1,
"maximum": 120
},
"headers": {
"type": "object",
"description": "Custom headers to include in the request",
"additionalProperties": {
"type": "string"
}
},
"follow_redirects": {
"type": "boolean",
"description": "Whether to follow redirects (default: true)",
"default": true
},
"max_size": {
"type": "integer",
"description": "Maximum response size in bytes (default: 10MB)",
"default": 10485760,
"minimum": 1024,
"maximum": 104857600
}
},
"required": ["url"]
})
}
fn clone_box(&self) -> Box<dyn Tool> {
Box::new(Self {
client: self.client.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_fetch_tool_creation() {
let tool = FetchTool::new().unwrap();
assert_eq!(tool.name(), "fetch");
assert!(!tool.description().is_empty());
}
#[test]
fn test_format_detection() {
let tool = FetchTool::new().unwrap();
assert_eq!(tool.detect_format("application/json", "{}"), "json");
assert_eq!(tool.detect_format("text/html", "<html></html>"), "html");
assert_eq!(tool.detect_format("text/plain", "hello"), "text");
assert_eq!(tool.detect_format("text/plain", "<!DOCTYPE html>"), "html");
assert_eq!(tool.detect_format("text/plain", "{\"key\": \"value\"}"), "json");
}
#[test]
fn test_html_to_text() {
let tool = FetchTool::new().unwrap();
let html = "<h1>Title</h1><p>Hello <strong>world</strong>!</p>";
let text = tool.html_to_text(html).unwrap();
assert!(text.contains("Title"));
assert!(text.contains("Hello world!"));
assert!(!text.contains("<"));
}
#[test]
fn test_json_formatting() {
let tool = FetchTool::new().unwrap();
let json = r#"{"name":"test","value":123}"#;
let formatted = tool.json_to_text(json).unwrap();
assert!(formatted.contains("\"name\": \"test\""));
assert!(formatted.len() > json.len()); }
}