pub mod cache;
pub mod html;
pub mod lookup_crate;
pub mod lookup_item;
pub mod search;
use crate::cache::{Cache, CacheConfig};
use crate::config::PerformanceConfig;
use rust_mcp_sdk::schema::CallToolError;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Format {
#[default]
Markdown,
Text,
Html,
Json,
}
impl std::fmt::Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Markdown => write!(f, "markdown"),
Self::Text => write!(f, "text"),
Self::Html => write!(f, "html"),
Self::Json => write!(f, "json"),
}
}
}
pub fn parse_format(format_str: Option<&str>) -> Result<Format, CallToolError> {
match format_str {
None => Ok(Format::Markdown),
Some(s) => match s.to_lowercase().as_str() {
"markdown" => Ok(Format::Markdown),
"text" => Ok(Format::Text),
"html" => Ok(Format::Html),
"json" => Ok(Format::Json),
_ => Err(CallToolError::invalid_arguments(
"format",
Some(format!(
"Invalid format '{s}'. Expected one of: markdown, text, html, json"
)),
)),
},
}
}
#[cfg(not(test))]
const DOCS_RS_BASE_URL: &str = "https://docs.rs";
#[cfg(not(test))]
const CRATES_IO_BASE_URL: &str = "https://crates.io";
#[must_use]
#[cfg(test)]
pub fn docs_rs_base_url() -> String {
std::env::var("CRATES_DOCS_DOCS_RS_URL").unwrap_or_else(|_| "https://docs.rs".to_string())
}
#[must_use]
#[cfg(not(test))]
pub fn docs_rs_base_url() -> String {
DOCS_RS_BASE_URL.to_string()
}
#[must_use]
#[cfg(test)]
pub fn crates_io_base_url() -> String {
std::env::var("CRATES_DOCS_CRATES_IO_URL").unwrap_or_else(|_| "https://crates.io".to_string())
}
#[must_use]
#[cfg(not(test))]
pub fn crates_io_base_url() -> String {
CRATES_IO_BASE_URL.to_string()
}
#[must_use]
pub fn build_docs_url(crate_name: &str, version: Option<&str>) -> String {
let base_url = docs_rs_base_url();
match version {
Some(ver) => format!("{base_url}/{crate_name}/{ver}/"),
None => format!("{base_url}/{crate_name}/"),
}
}
#[must_use]
pub fn build_docs_item_url(crate_name: &str, version: Option<&str>, item_path: &str) -> String {
let base_url = docs_rs_base_url();
let encoded_path = urlencoding::encode(item_path);
match version {
Some(ver) => format!("{base_url}/{crate_name}/{ver}/?search={encoded_path}"),
None => format!("{base_url}/{crate_name}/?search={encoded_path}"),
}
}
#[must_use]
pub fn build_crates_io_search_url(query: &str, sort: Option<&str>, limit: Option<usize>) -> String {
let base_url = crates_io_base_url();
let sort = sort.unwrap_or("relevance");
let limit = limit.unwrap_or(10);
format!(
"{}/api/v1/crates?q={}&per_page={}&sort={}",
base_url,
urlencoding::encode(query),
limit,
urlencoding::encode(sort)
)
}
pub struct DocService {
client: Arc<reqwest_middleware::ClientWithMiddleware>,
cache: Arc<dyn Cache>,
doc_cache: cache::DocCache,
}
impl DocService {
pub fn new(cache: Arc<dyn Cache>) -> crate::error::Result<Self> {
Self::with_config(cache, &CacheConfig::default())
}
pub fn with_config(
cache: Arc<dyn Cache>,
cache_config: &CacheConfig,
) -> crate::error::Result<Self> {
let ttl = cache::DocCacheTtl::from_cache_config(cache_config);
let doc_cache = cache::DocCache::with_ttl(cache.clone(), ttl);
let client = crate::utils::get_or_init_global_http_client()?;
Ok(Self {
client,
cache,
doc_cache,
})
}
pub fn with_full_config(
cache: Arc<dyn Cache>,
cache_config: &CacheConfig,
_perf_config: &PerformanceConfig,
) -> crate::error::Result<Self> {
let ttl = cache::DocCacheTtl::from_cache_config(cache_config);
let doc_cache = cache::DocCache::with_ttl(cache.clone(), ttl);
let client = crate::utils::get_or_init_global_http_client()?;
Ok(Self {
client,
cache,
doc_cache,
})
}
#[must_use]
pub fn client(&self) -> &reqwest_middleware::ClientWithMiddleware {
&self.client
}
#[must_use]
pub fn cache(&self) -> &Arc<dyn Cache> {
&self.cache
}
#[must_use]
pub fn doc_cache(&self) -> &cache::DocCache {
&self.doc_cache
}
pub async fn fetch_html(
&self,
url: &str,
tool_name: Option<&str>,
) -> Result<String, CallToolError> {
let response = self.client.get(url).send().await.map_err(|e| {
let prefix = tool_name.map_or(String::new(), |n| format!("[{n}] "));
CallToolError::from_message(format!("{prefix}HTTP request failed: {e}"))
})?;
let status = response.status();
if !status.is_success() {
let error_body = response.text().await.map_err(|e| {
let prefix = tool_name.map_or(String::new(), |n| format!("[{n}] "));
CallToolError::from_message(format!("{prefix}Failed to read error response: {e}"))
})?;
let prefix = tool_name.map_or(String::new(), |n| format!("[{n}] "));
return Err(CallToolError::from_message(format!(
"{prefix}Failed to get documentation: HTTP {} - {}",
status,
if error_body.is_empty() {
"No error details"
} else {
&error_body
}
)));
}
response.text().await.map_err(|e| {
let prefix = tool_name.map_or(String::new(), |n| format!("[{n}] "));
CallToolError::from_message(format!("{prefix}Failed to read response: {e}"))
})
}
#[must_use]
pub fn with_custom_client(
cache: Arc<dyn Cache>,
cache_config: &CacheConfig,
client: Arc<reqwest_middleware::ClientWithMiddleware>,
) -> Self {
let ttl = cache::DocCacheTtl::from_cache_config(cache_config);
let doc_cache = cache::DocCache::with_ttl(cache.clone(), ttl);
Self {
client,
cache,
doc_cache,
}
}
}
impl Default for DocService {
fn default() -> Self {
Self::try_default_with_fallback()
}
}
impl DocService {
fn try_default_with_fallback() -> Self {
let cache = Arc::new(crate::cache::memory::MemoryCache::new(1000));
let cache_config = CacheConfig::default();
let client: Arc<reqwest_middleware::ClientWithMiddleware> =
if let Ok(c) = crate::utils::HttpClientBuilder::new().build() {
Arc::new(c)
} else {
let plain_client = reqwest::Client::new();
Arc::new(reqwest_middleware::ClientBuilder::new(plain_client).build())
};
let ttl = cache::DocCacheTtl::from_cache_config(&cache_config);
let doc_cache = cache::DocCache::with_ttl(cache.clone(), ttl);
Self {
client,
cache,
doc_cache,
}
}
}
pub use lookup_crate::LookupCrateTool;
pub use lookup_item::LookupItemTool;
pub use search::SearchCratesTool;
pub use cache::DocCacheTtl;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_doc_service_default() {
let service = DocService::default();
let _ = service.client();
}
#[test]
fn test_doc_service_accessors() {
let service = DocService::default();
let _ = service.client();
let _ = service.client();
let _ = service.cache();
let _ = service.doc_cache();
}
#[test]
fn test_parse_format_none() {
assert_eq!(parse_format(None).unwrap(), Format::Markdown);
}
#[test]
fn test_parse_format_markdown() {
assert_eq!(parse_format(Some("markdown")).unwrap(), Format::Markdown);
assert_eq!(parse_format(Some("MARKDOWN")).unwrap(), Format::Markdown);
assert_eq!(parse_format(Some("Markdown")).unwrap(), Format::Markdown);
}
#[test]
fn test_parse_format_text() {
assert_eq!(parse_format(Some("text")).unwrap(), Format::Text);
assert_eq!(parse_format(Some("TEXT")).unwrap(), Format::Text);
}
#[test]
fn test_parse_format_html() {
assert_eq!(parse_format(Some("html")).unwrap(), Format::Html);
assert_eq!(parse_format(Some("HTML")).unwrap(), Format::Html);
}
#[test]
fn test_parse_format_json() {
assert_eq!(parse_format(Some("json")).unwrap(), Format::Json);
assert_eq!(parse_format(Some("JSON")).unwrap(), Format::Json);
}
#[test]
fn test_parse_format_invalid() {
assert!(parse_format(Some("invalid")).is_err());
assert!(parse_format(Some("xml")).is_err());
assert!(parse_format(Some("")).is_err());
}
#[test]
fn test_format_display() {
assert_eq!(Format::Markdown.to_string(), "markdown");
assert_eq!(Format::Text.to_string(), "text");
assert_eq!(Format::Html.to_string(), "html");
assert_eq!(Format::Json.to_string(), "json");
}
#[test]
fn test_format_default() {
assert_eq!(Format::default(), Format::Markdown);
}
#[test]
fn test_build_docs_url_without_version() {
std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
let url = build_docs_url("serde", None);
assert_eq!(url, "https://docs.rs/serde/");
std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
}
#[test]
fn test_build_docs_url_with_version() {
std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
let url = build_docs_url("serde", Some("1.0.0"));
assert_eq!(url, "https://docs.rs/serde/1.0.0/");
std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
}
#[test]
fn test_build_docs_item_url_without_version() {
std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
let url = build_docs_item_url("serde", None, "Serialize");
assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
}
#[test]
fn test_build_docs_item_url_with_version() {
std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
let url = build_docs_item_url("serde", Some("1.0.0"), "Serialize");
assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
}
#[test]
fn test_build_docs_item_url_encodes_special_chars() {
std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
let url = build_docs_item_url("std", None, "collections::HashMap");
assert!(url.contains("collections%3A%3AHashMap"));
std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
}
#[test]
fn test_build_crates_io_search_url_defaults() {
std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
let url = build_crates_io_search_url("web framework", None, None);
assert!(url.contains("crates.io/api/v1/crates"));
assert!(url.contains("q=web+framework") || url.contains("q=web%20framework"));
assert!(url.contains("per_page=10"));
assert!(url.contains("sort=relevance"));
std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
}
#[test]
fn test_build_crates_io_search_url_with_params() {
std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
let url = build_crates_io_search_url("async", Some("downloads"), Some(20));
assert!(url.contains("crates.io/api/v1/crates"));
assert!(url.contains("q=async"));
assert!(url.contains("per_page=20"));
assert!(url.contains("sort=downloads"));
std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
}
#[test]
fn test_build_crates_io_search_url_encodes_query() {
std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
let url = build_crates_io_search_url("web framework", None, None);
assert!(url.contains("web+framework") || url.contains("web%20framework"));
std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
}
}