Skip to main content

crates_docs/tools/docs/
search.rs

1//! Search crates tool
2#![allow(missing_docs)]
3
4use crate::tools::Tool;
5use async_trait::async_trait;
6use rust_mcp_sdk::macros;
7use rust_mcp_sdk::schema::CallToolError;
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10
11/// Search crates tool parameters
12#[macros::mcp_tool(
13    name = "search_crates",
14    title = "Search Crates",
15    description = "Search for Rust crates from crates.io. Returns a list of matching crates, including name, description, version, downloads, etc. Suitable for discovering and comparing available Rust libraries.",
16    destructive_hint = false,
17    idempotent_hint = true,
18    open_world_hint = false,
19    read_only_hint = true,
20    execution(task_support = "optional"),
21    icons = [
22        (src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
23        (src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
24    ]
25)]
26#[derive(Debug, Clone, Deserialize, Serialize, macros::JsonSchema)]
27pub struct SearchCratesTool {
28    /// Search query
29    #[json_schema(
30        title = "Search Query",
31        description = "Search keywords, e.g.: web framework, async, http client, serialization"
32    )]
33    pub query: String,
34
35    /// Result count limit
36    #[json_schema(
37        title = "Result Limit",
38        description = "Maximum number of results to return, range 1-100",
39        minimum = 1,
40        maximum = 100,
41        default = 10
42    )]
43    pub limit: Option<u32>,
44
45    /// Sort order
46    #[json_schema(
47        title = "Sort Order",
48        description = "Sort order: relevance (default), downloads, recent-downloads, recent-updates, new",
49        default = "relevance"
50    )]
51    pub sort: Option<String>,
52
53    /// Output format
54    #[json_schema(
55        title = "Output Format",
56        description = "Output format: markdown (default), text (plain text), json (raw JSON)",
57        default = "markdown"
58    )]
59    pub format: Option<String>,
60}
61
62const DEFAULT_SEARCH_SORT: &str = "relevance";
63const VALID_SEARCH_SORTS: &[&str] = &[
64    DEFAULT_SEARCH_SORT,
65    "downloads",
66    "recent-downloads",
67    "recent-updates",
68    "new",
69];
70
71/// Search crates tool实现
72pub struct SearchCratesToolImpl {
73    service: Arc<super::DocService>,
74}
75
76fn normalize_search_sort(sort: Option<&str>) -> std::result::Result<String, CallToolError> {
77    match sort {
78        Some(sort) if VALID_SEARCH_SORTS.contains(&sort) => Ok(sort.to_string()),
79        Some(sort) => Err(CallToolError::invalid_arguments(
80            "search_crates",
81            Some(format!(
82                "Invalid sort option '{sort}', expected one of: {}",
83                VALID_SEARCH_SORTS.join(", ")
84            )),
85        )),
86        None => Ok(DEFAULT_SEARCH_SORT.to_string()),
87    }
88}
89
90impl SearchCratesToolImpl {
91    /// Create a new tool instance
92    #[must_use]
93    pub fn new(service: Arc<super::DocService>) -> Self {
94        Self { service }
95    }
96
97    /// Search crates
98    async fn search_crates(
99        &self,
100        query: &str,
101        limit: u32,
102        sort: &str,
103    ) -> std::result::Result<Vec<CrateInfo>, CallToolError> {
104        // Build cache key
105        let cache_key = format!("search:{query}:{sort}:{limit}");
106
107        // Check cache
108        if let Some(cached) = self.service.cache().get(&cache_key).await {
109            return serde_json::from_str(&cached)
110                .map_err(|e| CallToolError::from_message(format!("Cache parsing failed: {e}")));
111        }
112
113        // Build crates.io API URL
114        let url = format!(
115            "https://crates.io/api/v1/crates?q={}&per_page={}&sort={}",
116            urlencoding::encode(query),
117            limit,
118            urlencoding::encode(sort)
119        );
120
121        // 发送 HTTP 请求
122        let response = self
123            .service
124            .client()
125            .get(&url)
126            .header("User-Agent", format!("CratesDocsMCP/{}", crate::VERSION))
127            .send()
128            .await
129            .map_err(|e| CallToolError::from_message(format!("HTTP 请求失败: {e}")))?;
130
131        if !response.status().is_success() {
132            return Err(CallToolError::from_message(format!(
133                "Search failed, status code: {}",
134                response.status()
135            )));
136        }
137
138        let json: serde_json::Value = response
139            .json()
140            .await
141            .map_err(|e| CallToolError::from_message(format!("JSON 解析失败: {e}")))?;
142
143        // 解析响应
144        let crates = parse_crates_response(&json, limit as usize);
145
146        // 缓存结果(5分钟)
147        let cache_value = serde_json::to_string(&crates)
148            .map_err(|e| CallToolError::from_message(format!("序列化失败: {e}")))?;
149
150        self.service
151            .cache()
152            .set(
153                cache_key,
154                cache_value,
155                Some(std::time::Duration::from_secs(300)),
156            )
157            .await
158            .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
159
160        Ok(crates)
161    }
162}
163
164/// Crate 信息
165#[derive(Debug, Clone, Serialize, Deserialize)]
166struct CrateInfo {
167    name: String,
168    description: Option<String>,
169    version: String,
170    downloads: u64,
171    repository: Option<String>,
172    documentation: Option<String>,
173}
174
175/// 解析 crates.io API 响应
176fn parse_crates_response(json: &serde_json::Value, limit: usize) -> Vec<CrateInfo> {
177    let mut crates = Vec::new();
178
179    if let Some(crates_array) = json.get("crates").and_then(|c| c.as_array()) {
180        for crate_item in crates_array.iter().take(limit) {
181            let name = crate_item
182                .get("name")
183                .and_then(|n| n.as_str())
184                .unwrap_or("Unknown")
185                .to_string();
186
187            let description = crate_item
188                .get("description")
189                .and_then(|d| d.as_str())
190                .map(std::string::ToString::to_string);
191
192            let version = crate_item
193                .get("max_version")
194                .and_then(|v| v.as_str())
195                .unwrap_or("0.0.0")
196                .to_string();
197
198            let downloads = crate_item
199                .get("downloads")
200                .and_then(serde_json::Value::as_u64)
201                .unwrap_or(0);
202
203            let repository = crate_item
204                .get("repository")
205                .and_then(|r| r.as_str())
206                .map(std::string::ToString::to_string);
207
208            let documentation = crate_item
209                .get("documentation")
210                .and_then(|d| d.as_str())
211                .map(std::string::ToString::to_string);
212
213            crates.push(CrateInfo {
214                name,
215                description,
216                version,
217                downloads,
218                repository,
219                documentation,
220            });
221        }
222    }
223
224    crates
225}
226
227/// 格式化搜索结果
228fn format_search_results(crates: &[CrateInfo], format: &str) -> String {
229    match format {
230        "json" => serde_json::to_string_pretty(crates).unwrap_or_else(|_| "[]".to_string()),
231        "markdown" => {
232            use std::fmt::Write;
233            let mut output = String::from("# 搜索结果\n\n");
234
235            for (i, crate_info) in crates.iter().enumerate() {
236                writeln!(output, "## {}. {}", i + 1, crate_info.name).unwrap();
237                writeln!(output, "**版本**: {}", crate_info.version).unwrap();
238                writeln!(output, "**下载量**: {}", crate_info.downloads).unwrap();
239
240                if let Some(desc) = &crate_info.description {
241                    writeln!(output, "**描述**: {desc}").unwrap();
242                }
243
244                if let Some(repo) = &crate_info.repository {
245                    writeln!(output, "**仓库**: [链接]({repo})").unwrap();
246                }
247
248                if let Some(docs) = &crate_info.documentation {
249                    writeln!(output, "**文档**: [链接]({docs})").unwrap();
250                }
251
252                writeln!(
253                    output,
254                    "**Docs.rs**: [https://docs.rs/{}/](https://docs.rs/{}/)\n",
255                    crate_info.name, crate_info.name
256                )
257                .unwrap();
258            }
259
260            output
261        }
262        "text" => {
263            use std::fmt::Write;
264            let mut output = String::new();
265
266            for (i, crate_info) in crates.iter().enumerate() {
267                writeln!(output, "{}. {}", i + 1, crate_info.name).unwrap();
268                writeln!(output, "   版本: {}", crate_info.version).unwrap();
269                writeln!(output, "   下载量: {}", crate_info.downloads).unwrap();
270
271                if let Some(desc) = &crate_info.description {
272                    writeln!(output, "   描述: {desc}").unwrap();
273                }
274
275                writeln!(output, "   Docs.rs: https://docs.rs/{}/", crate_info.name).unwrap();
276                writeln!(output).unwrap();
277            }
278
279            output
280        }
281        _ => {
282            // 默认使用 markdown
283            format_search_results(crates, "markdown")
284        }
285    }
286}
287
288#[async_trait]
289impl Tool for SearchCratesToolImpl {
290    fn definition(&self) -> rust_mcp_sdk::schema::Tool {
291        SearchCratesTool::tool()
292    }
293
294    async fn execute(
295        &self,
296        arguments: serde_json::Value,
297    ) -> std::result::Result<
298        rust_mcp_sdk::schema::CallToolResult,
299        rust_mcp_sdk::schema::CallToolError,
300    > {
301        let params: SearchCratesTool = serde_json::from_value(arguments).map_err(|e| {
302            rust_mcp_sdk::schema::CallToolError::invalid_arguments(
303                "search_crates",
304                Some(format!("参数解析失败: {e}")),
305            )
306        })?;
307
308        let limit = params.limit.unwrap_or(10).min(100); // 限制最大100个结果
309        let sort = normalize_search_sort(params.sort.as_deref())?;
310        let crates = self.search_crates(&params.query, limit, &sort).await?;
311
312        let format = params.format.unwrap_or_else(|| "markdown".to_string());
313        let content = format_search_results(&crates, &format);
314
315        Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
316            content.into(),
317        ]))
318    }
319}
320
321impl Default for SearchCratesToolImpl {
322    fn default() -> Self {
323        Self::new(Arc::new(super::DocService::default()))
324    }
325}