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        let cache = Arc::new(crate::cache::memory::MemoryCache::new(1000));
345        Self::new(cache).expect("Failed to create default DocService")
346    }
347}
348
349/// Re-export tool types
350pub use lookup_crate::LookupCrateTool;
351pub use lookup_item::LookupItemTool;
352pub use search::SearchCratesTool;
353
354/// Re-export cache types
355pub 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        // HTTP client is always available after service creation
366    }
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    // URL building tests
428    #[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}