use std::process::Command;
use crate::{
Response, Tool, execute_command,
serde_utils::{
PackageWithVersion, deserialize_string, deserialize_string_vec, locking_mode_to_cli_flags,
output_verbosity_to_cli_flags,
},
tools::Registry,
};
use rmcp::ErrorData;
fn dependency_type_to_cli_flag(
dependency_type: Option<&str>,
) -> Result<Option<&'static str>, ErrorData> {
Ok(match dependency_type {
None => None,
Some("regular") => None,
Some("dev") => Some("--dev"),
Some("build") => Some("--build"),
Some(dep) => {
return Err(ErrorData::invalid_params(
format!("Unknown dependency type: {dep}"),
None,
));
}
})
}
#[derive(Debug, ::serde::Deserialize, schemars::JsonSchema)]
pub struct CargoAddRequest {
#[serde(default, deserialize_with = "deserialize_string")]
toolchain: Option<String>,
#[serde(flatten)]
pub package_spec: PackageWithVersion,
#[serde(default, deserialize_with = "deserialize_string")]
pub dependency_type: Option<String>,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub no_default_features: Option<bool>,
#[serde(default)]
pub default_features: bool,
#[serde(default, deserialize_with = "deserialize_string_vec")]
pub features: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_string")]
pub rename: Option<String>,
pub target_package: String,
#[serde(default, deserialize_with = "deserialize_string")]
pub path: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub git: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub branch: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub tag: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub rev: Option<String>,
#[serde(default)]
pub registry: Registry,
#[serde(default, deserialize_with = "deserialize_string")]
pub target: Option<String>,
#[serde(default)]
pub dry_run: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
pub manifest_path: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub lockfile_path: Option<String>,
#[serde(default)]
pub ignore_rust_version: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
pub locking_mode: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub output_verbosity: Option<String>,
}
impl CargoAddRequest {
pub fn build_cmd(&self) -> Result<Command, ErrorData> {
let mut cmd = Command::new("cargo");
if let Some(toolchain) = &self.toolchain {
cmd.arg(format!("+{toolchain}"));
}
cmd.arg("add");
cmd.arg(self.package_spec.to_spec());
if let Some(flag) = dependency_type_to_cli_flag(self.dependency_type.as_deref())? {
cmd.arg(flag);
}
if self.optional {
cmd.arg("--optional");
}
if self.no_default_features.unwrap_or(false) {
cmd.arg("--no-default-features");
}
if self.default_features {
cmd.arg("--default-features");
}
if let Some(features) = &self.features {
cmd.arg("--features").arg(features.join(","));
}
cmd.arg("--package").arg(&self.target_package);
if let Some(path) = &self.path {
cmd.arg("--path").arg(path);
}
if let Some(git) = &self.git {
cmd.arg("--git").arg(git);
}
if let Some(branch) = &self.branch {
cmd.arg("--branch").arg(branch);
}
if let Some(tag) = &self.tag {
cmd.arg("--tag").arg(tag);
}
if let Some(rev) = &self.rev {
cmd.arg("--rev").arg(rev);
}
if let Some(registry) = self.registry.value() {
cmd.arg("--registry").arg(registry);
}
if let Some(target) = &self.target {
cmd.arg("--target").arg(target);
}
if let Some(rename) = &self.rename {
cmd.arg("--rename").arg(rename);
}
if self.dry_run.unwrap_or(false) {
cmd.arg("--dry-run");
}
if let Some(manifest_path) = &self.manifest_path {
cmd.arg("--manifest-path").arg(manifest_path);
}
if let Some(lockfile_path) = &self.lockfile_path {
cmd.arg("--lockfile-path").arg(lockfile_path);
}
if self.ignore_rust_version.unwrap_or(false) {
cmd.arg("--ignore-rust-version");
}
let locking_flags = locking_mode_to_cli_flags(self.locking_mode.as_deref(), "unlocked")?;
for flag in locking_flags {
cmd.arg(flag);
}
let output_flags = output_verbosity_to_cli_flags(self.output_verbosity.as_deref())?;
cmd.args(output_flags);
Ok(cmd)
}
}
pub struct CargoAddRmcpTool;
impl Tool for CargoAddRmcpTool {
const NAME: &'static str = "cargo-add";
const TITLE: &'static str = "Add Rust dependency";
const DESCRIPTION: &'static str = "Adds a dependency to a Rust project using cargo add.";
type RequestArgs = CargoAddRequest;
fn call_rmcp_tool(&self, request: Self::RequestArgs) -> Result<Response, ErrorData> {
execute_command(request.build_cmd()?, Self::NAME).map(Into::into)
}
}
#[derive(Debug, ::serde::Deserialize, schemars::JsonSchema)]
pub struct CargoRemoveRequest {
#[serde(default, deserialize_with = "deserialize_string")]
toolchain: Option<String>,
pub dep_id: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub dependency_type: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub target: Option<String>,
pub target_package: String,
#[serde(default)]
pub dry_run: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
pub manifest_path: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub lockfile_path: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub locking_mode: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
pub output_verbosity: Option<String>,
}
impl CargoRemoveRequest {
pub fn build_cmd(&self) -> Result<Command, ErrorData> {
let mut cmd = Command::new("cargo");
if let Some(toolchain) = &self.toolchain {
cmd.arg(format!("+{toolchain}"));
}
cmd.arg("remove");
for dep in &self.dep_id {
cmd.arg(dep);
}
if let Some(flag) = dependency_type_to_cli_flag(self.dependency_type.as_deref())? {
cmd.arg(flag);
}
if let Some(target) = &self.target {
cmd.arg("--target").arg(target);
}
cmd.arg("--package").arg(&self.target_package);
if self.dry_run.unwrap_or(false) {
cmd.arg("--dry-run");
}
if let Some(manifest_path) = &self.manifest_path {
cmd.arg("--manifest-path").arg(manifest_path);
}
if let Some(lockfile_path) = &self.lockfile_path {
cmd.arg("--lockfile-path").arg(lockfile_path);
}
let locking_flags = locking_mode_to_cli_flags(self.locking_mode.as_deref(), "unlocked")?;
for flag in locking_flags {
cmd.arg(flag);
}
let output_flags = output_verbosity_to_cli_flags(self.output_verbosity.as_deref())?;
cmd.args(output_flags);
Ok(cmd)
}
}
pub struct CargoRemoveRmcpTool;
impl Tool for CargoRemoveRmcpTool {
const NAME: &'static str = "cargo-remove";
const TITLE: &'static str = "Remove Rust dependency";
const DESCRIPTION: &'static str = "Remove dependencies from a Cargo.toml manifest file.";
type RequestArgs = CargoRemoveRequest;
fn call_rmcp_tool(&self, request: Self::RequestArgs) -> Result<Response, ErrorData> {
execute_command(request.build_cmd()?, Self::NAME).map(Into::into)
}
}
#[cfg(test)]
mod tests {
use crate::tool::DynTool;
use super::*;
#[test]
fn test_dependency_type_helper() {
assert_eq!(dependency_type_to_cli_flag(Some("regular")).unwrap(), None);
assert_eq!(
dependency_type_to_cli_flag(Some("dev")).unwrap(),
Some("--dev")
);
assert_eq!(
dependency_type_to_cli_flag(Some("build")).unwrap(),
Some("--build")
);
assert!(dependency_type_to_cli_flag(Some("unknown")).is_err());
}
#[test]
fn test_dependency_type_serde() {
assert_eq!(
serde_json::from_str::<String>("\"regular\"").unwrap(),
"regular"
);
assert_eq!(serde_json::from_str::<String>("\"dev\"").unwrap(), "dev");
assert_eq!(
serde_json::from_str::<String>("\"build\"").unwrap(),
"build"
);
}
#[test]
fn test_cargo_add_schema() {
const EXPECTED_SCHEMA: &str = r##"
{
"description": "Adds a dependency to a Rust project using cargo add.",
"properties": {
"branch": {
"default": null,
"description": "Git branch to download the crate from",
"type": "string"
},
"default_features": {
"default": false,
"description": "Re-enable the default features",
"type": "boolean"
},
"dependency_type": {
"default": null,
"description": "Dependency type: \"regular\" (default), \"dev\", or \"build\"",
"type": "string"
},
"dry_run": {
"default": null,
"description": "Don't actually write the manifest",
"type": "boolean"
},
"features": {
"default": null,
"description": "Space or comma separated list of features to activate",
"items": {
"type": "string"
},
"type": "array"
},
"git": {
"default": null,
"description": "Git repository location",
"type": "string"
},
"ignore_rust_version": {
"default": null,
"description": "Ignore `rust-version` specification in packages",
"type": "boolean"
},
"locking_mode": {
"default": null,
"description": "Locking mode for dependency resolution.\n\nValid options:\n- \"locked\": Assert that `Cargo.lock` will remain unchanged\n- \"unlocked\" (default): Allow `Cargo.lock` to be updated\n- \"offline\": Run without accessing the network\n- \"frozen\": Equivalent to specifying both --locked and --offline",
"type": "string"
},
"lockfile_path": {
"default": null,
"description": "Path to Cargo.lock (unstable)",
"type": "string"
},
"manifest_path": {
"default": null,
"description": "Path to Cargo.toml",
"type": "string"
},
"no_default_features": {
"default": null,
"description": "Disable the default features",
"type": "boolean"
},
"optional": {
"default": false,
"description": "Add as an optional dependency",
"type": "boolean"
},
"output_verbosity": {
"default": null,
"description": "Output verbosity level.\n\nValid options:\n- \"quiet\" (default): Show only the essential command output\n- \"normal\": Show standard output (no additional flags)\n- \"verbose\": Show detailed output including build information",
"type": "string"
},
"package": {
"description": "The package name",
"type": "string"
},
"path": {
"default": null,
"description": "Filesystem path to local crate to add",
"type": "string"
},
"registry": {
"default": null,
"description": "Package registry for this dependency",
"type": "string"
},
"rename": {
"default": null,
"description": "Rename the dependency",
"type": "string"
},
"rev": {
"default": null,
"description": "Git reference to download the crate from",
"type": "string"
},
"tag": {
"default": null,
"description": "Git tag to download the crate from",
"type": "string"
},
"target": {
"default": null,
"description": "Add as dependency to the given target platform",
"type": "string"
},
"target_package": {
"description": "Package to modify, must be specified",
"type": "string"
},
"toolchain": {
"default": null,
"description": "The toolchain to use, e.g., \"stable\" or \"nightly\".",
"type": "string"
},
"version": {
"default": null,
"description": "Optional version specification",
"type": "string"
}
},
"required": [
"package",
"target_package"
],
"title": "CargoAddRequest",
"type": "object"
}"##;
let schema = serde_json::Value::from(CargoAddRmcpTool {}.json_schema());
println!(
"CargoAddRequest schema: {}",
serde_json::to_string_pretty(&schema).unwrap()
);
let expected_schema: serde_json::Value = serde_json::from_str(EXPECTED_SCHEMA).unwrap();
assert_eq!(
schema, expected_schema,
"CargoAddRequest schema should match expected structure"
);
}
}