Skip to main content

crates_docs/tools/docs/
mod.rs

1//! Document lookup tool module
2//!
3//! Provides tools and services for querying Rust crate documentation.
4//!
5//! # Submodules
6//!
7//! - `cache`: Document cache
8//! - `html`: HTML processing
9//! - `lookup_crate`: Crate documentation lookup
10//! - `lookup_item`: Item documentation lookup
11//! - `search`: Crate search
12//!
13//! # Examples
14//!
15//! ```rust,no_run
16//! use std::sync::Arc;
17//! use crates_docs::tools::docs::DocService;
18//! use crates_docs::cache::memory::MemoryCache;
19//!
20//! let cache = Arc::new(MemoryCache::new(1000));
21//! let service = DocService::new(cache).expect("Failed to create DocService");
22//! ```
23
24pub 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/// Output format for documentation
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum Format {
38    /// Markdown format
39    #[default]
40    Markdown,
41    /// Plain text format
42    Text,
43    /// HTML format
44    Html,
45    /// JSON format (used by search tool)
46    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
60/// Parse format string into Format enum
61pub 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)]
87/// Get the docs.rs base URL (configurable via environment variable for testing)
88pub 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))]
94/// Get the docs.rs base URL
95pub fn docs_rs_base_url() -> String {
96    DOCS_RS_BASE_URL.to_string()
97}
98
99#[must_use]
100#[cfg(test)]
101/// Get the crates.io base URL (configurable via environment variable for testing)
102pub 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))]
108/// Get the crates.io base URL
109pub fn crates_io_base_url() -> String {
110    CRATES_IO_BASE_URL.to_string()
111}
112/// Build docs.rs URL for crate documentation
113#[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/// Build docs.rs search URL for item lookup
123#[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/// Build crates.io API search URL
134#[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
148/// Document service
149///
150/// Provides centralized management of HTTP client (with auto-retry), cache, and document cache.
151///
152/// # Fields
153///
154/// - `client`: HTTP client with retry middleware (shared reference for connection pool reuse)
155/// - `cache`: Generic cache instance
156/// - `doc_cache`: Document-specific cache
157pub struct DocService {
158    client: Arc<reqwest_middleware::ClientWithMiddleware>,
159    cache: Arc<dyn Cache>,
160    doc_cache: cache::DocCache,
161}
162
163impl DocService {
164    /// Create new document service (with default TTL)
165    ///
166    /// # Arguments
167    ///
168    /// * `cache` - cache instance
169    ///
170    /// # Errors
171    ///
172    /// Returns error if HTTP client creation fails
173    ///
174    /// # Examples
175    ///
176    /// ```rust,no_run
177    /// use std::sync::Arc;
178    /// use crates_docs::tools::docs::DocService;
179    /// use crates_docs::cache::memory::MemoryCache;
180    ///
181    /// let cache = Arc::new(MemoryCache::new(1000));
182    /// let service = DocService::new(cache).expect("Failed to create DocService");
183    /// ```
184    ///
185    /// # Note
186    ///
187    /// This method uses the global HTTP client singleton for connection pool reuse.
188    /// Make sure to call `init_global_http_client()` during server initialization
189    /// for optimal performance.
190    pub fn new(cache: Arc<dyn Cache>) -> crate::error::Result<Self> {
191        Self::with_config(cache, &CacheConfig::default())
192    }
193
194    /// Create new document service (with custom cache config)
195    ///
196    /// # Arguments
197    ///
198    /// * `cache` - cache instance
199    /// * `cache_config` - cache configuration
200    ///
201    /// # Errors
202    ///
203    /// Returns error if HTTP client creation fails
204    ///
205    /// # Note
206    ///
207    /// This method uses the global HTTP client singleton for connection pool reuse.
208    /// If the global client is not initialized, it will be initialized with default config.
209    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        // Use global HTTP client singleton for connection pool reuse
216        let client = crate::utils::get_or_init_global_http_client()?;
217        Ok(Self {
218            client,
219            cache,
220            doc_cache,
221        })
222    }
223
224    /// Create new document service (with full config)
225    ///
226    /// # Arguments
227    ///
228    /// * `cache` - cache instance
229    /// * `cache_config` - cache configuration
230    /// * `perf_config` - performance configuration(used only for initializing global HTTP client if not yet initialized)
231    ///
232    /// # Errors
233    ///
234    /// Returns error if HTTP client creation fails
235    ///
236    /// # Note
237    ///
238    /// This method uses the global HTTP client singleton for connection pool reuse.
239    /// The `perf_config` is used only if the global client hasn't been initialized yet.
240    /// For consistent configuration, call `init_global_http_client()` during server startup.
241    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        // Use global HTTP client singleton for connection pool reuse
249        let client = crate::utils::get_or_init_global_http_client()?;
250        Ok(Self {
251            client,
252            cache,
253            doc_cache,
254        })
255    }
256
257    /// Get HTTP client (with retry middleware)
258    #[must_use]
259    pub fn client(&self) -> &reqwest_middleware::ClientWithMiddleware {
260        &self.client
261    }
262
263    /// Get cache instance
264    #[must_use]
265    pub fn cache(&self) -> &Arc<dyn Cache> {
266        &self.cache
267    }
268
269    /// Get document cache
270    #[must_use]
271    pub fn doc_cache(&self) -> &cache::DocCache {
272        &self.doc_cache
273    }
274
275    /// Fetch HTML content from a URL
276    ///
277    /// This is a shared utility method used by multiple tools to fetch HTML
278    /// from docs.rs and crates.io.
279    ///
280    /// # Arguments
281    ///
282    /// * `url` - The URL to fetch
283    /// * `tool_name` - Optional tool name for better error messages (e.g., "`lookup_crate`", "`lookup_item`")
284    ///
285    /// # Errors
286    ///
287    /// Returns a `CallToolError` if:
288    /// - The HTTP request fails
289    /// - The response status is not successful
290    /// - Reading the response body fails
291    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    /// Create new document service with custom HTTP client (for testing)
326    #[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        // Try to create with fallible initialization
345        Self::try_default_with_fallback()
346    }
347}
348
349impl DocService {
350    /// Create `DocService` with default settings using fallible initialization
351    ///
352    /// This method attempts to create a fully configured HTTP client.
353    /// If that fails, it falls back to a basic client without retry middleware.
354    /// The fallback uses `Client::new()` which is infallible.
355    fn try_default_with_fallback() -> Self {
356        let cache = Arc::new(crate::cache::memory::MemoryCache::new(1000));
357        let cache_config = CacheConfig::default();
358
359        // Try to create client with full configuration (may fail in extreme cases)
360        let client: Arc<reqwest_middleware::ClientWithMiddleware> =
361            if let Ok(c) = crate::utils::HttpClientBuilder::new().build() {
362                Arc::new(c)
363            } else {
364                // Fallback: create a minimal client without retry middleware
365                // Using Client::new() which is infallible - never panics
366                let plain_client = reqwest::Client::new();
367                Arc::new(reqwest_middleware::ClientBuilder::new(plain_client).build())
368            };
369
370        let ttl = cache::DocCacheTtl::from_cache_config(&cache_config);
371        let doc_cache = cache::DocCache::with_ttl(cache.clone(), ttl);
372
373        Self {
374            client,
375            cache,
376            doc_cache,
377        }
378    }
379}
380
381/// Re-export tool types
382pub use lookup_crate::LookupCrateTool;
383pub use lookup_item::LookupItemTool;
384pub use search::SearchCratesTool;
385
386/// Re-export cache types
387pub use cache::DocCacheTtl;
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_doc_service_default() {
395        let service = DocService::default();
396        let _ = service.client();
397        // HTTP client is always available after service creation
398    }
399
400    #[test]
401    fn test_doc_service_accessors() {
402        let service = DocService::default();
403        let _ = service.client();
404        let _ = service.client();
405        let _ = service.cache();
406        let _ = service.doc_cache();
407    }
408
409    #[test]
410    fn test_parse_format_none() {
411        assert_eq!(parse_format(None).unwrap(), Format::Markdown);
412    }
413
414    #[test]
415    fn test_parse_format_markdown() {
416        assert_eq!(parse_format(Some("markdown")).unwrap(), Format::Markdown);
417        assert_eq!(parse_format(Some("MARKDOWN")).unwrap(), Format::Markdown);
418        assert_eq!(parse_format(Some("Markdown")).unwrap(), Format::Markdown);
419    }
420
421    #[test]
422    fn test_parse_format_text() {
423        assert_eq!(parse_format(Some("text")).unwrap(), Format::Text);
424        assert_eq!(parse_format(Some("TEXT")).unwrap(), Format::Text);
425    }
426
427    #[test]
428    fn test_parse_format_html() {
429        assert_eq!(parse_format(Some("html")).unwrap(), Format::Html);
430        assert_eq!(parse_format(Some("HTML")).unwrap(), Format::Html);
431    }
432
433    #[test]
434    fn test_parse_format_json() {
435        assert_eq!(parse_format(Some("json")).unwrap(), Format::Json);
436        assert_eq!(parse_format(Some("JSON")).unwrap(), Format::Json);
437    }
438
439    #[test]
440    fn test_parse_format_invalid() {
441        assert!(parse_format(Some("invalid")).is_err());
442        assert!(parse_format(Some("xml")).is_err());
443        assert!(parse_format(Some("")).is_err());
444    }
445
446    #[test]
447    fn test_format_display() {
448        assert_eq!(Format::Markdown.to_string(), "markdown");
449        assert_eq!(Format::Text.to_string(), "text");
450        assert_eq!(Format::Html.to_string(), "html");
451        assert_eq!(Format::Json.to_string(), "json");
452    }
453
454    #[test]
455    fn test_format_default() {
456        assert_eq!(Format::default(), Format::Markdown);
457    }
458
459    // URL building tests
460    #[test]
461    fn test_build_docs_url_without_version() {
462        std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
463        let url = build_docs_url("serde", None);
464        assert_eq!(url, "https://docs.rs/serde/");
465        std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
466    }
467
468    #[test]
469    fn test_build_docs_url_with_version() {
470        std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
471        let url = build_docs_url("serde", Some("1.0.0"));
472        assert_eq!(url, "https://docs.rs/serde/1.0.0/");
473        std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
474    }
475
476    #[test]
477    fn test_build_docs_item_url_without_version() {
478        std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
479        let url = build_docs_item_url("serde", None, "Serialize");
480        assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
481        std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
482    }
483
484    #[test]
485    fn test_build_docs_item_url_with_version() {
486        std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
487        let url = build_docs_item_url("serde", Some("1.0.0"), "Serialize");
488        assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
489        std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
490    }
491
492    #[test]
493    fn test_build_docs_item_url_encodes_special_chars() {
494        std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
495        let url = build_docs_item_url("std", None, "collections::HashMap");
496        assert!(url.contains("collections%3A%3AHashMap"));
497        std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
498    }
499
500    #[test]
501    fn test_build_crates_io_search_url_defaults() {
502        std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
503        let url = build_crates_io_search_url("web framework", None, None);
504        assert!(url.contains("crates.io/api/v1/crates"));
505        assert!(url.contains("q=web+framework") || url.contains("q=web%20framework"));
506        assert!(url.contains("per_page=10"));
507        assert!(url.contains("sort=relevance"));
508        std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
509    }
510
511    #[test]
512    fn test_build_crates_io_search_url_with_params() {
513        std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
514        let url = build_crates_io_search_url("async", Some("downloads"), Some(20));
515        assert!(url.contains("crates.io/api/v1/crates"));
516        assert!(url.contains("q=async"));
517        assert!(url.contains("per_page=20"));
518        assert!(url.contains("sort=downloads"));
519        std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
520    }
521
522    #[test]
523    fn test_build_crates_io_search_url_encodes_query() {
524        std::env::set_var("CRATES_DOCS_CRATES_IO_URL", "https://crates.io");
525        let url = build_crates_io_search_url("web framework", None, None);
526        assert!(url.contains("web+framework") || url.contains("web%20framework"));
527        std::env::remove_var("CRATES_DOCS_CRATES_IO_URL");
528    }
529}