1pub mod cache;
25pub mod html;
26pub mod lookup_crate;
27pub mod lookup_item;
28pub mod search;
29
30use crate::cache::{Cache, CacheConfig};
31use crate::config::PerformanceConfig;
32use rust_mcp_sdk::schema::CallToolError;
33use std::sync::Arc;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum Format {
38 #[default]
40 Markdown,
41 Text,
43 Html,
45 Json,
47}
48
49impl std::fmt::Display for Format {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 Self::Markdown => write!(f, "markdown"),
53 Self::Text => write!(f, "text"),
54 Self::Html => write!(f, "html"),
55 Self::Json => write!(f, "json"),
56 }
57 }
58}
59
60pub fn parse_format(format_str: Option<&str>) -> Result<Format, CallToolError> {
62 match format_str {
63 None => Ok(Format::Markdown),
64 Some(s) => match s.to_lowercase().as_str() {
65 "markdown" => Ok(Format::Markdown),
66 "text" => Ok(Format::Text),
67 "html" => Ok(Format::Html),
68 "json" => Ok(Format::Json),
69 _ => Err(CallToolError::invalid_arguments(
70 "format",
71 Some(format!(
72 "Invalid format '{s}'. Expected one of: markdown, text, html, json"
73 )),
74 )),
75 },
76 }
77}
78
79#[cfg(not(test))]
80const DOCS_RS_BASE_URL: &str = "https://docs.rs";
81
82#[cfg(not(test))]
83const CRATES_IO_BASE_URL: &str = "https://crates.io";
84
85#[must_use]
86#[cfg(test)]
87pub fn docs_rs_base_url() -> String {
89 std::env::var("CRATES_DOCS_DOCS_RS_URL").unwrap_or_else(|_| "https://docs.rs".to_string())
90}
91
92#[must_use]
93#[cfg(not(test))]
94pub fn docs_rs_base_url() -> String {
96 DOCS_RS_BASE_URL.to_string()
97}
98
99#[must_use]
100#[cfg(test)]
101pub fn crates_io_base_url() -> String {
103 std::env::var("CRATES_DOCS_CRATES_IO_URL").unwrap_or_else(|_| "https://crates.io".to_string())
104}
105
106#[must_use]
107#[cfg(not(test))]
108pub fn crates_io_base_url() -> String {
110 CRATES_IO_BASE_URL.to_string()
111}
112#[must_use]
114pub fn build_docs_url(crate_name: &str, version: Option<&str>) -> String {
115 let base_url = docs_rs_base_url();
116 match version {
117 Some(ver) => format!("{base_url}/{crate_name}/{ver}/"),
118 None => format!("{base_url}/{crate_name}/"),
119 }
120}
121
122#[must_use]
124pub fn build_docs_item_url(crate_name: &str, version: Option<&str>, item_path: &str) -> String {
125 let base_url = docs_rs_base_url();
126 let encoded_path = urlencoding::encode(item_path);
127 match version {
128 Some(ver) => format!("{base_url}/{crate_name}/{ver}/?search={encoded_path}"),
129 None => format!("{base_url}/{crate_name}/?search={encoded_path}"),
130 }
131}
132
133#[must_use]
135pub fn build_crates_io_search_url(query: &str, sort: Option<&str>, limit: Option<usize>) -> String {
136 let base_url = crates_io_base_url();
137 let sort = sort.unwrap_or("relevance");
138 let limit = limit.unwrap_or(10);
139 format!(
140 "{}/api/v1/crates?q={}&per_page={}&sort={}",
141 base_url,
142 urlencoding::encode(query),
143 limit,
144 urlencoding::encode(sort)
145 )
146}
147
148pub struct DocService {
158 client: Arc<reqwest_middleware::ClientWithMiddleware>,
159 cache: Arc<dyn Cache>,
160 doc_cache: cache::DocCache,
161}
162
163impl DocService {
164 pub fn new(cache: Arc<dyn Cache>) -> crate::error::Result<Self> {
191 Self::with_config(cache, &CacheConfig::default())
192 }
193
194 pub fn with_config(
210 cache: Arc<dyn Cache>,
211 cache_config: &CacheConfig,
212 ) -> crate::error::Result<Self> {
213 let ttl = cache::DocCacheTtl::from_cache_config(cache_config);
214 let doc_cache = cache::DocCache::with_ttl(cache.clone(), ttl);
215 let client = crate::utils::get_or_init_global_http_client()?;
217 Ok(Self {
218 client,
219 cache,
220 doc_cache,
221 })
222 }
223
224 pub fn with_full_config(
242 cache: Arc<dyn Cache>,
243 cache_config: &CacheConfig,
244 _perf_config: &PerformanceConfig,
245 ) -> crate::error::Result<Self> {
246 let ttl = cache::DocCacheTtl::from_cache_config(cache_config);
247 let doc_cache = cache::DocCache::with_ttl(cache.clone(), ttl);
248 let client = crate::utils::get_or_init_global_http_client()?;
250 Ok(Self {
251 client,
252 cache,
253 doc_cache,
254 })
255 }
256
257 #[must_use]
259 pub fn client(&self) -> &reqwest_middleware::ClientWithMiddleware {
260 &self.client
261 }
262
263 #[must_use]
265 pub fn cache(&self) -> &Arc<dyn Cache> {
266 &self.cache
267 }
268
269 #[must_use]
271 pub fn doc_cache(&self) -> &cache::DocCache {
272 &self.doc_cache
273 }
274
275 pub async fn fetch_html(
292 &self,
293 url: &str,
294 tool_name: Option<&str>,
295 ) -> Result<String, CallToolError> {
296 let response = self.client.get(url).send().await.map_err(|e| {
297 let prefix = tool_name.map_or(String::new(), |n| format!("[{n}] "));
298 CallToolError::from_message(format!("{prefix}HTTP request failed: {e}"))
299 })?;
300
301 let status = response.status();
302 if !status.is_success() {
303 let error_body = response.text().await.map_err(|e| {
304 let prefix = tool_name.map_or(String::new(), |n| format!("[{n}] "));
305 CallToolError::from_message(format!("{prefix}Failed to read error response: {e}"))
306 })?;
307 let prefix = tool_name.map_or(String::new(), |n| format!("[{n}] "));
308 return Err(CallToolError::from_message(format!(
309 "{prefix}Failed to get documentation: HTTP {} - {}",
310 status,
311 if error_body.is_empty() {
312 "No error details"
313 } else {
314 &error_body
315 }
316 )));
317 }
318
319 response.text().await.map_err(|e| {
320 let prefix = tool_name.map_or(String::new(), |n| format!("[{n}] "));
321 CallToolError::from_message(format!("{prefix}Failed to read response: {e}"))
322 })
323 }
324
325 #[must_use]
327 pub fn with_custom_client(
328 cache: Arc<dyn Cache>,
329 cache_config: &CacheConfig,
330 client: Arc<reqwest_middleware::ClientWithMiddleware>,
331 ) -> Self {
332 let ttl = cache::DocCacheTtl::from_cache_config(cache_config);
333 let doc_cache = cache::DocCache::with_ttl(cache.clone(), ttl);
334 Self {
335 client,
336 cache,
337 doc_cache,
338 }
339 }
340}
341
342impl Default for DocService {
343 fn default() -> Self {
344 let cache = Arc::new(crate::cache::memory::MemoryCache::new(1000));
345 Self::new(cache).expect("Failed to create default DocService")
346 }
347}
348
349pub use lookup_crate::LookupCrateTool;
351pub use lookup_item::LookupItemTool;
352pub use search::SearchCratesTool;
353
354pub use cache::DocCacheTtl;
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_doc_service_default() {
363 let service = DocService::default();
364 let _ = service.client();
365 }
367
368 #[test]
369 fn test_doc_service_accessors() {
370 let service = DocService::default();
371 let _ = service.client();
372 let _ = service.client();
373 let _ = service.cache();
374 let _ = service.doc_cache();
375 }
376
377 #[test]
378 fn test_parse_format_none() {
379 assert_eq!(parse_format(None).unwrap(), Format::Markdown);
380 }
381
382 #[test]
383 fn test_parse_format_markdown() {
384 assert_eq!(parse_format(Some("markdown")).unwrap(), Format::Markdown);
385 assert_eq!(parse_format(Some("MARKDOWN")).unwrap(), Format::Markdown);
386 assert_eq!(parse_format(Some("Markdown")).unwrap(), Format::Markdown);
387 }
388
389 #[test]
390 fn test_parse_format_text() {
391 assert_eq!(parse_format(Some("text")).unwrap(), Format::Text);
392 assert_eq!(parse_format(Some("TEXT")).unwrap(), Format::Text);
393 }
394
395 #[test]
396 fn test_parse_format_html() {
397 assert_eq!(parse_format(Some("html")).unwrap(), Format::Html);
398 assert_eq!(parse_format(Some("HTML")).unwrap(), Format::Html);
399 }
400
401 #[test]
402 fn test_parse_format_json() {
403 assert_eq!(parse_format(Some("json")).unwrap(), Format::Json);
404 assert_eq!(parse_format(Some("JSON")).unwrap(), Format::Json);
405 }
406
407 #[test]
408 fn test_parse_format_invalid() {
409 assert!(parse_format(Some("invalid")).is_err());
410 assert!(parse_format(Some("xml")).is_err());
411 assert!(parse_format(Some("")).is_err());
412 }
413
414 #[test]
415 fn test_format_display() {
416 assert_eq!(Format::Markdown.to_string(), "markdown");
417 assert_eq!(Format::Text.to_string(), "text");
418 assert_eq!(Format::Html.to_string(), "html");
419 assert_eq!(Format::Json.to_string(), "json");
420 }
421
422 #[test]
423 fn test_format_default() {
424 assert_eq!(Format::default(), Format::Markdown);
425 }
426
427 #[test]
429 fn test_build_docs_url_without_version() {
430 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
431 let url = build_docs_url("serde", None);
432 assert_eq!(url, "https://docs.rs/serde/");
433 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
434 }
435
436 #[test]
437 fn test_build_docs_url_with_version() {
438 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
439 let url = build_docs_url("serde", Some("1.0.0"));
440 assert_eq!(url, "https://docs.rs/serde/1.0.0/");
441 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
442 }
443
444 #[test]
445 fn test_build_docs_item_url_without_version() {
446 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
447 let url = build_docs_item_url("serde", None, "Serialize");
448 assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
449 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
450 }
451
452 #[test]
453 fn test_build_docs_item_url_with_version() {
454 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
455 let url = build_docs_item_url("serde", Some("1.0.0"), "Serialize");
456 assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
457 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
458 }
459
460 #[test]
461 fn test_build_docs_item_url_encodes_special_chars() {
462 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
463 let url = build_docs_item_url("std", None, "collections::HashMap");
464 assert!(url.contains("collections%3A%3AHashMap"));
465 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
466 }
467
468 #[test]
469 fn test_build_crates_io_search_url_defaults() {
470 std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
471 let url = build_crates_io_search_url("web framework", None, None);
472 assert!(url.contains("crates.io/api/v1/crates"));
473 assert!(url.contains("q=web+framework") || url.contains("q=web%20framework"));
474 assert!(url.contains("per_page=10"));
475 assert!(url.contains("sort=relevance"));
476 std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
477 }
478
479 #[test]
480 fn test_build_crates_io_search_url_with_params() {
481 std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
482 let url = build_crates_io_search_url("async", Some("downloads"), Some(20));
483 assert!(url.contains("crates.io/api/v1/crates"));
484 assert!(url.contains("q=async"));
485 assert!(url.contains("per_page=20"));
486 assert!(url.contains("sort=downloads"));
487 std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
488 }
489
490 #[test]
491 fn test_build_crates_io_search_url_encodes_query() {
492 std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
493 let url = build_crates_io_search_url("web framework", None, None);
494 assert!(url.contains("web+framework") || url.contains("web%20framework"));
495 std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
496 }
497}