import { Type } from "typebox"; import { HttpError, HttpStatusError } from "../shared/http"; import { serializeResponseWithMeta } from "../shared/serialize"; import { pyGet } from "../shared/pyget"; import { BaseProvider, type HttpClientFactory, type ProviderMetadata, type ProviderResult, type ToolDefinition, } from "./base"; export class CratesProvider extends BaseProvider { static readonly BASE_URL = "https://crates.io/api/v1"; static readonly MIN_REQUEST_INTERVAL = 1.0; private _lastRequestTime = 0; private _lock: Promise = Promise.resolve(); constructor(httpClientFactory: HttpClientFactory) { super(httpClientFactory); } getMetadata(): ProviderMetadata { return { name: "crates", description: "Rust crates.io package registry metadata", exposeAsTool: true, toolNames: ["search_crates", "crates_metadata"], supportsLibrarySearch: true, requiredEnvVars: [], optionalEnvVars: [], toolTiers: { search_crates: { tier: 3, deferRecommended: true, category: "search", }, crates_metadata: { tier: 3, deferRecommended: true, category: "metadata", }, }, }; } async searchLibrary(library: string, limit = 5): Promise { try { const data = await this._fetchCratesSearch(library, limit); return { success: true, data: this._formatSearchResults(library, data, limit), providerName: "crates", }; } catch (error) { if (error instanceof HttpStatusError) { if (error.response.status === 404) { return { success: false, error: null, providerName: "crates", }; } return { success: false, error: `crates.io returned ${error.response.status}`, providerName: "crates", }; } if (error instanceof HttpError) { return { success: false, error: `crates.io request failed: ${error.message}`, providerName: "crates", }; } return { success: false, error: null, providerName: "crates", }; } } private async _rateLimit(): Promise { let release: (() => void) | undefined; const previous = this._lock; this._lock = new Promise((resolve) => { release = resolve; }); await previous; try { const now = Date.now() / 1000; const elapsed = now - this._lastRequestTime; if (elapsed < CratesProvider.MIN_REQUEST_INTERVAL) { const waitTime = CratesProvider.MIN_REQUEST_INTERVAL - elapsed; this._lastRequestTime = now + waitTime; await new Promise((resolve) => { globalThis.setTimeout(resolve, waitTime * 1000); }); } else { this._lastRequestTime = now; } } finally { release?.(); } } private async _fetchCratesSearch(query: string, perPage = 5): Promise> { await this._rateLimit(); const client = await this.httpClient(); const response = (await client.get(`${CratesProvider.BASE_URL}/crates`, { params: { q: query, per_page: Math.min(perPage, 100), page: 1, }, })) as { text: string; raiseForStatus(): void }; response.raiseForStatus(); return JSON.parse(response.text) as { crates?: Array>; meta?: { total?: unknown }; }; } private _formatSearchResults( query: string, data: { crates?: Array>; meta?: { total?: unknown }; }, perPage: number, ): Record { const crates = Array.isArray(data.crates) ? data.crates : []; const formattedResults = crates.slice(0, perPage).map((crate) => ({ name: crate?.name ?? null, version: crate?.max_version ?? null, // python `crate.get("description", "")` — default only when key absent description: pyGet(crate, "description", "") as string, downloads: crate?.downloads ?? null, recent_downloads: crate?.recent_downloads ?? null, repository: crate?.repository ?? null, documentation: crate?.documentation ?? null, homepage: crate?.homepage ?? null, license: crate?.license ?? null, // python `crate.get("categories", [])` / `crate.get("keywords", [])` — // API returns null when empty; `??` would wrongly coerce to []. categories: pyGet(crate, "categories", []), keywords: pyGet(crate, "keywords", []), created_at: crate?.created_at ?? null, updated_at: crate?.updated_at ?? null, })); return { query, results: formattedResults, total: data.meta?.total ?? null, source: "https://crates.io/", }; } async _searchCrates(query: string, perPage = 5): Promise> { try { const data = await this._fetchCratesSearch(query, perPage); return this._formatSearchResults(query, data, perPage); } catch (error) { return { query, error: String(error), source: "https://crates.io/", }; } } private async _fetchCrateMetadata(crateName: string): Promise> { await this._rateLimit(); const client = await this.httpClient(); const response = (await client.get(`${CratesProvider.BASE_URL}/crates/${crateName}`)) as { text: string; raiseForStatus(): void; }; response.raiseForStatus(); return JSON.parse(response.text) as { crate?: Record; versions?: Array>; }; } async _getCrateMetadata(crateName: string): Promise> { try { const data = await this._fetchCrateMetadata(crateName); const crate = (data.crate as Record | undefined) ?? {}; const versions = Array.isArray(data.versions) ? data.versions : []; const version = (versions[0] as Record | undefined) ?? {}; return { name: crate.name ?? null, version: crate.max_version ?? null, description: crate.description ?? null, repository: crate.repository ?? null, documentation: crate.documentation ?? null, homepage: crate.homepage ?? null, license: version.license ?? null, downloads: crate.downloads ?? null, recent_downloads: crate.recent_downloads ?? null, categories: crate.categories ?? [], keywords: crate.keywords ?? [], num_versions: crate.num_versions ?? null, created_at: crate.created_at ?? null, updated_at: crate.updated_at ?? null, rust_version: version.rust_version ?? null, url: `https://crates.io/crates/${crateName}`, }; } catch (error) { return { name: crateName, error: String(error), url: `https://crates.io/crates/${crateName}`, }; } } getTools(): Record { return { search_crates: { name: "search_crates", label: "Search crates.io", description: "Search crates.io for Rust packages. Returns names, versions, descriptions, downloads.\n\nWhen: Finding Rust crates for a topic or functionality\nSee also: crates_metadata (for details)\nArgs: query=\"web framework\", limit=5\nEx: search_crates(\"http client\") → matching crates with versions", promptSnippet: "Search crates.io for Rust packages.", parameters: Type.Object({ query: Type.String({ description: "Search query" }), limit: Type.Optional(Type.Integer({ description: "Maximum results to return" })), }), execute: async (_id: string, { query, limit = 5 }: { query: string; limit?: number }) => { const result = await this._searchCrates(query, limit); return serializeResponseWithMeta(result); }, }, crates_metadata: { name: "crates_metadata", label: "Crate metadata", description: "Get Rust crate metadata: version, URLs, downloads, license. Not full docs.\n\nWhen: Need detailed info about a crate\nSee also: search_crates (to find crates)\nArgs: crate=\"serde\"\nEx: crates_metadata(\"tokio\") → version, docs.rs link, GitHub repo, stats", promptSnippet: "Get detailed metadata for a Rust crate.", parameters: Type.Object({ crate: Type.String({ description: "Crate name" }), }), execute: async (_id: string, { crate: crateName }: { crate: string }) => { const result = await this._getCrateMetadata(crateName); return serializeResponseWithMeta(result); }, }, }; } }