cratesio-mcp 0.2.1

MCP server for querying crates.io - the Rust package registry
Documentation
//! Compare crates tool

use std::sync::Arc;

use schemars::JsonSchema;
use serde::Deserialize;
use tower_mcp::{
    CallToolResult, Tool, ToolBuilder,
    extract::{Json, State},
};

use crate::state::{AppState, format_number};

/// Input for comparing crates
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CompareInput {
    /// List of crate names to compare (2-5 crates)
    crates: Vec<String>,
}

pub fn build(state: Arc<AppState>) -> Tool {
    ToolBuilder::new("compare_crates")
        .title("Compare Crates")
        .description(
            "Compare two or more crates side by side. Returns a structured comparison of \
             downloads, versions, dependencies, reverse dependencies, and freshness.",
        )
        .read_only()
        .idempotent()
        .icon("https://crates.io/assets/cargo.png")
        .extractor_handler(
            state,
            |State(state): State<Arc<AppState>>, Json(input): Json<CompareInput>| async move {
                let names: Vec<&str> = input.crates.iter().map(|s| s.trim()).collect();

                if names.len() < 2 {
                    return Ok(CallToolResult::text(
                        "Please provide at least 2 crate names separated by commas.",
                    ));
                }
                if names.len() > 5 {
                    return Ok(CallToolResult::text(
                        "Please provide at most 5 crate names to compare.",
                    ));
                }

                let mut output = format!("# Crate Comparison: {}\n\n", names.join(" vs "));

                // Table header
                output.push_str("| | ");
                for name in &names {
                    output.push_str(&format!("**{}** | ", name));
                }
                output.push('\n');

                output.push_str("|---|");
                for _ in &names {
                    output.push_str("---|");
                }
                output.push('\n');

                // Gather data for each crate
                let mut versions_row = vec![];
                let mut downloads_row = vec![];
                let mut recent_row = vec![];
                let mut deps_row = vec![];
                let mut rev_deps_row = vec![];
                let mut last_release_row = vec![];
                let mut license_row = vec![];
                let mut msrv_row = vec![];
                let mut description_row = vec![];

                for name in &names {
                    let info = state.client.get_crate(name).await;
                    let rev_deps = state.client.crate_reverse_dependencies(name).await;

                    match info {
                        Ok(resp) => {
                            let c = &resp.crate_data;
                            versions_row.push(c.max_version.clone());
                            downloads_row.push(format_number(c.downloads));
                            recent_row.push(
                                c.recent_downloads
                                    .map(format_number)
                                    .unwrap_or_else(|| "-".to_string()),
                            );
                            last_release_row.push(c.updated_at.date_naive().to_string());
                            description_row
                                .push(c.description.clone().unwrap_or_else(|| "-".to_string()));

                            // Get deps and version details from the latest version
                            let version = &c.max_version;
                            match state.client.crate_dependencies(name, version).await {
                                Ok(deps) => {
                                    let normal: Vec<_> = deps
                                        .iter()
                                        .filter(|d| d.kind == "normal" && !d.optional)
                                        .collect();
                                    deps_row.push(format!("{}", normal.len()));
                                }
                                Err(_) => deps_row.push("-".to_string()),
                            }

                            match state.client.crate_version(name, version).await {
                                Ok(v) => {
                                    license_row.push(v.license.unwrap_or_else(|| "-".to_string()));
                                    msrv_row
                                        .push(v.rust_version.unwrap_or_else(|| "-".to_string()));
                                }
                                Err(_) => {
                                    license_row.push("-".to_string());
                                    msrv_row.push("-".to_string());
                                }
                            }
                        }
                        Err(e) => {
                            let err = format!("error: {}", e);
                            versions_row.push(err.clone());
                            downloads_row.push(err.clone());
                            recent_row.push(err.clone());
                            deps_row.push(err.clone());
                            last_release_row.push(err.clone());
                            license_row.push(err.clone());
                            msrv_row.push(err.clone());
                            description_row.push(err);
                        }
                    }

                    match rev_deps {
                        Ok(rd) => rev_deps_row.push(format!("{}", rd.meta.total)),
                        Err(_) => rev_deps_row.push("-".to_string()),
                    }
                }

                // Build table rows
                let rows = [
                    ("Description", &description_row),
                    ("Latest Version", &versions_row),
                    ("Total Downloads", &downloads_row),
                    ("Recent Downloads", &recent_row),
                    ("Direct Deps", &deps_row),
                    ("Reverse Deps", &rev_deps_row),
                    ("Last Release", &last_release_row),
                    ("License", &license_row),
                    ("MSRV", &msrv_row),
                ];

                for (label, values) in &rows {
                    output.push_str(&format!("| {} | ", label));
                    for val in *values {
                        output.push_str(&format!("{} | ", val));
                    }
                    output.push('\n');
                }

                Ok(CallToolResult::text(output))
            },
        )
        .build()
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;
    use std::time::Duration;

    use tokio::sync::RwLock;
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    use crate::client::CratesIoClient;
    use crate::client::docsrs::DocsRsClient;
    use crate::client::osv::OsvClient;
    use crate::docs::cache::DocsCache;
    use crate::state::AppState;

    fn test_state(base_url: &str) -> Arc<AppState> {
        Arc::new(AppState {
            client: CratesIoClient::with_base_url(
                "test",
                Duration::from_millis(0),
                Duration::from_secs(30),
                base_url,
            )
            .unwrap(),
            docsrs_client: DocsRsClient::with_base_url("test", Duration::from_secs(30), base_url)
                .unwrap(),
            osv_client: OsvClient::with_base_url(
                "test",
                Duration::from_secs(30),
                "http://localhost:1",
            )
            .unwrap(),
            docs_cache: DocsCache::new(10, Duration::from_secs(3600)),
            recent_searches: RwLock::new(Vec::new()),
        })
    }

    #[tokio::test]
    async fn compare_partial_failure() {
        let server = MockServer::start().await;

        // good-crate succeeds
        Mock::given(method("GET"))
            .and(path("/crates/good-crate"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "crate": {
                    "name": "good-crate",
                    "max_version": "1.0.0",
                    "description": "A good crate",
                    "downloads": 10000,
                    "created_at": "2026-01-01T00:00:00.000000Z",
                    "updated_at": "2026-01-01T00:00:00.000000Z"
                },
                "versions": [{"num": "1.0.0", "yanked": false, "created_at": "2026-01-01T00:00:00.000000Z", "downloads": 10000}]
            })))
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/crates/good-crate/1.0.0/dependencies"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "dependencies": []
            })))
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/crates/good-crate/1.0.0"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "version": {
                    "num": "1.0.0",
                    "yanked": false,
                    "created_at": "2026-01-01T00:00:00.000000Z",
                    "downloads": 10000,
                    "license": "MIT"
                }
            })))
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/crates/good-crate/reverse_dependencies"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "dependencies": [],
                "versions": [],
                "meta": {"total": 5}
            })))
            .mount(&server)
            .await;

        // bad-crate returns 404 -- triggers the Err(e) row-formatting path
        Mock::given(method("GET"))
            .and(path("/crates/bad-crate"))
            .respond_with(ResponseTemplate::new(404))
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/crates/bad-crate/reverse_dependencies"))
            .respond_with(ResponseTemplate::new(404))
            .mount(&server)
            .await;

        let state = test_state(&server.uri());
        let tool = super::build(state);
        let result = tool
            .call(serde_json::json!({"crates": ["good-crate", "bad-crate"]}))
            .await;

        let text = result.all_text();
        assert!(!result.is_error);
        assert!(
            text.contains("error:"),
            "bad-crate cells should contain 'error:' from Err(e) formatting path, got: {text}"
        );
        assert!(text.contains("good-crate"));
    }

    #[tokio::test]
    async fn compare_too_many_crates() {
        let server = MockServer::start().await;
        let state = test_state(&server.uri());
        let tool = super::build(state);

        let result = tool
            .call(serde_json::json!({"crates": ["a", "b", "c", "d", "e", "f"]}))
            .await;

        let text = result.all_text();
        assert!(!result.is_error);
        assert!(text.contains("at most 5"));
    }
}