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};
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CompareInput {
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 "));
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');
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()));
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()),
}
}
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;
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;
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"));
}
}