Skip to main content

crates_docs/tools/docs/
lookup_item.rs

1//! Lookup item documentation tool
2//!
3//! Provides functionality to retrieve documentation for a specific item
4//! (function, struct, trait, module, etc.) from a Rust crate on docs.rs.
5//! Supports search paths like `serde::Serialize`, `std::collections::HashMap`, etc.
6
7#![allow(missing_docs)]
8
9use crate::tools::docs::html;
10use crate::tools::docs::DocService;
11use crate::tools::Tool;
12use async_trait::async_trait;
13use rust_mcp_sdk::schema::CallToolError;
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16
17const TOOL_NAME: &str = "lookup_item";
18
19/// Lookup item documentation tool parameters
20///
21/// Used to specify which crate item to look up and in what format to return the documentation.
22#[rust_mcp_sdk::macros::mcp_tool(
23    name = "lookup_item",
24    title = "Lookup Item Documentation",
25    description = "Get documentation for a specific item (function, struct, trait, module, etc.) from a Rust crate on docs.rs. Supports search paths like serde::Serialize, std::collections::HashMap, etc.",
26    destructive_hint = false,
27    idempotent_hint = true,
28    open_world_hint = false,
29    read_only_hint = true,
30    icons = [
31        (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
32        (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
33    ]
34)]
35/// Parameters for the `lookup_item` tool
36///
37/// Defines the input parameters for retrieving item documentation within a crate,
38/// including the crate name, item path, optional version, and output format.
39#[derive(Debug, Clone, Deserialize, Serialize, rust_mcp_sdk::macros::JsonSchema)]
40pub struct LookupItemTool {
41    /// Crate name containing the item (e.g., "serde", "tokio", "std")
42    #[json_schema(
43        title = "Crate Name",
44        description = "Crate name to lookup, e.g.: serde, tokio, std"
45    )]
46    pub crate_name: String,
47
48    /// Item path within the crate (e.g., `"std::collections::HashMap"`)
49    #[json_schema(
50        title = "Item Path",
51        description = "Item path in format 'module::submodule::item', e.g.: serde::Serialize, tokio::runtime::Runtime, std::collections::HashMap"
52    )]
53    pub item_path: String,
54
55    /// Crate version (optional, defaults to latest)
56    #[json_schema(
57        title = "Version",
58        description = "Crate version. Uses latest version if not specified"
59    )]
60    pub version: Option<String>,
61
62    /// Output format: "markdown", "text", or "html" (defaults to "markdown")
63    #[json_schema(
64        title = "Output Format",
65        description = "Output format: markdown (default), text (plain text), html",
66        default = "markdown"
67    )]
68    pub format: Option<String>,
69}
70
71/// Implementation of the lookup item documentation tool
72///
73/// Handles the execution of item documentation lookups within crates,
74/// including cache management, HTTP fetching from docs.rs, and result formatting.
75pub struct LookupItemToolImpl {
76    /// Shared document service for HTTP requests and caching
77    service: Arc<DocService>,
78}
79
80/// Memoized result of fetching a crate's `all.html` index.
81///
82/// Distinguishes "not fetched yet" from "fetched, but the index does not
83/// exist" so the underlying network request is issued at most once per item
84/// resolution, even when both the full-path and parent-path attempts fall back
85/// to the index.
86enum AllHtmlMemo {
87    Unfetched,
88    Fetched(Option<String>),
89}
90
91impl LookupItemToolImpl {
92    /// Create a new lookup item tool instance
93    #[must_use]
94    pub fn new(service: Arc<DocService>) -> Self {
95        Self { service }
96    }
97
98    /// Build docs.rs search URL for item
99    fn build_search_url(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
100        super::build_docs_item_url(crate_name, version, item_path)
101    }
102
103    async fn fetch_item_html(
104        &self,
105        crate_name: &str,
106        item_path: &str,
107        version: Option<&str>,
108    ) -> std::result::Result<String, CallToolError> {
109        if let Some(cached) = self
110            .service
111            .doc_cache()
112            .get_item_html(crate_name, item_path, version)
113            .await
114        {
115            return Ok(cached.to_string());
116        }
117
118        let html = self
119            .resolve_item_html(crate_name, item_path, version)
120            .await?;
121
122        // Cache write failures must not fail the request (see fetch_item_docs):
123        // the HTML was fetched successfully, so log and continue uncached.
124        if let Err(e) = self
125            .service
126            .doc_cache()
127            .set_item_html(crate_name, item_path, version, html.clone())
128            .await
129        {
130            tracing::warn!("[{TOOL_NAME}] failed to cache item HTML (continuing uncached): {e}");
131        }
132
133        Ok(html)
134    }
135
136    /// Resolve and fetch the HTML for a specific item.
137    ///
138    /// Probes the candidate rustdoc item URLs (`struct.`, `trait.`, `fn.`, ...)
139    /// and returns the first that exists. docs.rs renders in-page search with
140    /// client-side JavaScript, so the `?search=` URL only ever returns the
141    /// crate landing page server-side; therefore, if no direct item page is
142    /// found, it falls back to that crate page so the caller still gets useful
143    /// context instead of a hard error.
144    async fn resolve_item_html(
145        &self,
146        crate_name: &str,
147        item_path: &str,
148        version: Option<&str>,
149    ) -> std::result::Result<String, CallToolError> {
150        // Reuse a single `all.html` fetch across the full-path and parent-path
151        // resolution attempts. Both attempts consult the same crate-level
152        // `all.html` index, so memoizing it here avoids a duplicate network
153        // round trip when neither path resolves via a direct item page.
154        let mut all_html_memo = AllHtmlMemo::Unfetched;
155        if let Some(html) = self
156            .try_resolve_item_path(crate_name, item_path, version, &mut all_html_memo)
157            .await?
158        {
159            return Ok(html);
160        }
161
162        // Method / associated-item fallback: `Type::member` and trait methods
163        // have no standalone rustdoc page; the member is documented on the
164        // containing type's page. If the full path did not resolve, retry with
165        // the parent path so the caller gets the focused type page (e.g. the
166        // `Vec` struct for `Vec::push`) instead of the entire crate overview.
167        if let Some((parent, _member)) = item_path.rsplit_once("::") {
168            let parent = parent.trim();
169            if !parent.is_empty() {
170                if let Some(html) = self
171                    .try_resolve_item_path(crate_name, parent, version, &mut all_html_memo)
172                    .await?
173                {
174                    return Ok(html);
175                }
176            }
177        }
178
179        // Fallback: the crate page (legacy `?search=` behaviour).
180        let url = Self::build_search_url(crate_name, item_path, version);
181        self.service.fetch_html(&url, Some(TOOL_NAME)).await
182    }
183
184    /// Probe the candidate rustdoc item pages and the crate `all.html`
185    /// re-export index for an exact item path. Returns the page HTML if found,
186    /// or `None` if neither path resolves.
187    async fn try_resolve_item_path(
188        &self,
189        crate_name: &str,
190        item_path: &str,
191        version: Option<&str>,
192        all_html_memo: &mut AllHtmlMemo,
193    ) -> std::result::Result<Option<String>, CallToolError> {
194        let candidates = super::build_docs_item_url_candidates(crate_name, version, item_path);
195        for url in candidates {
196            if let Some(html) = self
197                .service
198                .fetch_html_optional(&url, Some(TOOL_NAME))
199                .await?
200            {
201                return Ok(Some(html));
202            }
203        }
204
205        // Re-export fallback: consult the crate's `all.html` index to resolve
206        // items that have no stub page at the path implied by their name
207        // (e.g. `tokio::spawn`, actually defined at `tokio::task::spawn`).
208        let item_name = item_path.rsplit("::").next().unwrap_or(item_path).trim();
209        if !item_name.is_empty() {
210            // Lazily fetch `all.html` exactly once, memoizing the result (which
211            // may itself be `None` when the index does not exist) so a second
212            // resolution attempt for the parent path reuses it instead of
213            // issuing a duplicate request.
214            if matches!(all_html_memo, AllHtmlMemo::Unfetched) {
215                let all_url = super::build_docs_all_items_url(crate_name, version);
216                // Bind the fallible await to a `let` so the `?` temporary is
217                // dropped at the statement boundary and not held across a later
218                // await (which would make the future non-`Send`).
219                let fetched = self
220                    .service
221                    .fetch_html_optional(&all_url, Some(TOOL_NAME))
222                    .await?;
223                *all_html_memo = AllHtmlMemo::Fetched(fetched);
224            }
225            // Compute the resolved URL in a scope that ends before the next
226            // await so the borrow of `all_html_memo` is not held across it.
227            let item_url = {
228                let all_html = match &*all_html_memo {
229                    AllHtmlMemo::Fetched(html) => html.as_deref(),
230                    AllHtmlMemo::Unfetched => None,
231                };
232                all_html.and_then(|html| {
233                    super::find_item_url_in_all_html(crate_name, version, html, item_name)
234                })
235            };
236            if let Some(item_url) = item_url {
237                let resolved = self
238                    .service
239                    .fetch_html_optional(&item_url, Some(TOOL_NAME))
240                    .await?;
241                if let Some(html) = resolved {
242                    return Ok(Some(html));
243                }
244            }
245        }
246
247        Ok(None)
248    }
249
250    /// Get item documentation (markdown format)
251    ///
252    /// Returns `Arc<str>` to preserve shared ownership on cache hits,
253    /// avoiding unnecessary cloning of large documentation strings.
254    async fn fetch_item_docs(
255        &self,
256        crate_name: &str,
257        item_path: &str,
258        version: Option<&str>,
259    ) -> std::result::Result<Arc<str>, CallToolError> {
260        // Try cache first - returns Arc<str> directly without cloning
261        if let Some(cached) = self
262            .service
263            .doc_cache()
264            .get_item_docs(crate_name, item_path, version)
265            .await
266        {
267            return Ok(cached);
268        }
269
270        let html = self.fetch_item_html(crate_name, item_path, version).await?;
271
272        // Extract search results into Arc<str> for shared ownership
273        let docs: Arc<str> =
274            Arc::from(html::extract_search_results(&html, item_path).into_boxed_str());
275
276        // Cache the result. A cache write failure (e.g. a Redis outage) must
277        // not fail the user's request: the documentation was fetched
278        // successfully, so log and continue with an uncached result.
279        if let Err(e) = self
280            .service
281            .doc_cache()
282            .set_item_docs(crate_name, item_path, version, docs.to_string())
283            .await
284        {
285            tracing::warn!("[{TOOL_NAME}] failed to cache item docs (continuing uncached): {e}");
286        }
287
288        Ok(docs)
289    }
290
291    /// Get item documentation as plain text
292    async fn fetch_item_docs_as_text(
293        &self,
294        crate_name: &str,
295        item_path: &str,
296        version: Option<&str>,
297    ) -> std::result::Result<String, CallToolError> {
298        let html = self.fetch_item_html(crate_name, item_path, version).await?;
299        let body = html::extract_documentation_as_text(&html);
300        // Mirror the markdown fallback note. `is_item_fallback_page` inspects
301        // the page `<h1>` so it catches both the containing-type fallback
302        // (e.g. the `Value` enum page for `Value::is_null`) and the crate
303        // overview fallback, and stays correct on cache replays.
304        let note = if html::is_item_fallback_page(&html, item_path) {
305            format!(
306                "No dedicated documentation page was found for '{item_path}'; showing the closest available page (its containing type or the crate overview) instead. It may be a method, associated item, or trait method, or it may not exist.\n\n"
307            )
308        } else {
309            String::new()
310        };
311        Ok(format!("Documentation: {item_path}\n\n{note}{body}"))
312    }
313
314    /// Get item documentation as raw HTML
315    async fn fetch_item_docs_as_html(
316        &self,
317        crate_name: &str,
318        item_path: &str,
319        version: Option<&str>,
320    ) -> std::result::Result<String, CallToolError> {
321        let html = self.fetch_item_html(crate_name, item_path, version).await?;
322        let body = html::extract_documentation_html(&html);
323        // Mirror the markdown/text fallback note so all three formats are
324        // consistent. `is_item_fallback_page` inspects the page `<h1>` to catch
325        // both the containing-type fallback and the crate overview fallback,
326        // and stays correct on cache replays.
327        if html::is_item_fallback_page(&html, item_path) {
328            // item_path is validated to [A-Za-z0-9_:-]; escape defensively
329            // anyway since this is an HTML context.
330            let safe_path = item_path
331                .replace('&', "&amp;")
332                .replace('<', "&lt;")
333                .replace('>', "&gt;");
334            return Ok(format!(
335                "<p><em>No dedicated documentation page was found for '{safe_path}'; showing the closest available page (its containing type or the crate overview) instead. It may be a method, associated item, or trait method, or it may not exist.</em></p>\n{body}"
336            ));
337        }
338        Ok(body)
339    }
340}
341
342#[async_trait]
343impl Tool for LookupItemToolImpl {
344    fn definition(&self) -> rust_mcp_sdk::schema::Tool {
345        LookupItemTool::tool()
346    }
347
348    async fn execute(
349        &self,
350        arguments: serde_json::Value,
351    ) -> std::result::Result<
352        rust_mcp_sdk::schema::CallToolResult,
353        rust_mcp_sdk::schema::CallToolError,
354    > {
355        let mut params: LookupItemTool = serde_json::from_value(arguments).map_err(|e| {
356            rust_mcp_sdk::schema::CallToolError::invalid_arguments(
357                "lookup_item",
358                Some(format!("Parameter parsing failed: {e}")),
359            )
360        })?;
361
362        super::validate_crate_name(TOOL_NAME, &params.crate_name)?;
363        super::validate_version(TOOL_NAME, params.version.as_deref())?;
364        super::validate_item_path(TOOL_NAME, &params.item_path)?;
365        // Normalise surrounding whitespace so it does not leak into headings or
366        // candidate URL construction.
367        params.crate_name = params.crate_name.trim().to_string();
368        if let Some(version) = params.version.as_mut() {
369            *version = super::normalize_version(version);
370        }
371        params.item_path = params.item_path.trim().to_string();
372
373        // Propagate the detailed parse error (e.g. "Invalid format 'xml'. Expected
374        // one of: ...") rather than masking it with a generic message, so callers
375        // get actionable feedback.
376        let format = super::parse_format(TOOL_NAME, params.format.as_deref(), super::DOC_FORMATS)?;
377        let content = match format {
378            super::Format::Text => {
379                self.fetch_item_docs_as_text(
380                    &params.crate_name,
381                    &params.item_path,
382                    params.version.as_deref(),
383                )
384                .await?
385            }
386            super::Format::Html => {
387                self.fetch_item_docs_as_html(
388                    &params.crate_name,
389                    &params.item_path,
390                    params.version.as_deref(),
391                )
392                .await?
393            }
394            super::Format::Json => {
395                return Err(rust_mcp_sdk::schema::CallToolError::invalid_arguments(
396                    "lookup_item",
397                    Some(
398                        "Invalid format 'json'. This tool supports: markdown, text, html"
399                            .to_string(),
400                    ),
401                ))
402            }
403            super::Format::Markdown => self
404                .fetch_item_docs(
405                    &params.crate_name,
406                    &params.item_path,
407                    params.version.as_deref(),
408                )
409                .await
410                .map(|arc| arc.to_string())?,
411        };
412
413        Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
414            content.into(),
415        ]))
416    }
417}
418
419impl Default for LookupItemToolImpl {
420    fn default() -> Self {
421        Self::new(Arc::new(super::DocService::default()))
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use serial_test::serial;
429
430    #[test]
431    #[serial]
432    fn test_build_search_url_without_version() {
433        std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
434        let url = LookupItemToolImpl::build_search_url("serde", "Serialize", None);
435        assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
436        std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
437    }
438
439    #[test]
440    #[serial]
441    fn test_build_search_url_with_version() {
442        std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
443        let url = LookupItemToolImpl::build_search_url("serde", "Serialize", Some("1.0.0"));
444        assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
445        std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
446    }
447
448    #[test]
449    #[serial]
450    fn test_build_search_url_encodes_special_chars() {
451        std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
452        let url = LookupItemToolImpl::build_search_url("std", "collections::HashMap", None);
453        assert!(url.contains("collections%3A%3AHashMap"));
454        std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
455    }
456}