Skip to main content

crates_docs/tools/docs/
lookup_item.rs

1//! Lookup item documentation tool
2#![allow(missing_docs)]
3
4use crate::tools::docs::html;
5use crate::tools::docs::DocService;
6use crate::tools::Tool;
7use async_trait::async_trait;
8use rust_mcp_sdk::schema::CallToolError;
9use serde::{Deserialize, Serialize};
10use std::sync::Arc;
11
12/// Lookup item documentation tool
13#[rust_mcp_sdk::macros::mcp_tool(
14    name = "lookup_item",
15    title = "查找 Crate 项目文档",
16    description = "从 docs.rs 获取 Rust crate 中特定项目(函数、结构体、trait、模块等)的文档。适用于查找特定 API 的详细用法和签名。支持搜索路径如 serde::Serialize、std::collections::HashMap 等。",
17    destructive_hint = false,
18    idempotent_hint = true,
19    open_world_hint = false,
20    read_only_hint = true,
21    execution(task_support = "optional"),
22    icons = [
23        (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
24        (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
25    ]
26)]
27#[allow(missing_docs)]
28#[derive(Debug, Clone, Deserialize, Serialize, rust_mcp_sdk::macros::JsonSchema)]
29pub struct LookupItemTool {
30    /// Crate name
31    #[json_schema(
32        title = "Crate 名称",
33        description = "要查找的 Crate name,例如:serde、tokio、std"
34    )]
35    pub crate_name: String,
36
37    /// Item path (e.g., `std::collections::HashMap`)
38    #[json_schema(
39        title = "项目路径",
40        description = "要查找的项目路径,格式为 '模块::子模块::项目名'。例如:serde::Serialize、tokio::runtime::Runtime、std::collections::HashMap"
41    )]
42    pub item_path: String,
43
44    /// Version (optional, defaults to latest)
45    #[json_schema(
46        title = "版本号",
47        description = "指定 crate 版本号。不指定则使用最新版本"
48    )]
49    pub version: Option<String>,
50
51    /// Output format: markdown, text, or html
52    #[json_schema(
53        title = "输出格式",
54        description = "Documentation output format: markdown (default), text (plain text), html",
55        default = "markdown"
56    )]
57    pub format: Option<String>,
58}
59
60/// Lookup item documentation tool implementation
61pub struct LookupItemToolImpl {
62    service: Arc<DocService>,
63}
64
65impl LookupItemToolImpl {
66    /// Create a new lookup item tool instance
67    #[must_use]
68    pub fn new(service: Arc<DocService>) -> Self {
69        Self { service }
70    }
71
72    /// Build docs.rs search URL for item
73    fn build_search_url(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
74        let encoded_path = urlencoding::encode(item_path);
75        match version {
76            Some(ver) => format!("https://docs.rs/{crate_name}/{ver}/?search={encoded_path}"),
77            None => format!("https://docs.rs/{crate_name}/?search={encoded_path}"),
78        }
79    }
80
81    /// Fetch HTML from docs.rs
82    async fn fetch_html(&self, url: &str) -> std::result::Result<String, CallToolError> {
83        let response = self
84            .service
85            .client()
86            .get(url)
87            .send()
88            .await
89            .map_err(|e| CallToolError::from_message(format!("HTTP request failed: {e}")))?;
90
91        if !response.status().is_success() {
92            return Err(CallToolError::from_message(format!(
93                "Failed to get item documentation: HTTP {} - {}",
94                response.status(),
95                response.text().await.unwrap_or_default()
96            )));
97        }
98
99        response
100            .text()
101            .await
102            .map_err(|e| CallToolError::from_message(format!("Failed to read response: {e}")))
103    }
104
105    /// Get item documentation (markdown format)
106    async fn fetch_item_docs(
107        &self,
108        crate_name: &str,
109        item_path: &str,
110        version: Option<&str>,
111    ) -> std::result::Result<String, CallToolError> {
112        // Try cache first
113        if let Some(cached) = self
114            .service
115            .doc_cache()
116            .get_item_docs(crate_name, item_path, version)
117            .await
118        {
119            return Ok(cached);
120        }
121
122        // Fetch from docs.rs
123        let url = Self::build_search_url(crate_name, item_path, version);
124        let html = self.fetch_html(&url).await?;
125
126        // Extract search results
127        let docs = html::extract_search_results(&html, item_path);
128
129        // Cache result
130        self.service
131            .doc_cache()
132            .set_item_docs(crate_name, item_path, version, docs.clone())
133            .await
134            .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
135
136        Ok(docs)
137    }
138
139    /// Get item documentation as plain text
140    async fn fetch_item_docs_as_text(
141        &self,
142        crate_name: &str,
143        item_path: &str,
144        version: Option<&str>,
145    ) -> std::result::Result<String, CallToolError> {
146        let url = Self::build_search_url(crate_name, item_path, version);
147        let html = self.fetch_html(&url).await?;
148        Ok(format!(
149            "搜索结果: {}\n\n{}",
150            item_path,
151            html::html_to_text(&html)
152        ))
153    }
154
155    /// Get item documentation as raw HTML
156    async fn fetch_item_docs_as_html(
157        &self,
158        crate_name: &str,
159        item_path: &str,
160        version: Option<&str>,
161    ) -> std::result::Result<String, CallToolError> {
162        let url = Self::build_search_url(crate_name, item_path, version);
163        self.fetch_html(&url).await
164    }
165}
166
167#[async_trait]
168impl Tool for LookupItemToolImpl {
169    fn definition(&self) -> rust_mcp_sdk::schema::Tool {
170        LookupItemTool::tool()
171    }
172
173    async fn execute(
174        &self,
175        arguments: serde_json::Value,
176    ) -> std::result::Result<
177        rust_mcp_sdk::schema::CallToolResult,
178        rust_mcp_sdk::schema::CallToolError,
179    > {
180        let params: LookupItemTool = serde_json::from_value(arguments).map_err(|e| {
181            rust_mcp_sdk::schema::CallToolError::invalid_arguments(
182                "lookup_item",
183                Some(format!("Parameter parsing failed: {e}")),
184            )
185        })?;
186
187        let format = params.format.as_deref().unwrap_or("markdown");
188        let content = match format {
189            "text" => {
190                self.fetch_item_docs_as_text(
191                    &params.crate_name,
192                    &params.item_path,
193                    params.version.as_deref(),
194                )
195                .await?
196            }
197            "html" => {
198                self.fetch_item_docs_as_html(
199                    &params.crate_name,
200                    &params.item_path,
201                    params.version.as_deref(),
202                )
203                .await?
204            }
205            _ => {
206                // "markdown" and other formats default to markdown
207                self.fetch_item_docs(
208                    &params.crate_name,
209                    &params.item_path,
210                    params.version.as_deref(),
211                )
212                .await?
213            }
214        };
215
216        Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
217            content.into(),
218        ]))
219    }
220}
221
222impl Default for LookupItemToolImpl {
223    fn default() -> Self {
224        Self::new(Arc::new(super::DocService::default()))
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_build_search_url_without_version() {
234        let url = LookupItemToolImpl::build_search_url("serde", "Serialize", None);
235        assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
236    }
237
238    #[test]
239    fn test_build_search_url_with_version() {
240        let url = LookupItemToolImpl::build_search_url("serde", "Serialize", Some("1.0.0"));
241        assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
242    }
243
244    #[test]
245    fn test_build_search_url_encodes_special_chars() {
246        let url = LookupItemToolImpl::build_search_url("std", "collections::HashMap", None);
247        assert!(url.contains("collections%3A%3AHashMap"));
248    }
249}