Skip to main content

crates_docs/tools/docs/
search.rs

1//! Search crates tool
2//!
3//! Provides functionality to search for Rust crates from crates.io.
4//! Returns a list of matching crates with metadata like name, description,
5//! version, downloads, etc.
6
7#![allow(missing_docs)]
8
9use crate::tools::Tool;
10use async_trait::async_trait;
11use rust_mcp_sdk::macros;
12use rust_mcp_sdk::schema::CallToolError;
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15
16const DEFAULT_SEARCH_LIMIT: u32 = 10;
17const ESTIMATED_MARKDOWN_ENTRY_SIZE: usize = 200;
18const ESTIMATED_TEXT_ENTRY_SIZE: usize = 100;
19
20/// Search crates tool parameters
21///
22/// Used to specify search criteria for finding Rust crates on crates.io.
23#[macros::mcp_tool(
24    name = "search_crates",
25    title = "Search Crates",
26    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.",
27    destructive_hint = false,
28    idempotent_hint = true,
29    open_world_hint = false,
30    read_only_hint = true,
31    execution(task_support = "optional"),
32    icons = [
33        (src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
34        (src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
35    ]
36)]
37/// Parameters for the `search_crates` tool
38///
39/// Defines the input parameters for searching Rust crates on crates.io,
40/// including the search query, result limit, sort order, and output format.
41#[derive(Debug, Clone, Deserialize, Serialize, macros::JsonSchema)]
42pub struct SearchCratesTool {
43    /// Search keywords (e.g., "web framework", "async", "http client")
44    #[json_schema(
45        title = "Search Query",
46        description = "Search keywords, e.g.: web framework, async, http client, serialization"
47    )]
48    pub query: String,
49
50    /// Maximum number of results to return (range 1-100, defaults to 10)
51    #[json_schema(
52        title = "Result Limit",
53        description = "Maximum number of results to return, range 1-100",
54        minimum = 1,
55        maximum = 100,
56        default = 10
57    )]
58    pub limit: Option<u32>,
59
60    /// Sort order: "relevance", "downloads", "recent-downloads", "recent-updates", "new"
61    #[json_schema(
62        title = "Sort Order",
63        description = "Sort order: relevance (default), downloads, recent-downloads, recent-updates, new",
64        default = "relevance"
65    )]
66    pub sort: Option<String>,
67
68    /// Output format: "markdown", "text", or "json" (defaults to "markdown")
69    #[json_schema(
70        title = "Output Format",
71        description = "Output format: markdown (default), text (plain text), json (raw JSON)",
72        default = "markdown"
73    )]
74    pub format: Option<String>,
75}
76
77const DEFAULT_SEARCH_SORT: &str = "relevance";
78const VALID_SEARCH_SORTS: &[&str] = &[
79    DEFAULT_SEARCH_SORT,
80    "downloads",
81    "recent-downloads",
82    "recent-updates",
83    "new",
84];
85
86/// Crates.io search response (typed deserialization)
87#[derive(Debug, Deserialize)]
88struct SearchCratesResponse {
89    crates: Vec<SearchCrateRecord>,
90}
91
92/// Individual crate record from crates.io search
93#[derive(Debug, Deserialize)]
94struct SearchCrateRecord {
95    name: String,
96    #[serde(default)]
97    description: Option<String>,
98    #[serde(default = "default_max_version")]
99    max_version: String,
100    #[serde(default)]
101    downloads: u64,
102    #[serde(default)]
103    repository: Option<String>,
104    #[serde(default)]
105    documentation: Option<String>,
106}
107
108fn default_max_version() -> String {
109    "0.0.0".to_string()
110}
111
112/// Implementation of the search crates tool
113///
114/// Handles the execution of crate searches on crates.io, including
115/// cache management, HTTP requests, and result formatting.
116pub struct SearchCratesToolImpl {
117    /// Shared document service for HTTP requests and caching
118    service: Arc<super::DocService>,
119}
120
121fn normalize_search_sort(sort: Option<&str>) -> std::result::Result<String, CallToolError> {
122    match sort {
123        Some(sort) if VALID_SEARCH_SORTS.contains(&sort) => Ok(sort.to_string()),
124        Some(sort) => Err(CallToolError::invalid_arguments(
125            "search_crates",
126            Some(format!(
127                "Invalid sort option '{sort}', expected one of: {}",
128                VALID_SEARCH_SORTS.join(", ")
129            )),
130        )),
131        None => Ok(DEFAULT_SEARCH_SORT.to_string()),
132    }
133}
134
135impl SearchCratesToolImpl {
136    /// Create a new tool instance
137    #[must_use]
138    pub fn new(service: Arc<super::DocService>) -> Self {
139        Self { service }
140    }
141
142    /// Search crates
143    async fn search_crates(
144        &self,
145        query: &str,
146        limit: u32,
147        sort: &str,
148    ) -> std::result::Result<Vec<CrateInfo>, CallToolError> {
149        // Check cache using DocCache API
150        if let Some(cached) = self
151            .service
152            .doc_cache()
153            .get_search_results(query, limit, Some(sort))
154            .await
155        {
156            return serde_json::from_str(&cached)
157                .map_err(|e| CallToolError::from_message(format!("Cache parsing failed: {e}")));
158        }
159
160        // Build URL using helper function
161        let url = super::build_crates_io_search_url(query, Some(sort), Some(limit as usize));
162
163        let response = self
164            .service
165            .client()
166            .get(&url)
167            .header("User-Agent", format!("CratesDocsMCP/{}", crate::VERSION))
168            .send()
169            .await
170            .map_err(|e| CallToolError::from_message(format!("HTTP request failed: {e}")))?;
171
172        if !response.status().is_success() {
173            return Err(CallToolError::from_message(format!(
174                "Search failed, status code: {}",
175                response.status()
176            )));
177        }
178
179        // Use typed deserialization instead of serde_json::Value
180        let search_response: SearchCratesResponse = response
181            .json()
182            .await
183            .map_err(|e| CallToolError::from_message(format!("JSON parsing failed: {e}")))?;
184
185        let crates = parse_crates_response(search_response, limit as usize);
186
187        let cache_value = serde_json::to_string(&crates)
188            .map_err(|e| CallToolError::from_message(format!("Serialization failed: {e}")))?;
189
190        // Set cache using DocCache API
191        self.service
192            .doc_cache()
193            .set_search_results(query, limit, Some(sort), cache_value)
194            .await
195            .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
196
197        Ok(crates)
198    }
199}
200
201/// Crate information from search results
202#[derive(Debug, Clone, Serialize, Deserialize)]
203struct CrateInfo {
204    /// Crate name
205    name: String,
206    /// Crate description
207    description: Option<String>,
208    /// Latest version
209    version: String,
210    /// Total downloads
211    downloads: u64,
212    /// Repository URL
213    repository: Option<String>,
214    /// Documentation URL
215    documentation: Option<String>,
216}
217
218#[inline]
219fn parse_crates_response(response: SearchCratesResponse, limit: usize) -> Vec<CrateInfo> {
220    response
221        .crates
222        .into_iter()
223        .take(limit)
224        .map(|crate_record| CrateInfo {
225            name: crate_record.name,
226            description: crate_record.description,
227            version: crate_record.max_version,
228            downloads: crate_record.downloads,
229            repository: crate_record.repository,
230            documentation: crate_record.documentation,
231        })
232        .collect()
233}
234
235#[inline]
236fn format_search_results(crates: &[CrateInfo], format: super::Format) -> String {
237    match format {
238        super::Format::Json => {
239            serde_json::to_string_pretty(crates).unwrap_or_else(|_| "[]".to_string())
240        }
241        super::Format::Text => format_text_results(crates),
242        _ => format_markdown_results(crates),
243    }
244}
245
246fn format_markdown_results(crates: &[CrateInfo]) -> String {
247    // SAFETY: writeln! to String never fails (writes to memory buffer). unwrap() is safe here.
248    use std::fmt::Write;
249    let estimated_size = crates.len().saturating_mul(ESTIMATED_MARKDOWN_ENTRY_SIZE) + 20;
250    let mut output = String::with_capacity(estimated_size);
251    output.push_str("# Search Results\n\n");
252
253    for (i, crate_info) in crates.iter().enumerate() {
254        writeln!(output, "## {}. {}", i + 1, crate_info.name).unwrap();
255        writeln!(output, "**Version**: {}", crate_info.version).unwrap();
256        writeln!(output, "**Downloads**: {}", crate_info.downloads).unwrap();
257
258        if let Some(desc) = &crate_info.description {
259            writeln!(output, "**Description**: {desc}").unwrap();
260        }
261
262        if let Some(repo) = &crate_info.repository {
263            writeln!(output, "**Repository**: [Link]({repo})").unwrap();
264        }
265
266        if let Some(docs) = &crate_info.documentation {
267            writeln!(output, "**Documentation**: [Link]({docs})").unwrap();
268        }
269
270        writeln!(
271            output,
272            "**Docs.rs**: [https://docs.rs/{}/](https://docs.rs/{}/)\n",
273            crate_info.name, crate_info.name
274        )
275        .unwrap();
276    }
277
278    output
279}
280
281fn format_text_results(crates: &[CrateInfo]) -> String {
282    // SAFETY: writeln! to String never fails (writes to memory buffer). unwrap() is safe here.
283    use std::fmt::Write;
284    let estimated_size = crates.len().saturating_mul(ESTIMATED_TEXT_ENTRY_SIZE);
285    let mut output = String::with_capacity(estimated_size);
286
287    for (i, crate_info) in crates.iter().enumerate() {
288        writeln!(output, "{}. {}", i + 1, crate_info.name).unwrap();
289        writeln!(output, "   Version: {}", crate_info.version).unwrap();
290        writeln!(output, "   Downloads: {}", crate_info.downloads).unwrap();
291
292        if let Some(desc) = &crate_info.description {
293            writeln!(output, "   Description: {desc}").unwrap();
294        }
295
296        writeln!(output, "   Docs.rs: https://docs.rs/{}/", crate_info.name).unwrap();
297        writeln!(output).unwrap();
298    }
299
300    output
301}
302
303#[async_trait]
304impl Tool for SearchCratesToolImpl {
305    fn definition(&self) -> rust_mcp_sdk::schema::Tool {
306        SearchCratesTool::tool()
307    }
308
309    async fn execute(
310        &self,
311        arguments: serde_json::Value,
312    ) -> std::result::Result<
313        rust_mcp_sdk::schema::CallToolResult,
314        rust_mcp_sdk::schema::CallToolError,
315    > {
316        let params: SearchCratesTool = serde_json::from_value(arguments).map_err(|e| {
317            rust_mcp_sdk::schema::CallToolError::invalid_arguments(
318                "search_crates",
319                Some(format!("Parameter parsing failed: {e}")),
320            )
321        })?;
322
323        let limit = params.limit.unwrap_or(DEFAULT_SEARCH_LIMIT).min(100);
324        let sort = normalize_search_sort(params.sort.as_deref())?;
325        let crates = self.search_crates(&params.query, limit, &sort).await?;
326
327        let format = super::parse_format(params.format.as_deref())?;
328        let content = format_search_results(&crates, format);
329
330        Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
331            content.into(),
332        ]))
333    }
334}
335
336impl Default for SearchCratesToolImpl {
337    fn default() -> Self {
338        Self::new(Arc::new(super::DocService::default()))
339    }
340}