use std::path::Path;
use std::process::Command;
use rmcp::{ErrorData, model::RawContent};
use crate::{
Tool, execute_command,
serde_utils::{
deserialize_string, deserialize_string_vec, locking_mode_to_cli_flags,
output_verbosity_to_cli_flags,
},
tools::get_workspace_root,
};
#[derive(Debug, ::serde::Deserialize, ::schemars::JsonSchema)]
pub struct CargoDocRequest {
#[serde(default, deserialize_with = "deserialize_string")]
toolchain: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_vec")]
package: Option<Vec<String>>,
#[serde(default)]
workspace: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string_vec")]
exclude: Option<Vec<String>>,
#[serde(default)]
no_deps: Option<bool>,
#[serde(default)]
document_private_items: Option<bool>,
#[serde(default)]
docsrs_config: Option<bool>,
#[serde(default)]
lib: Option<bool>,
#[serde(default)]
bins: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
bin: Option<String>,
#[serde(default)]
examples: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
example: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_vec")]
features: Option<Vec<String>>,
#[serde(default)]
all_features: Option<bool>,
#[serde(default)]
no_default_features: Option<bool>,
#[serde(default)]
release: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
profile: Option<String>,
#[serde(default)]
jobs: Option<u32>,
#[serde(default)]
keep_going: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
target: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
target_dir: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
manifest_path: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
lockfile_path: Option<String>,
#[serde(default)]
ignore_rust_version: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
locking_mode: Option<String>,
#[serde(default, deserialize_with = "deserialize_string")]
output_verbosity: Option<String>,
#[serde(default)]
warnings_as_errors: Option<bool>,
#[serde(default, deserialize_with = "deserialize_string")]
message_format: Option<String>,
}
impl CargoDocRequest {
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("doc");
if let Some(packages) = &self.package {
for package in packages {
cmd.arg("--package").arg(package);
}
}
if self.workspace.unwrap_or(false) {
cmd.arg("--workspace");
}
if let Some(excludes) = &self.exclude {
for exclude in excludes {
cmd.arg("--exclude").arg(exclude);
}
}
if self.no_deps.unwrap_or(true) {
cmd.arg("--no-deps");
}
if self.document_private_items.unwrap_or(false) {
cmd.arg("--document-private-items");
}
let mut rustdocflags = Vec::new();
if self.docsrs_config.unwrap_or(false) {
rustdocflags.push("--cfg docsrs");
}
if self.warnings_as_errors.unwrap_or(false) {
rustdocflags.push("-D warnings");
}
if !rustdocflags.is_empty() {
cmd.env("RUSTDOCFLAGS", rustdocflags.join(" "));
}
if self.lib.unwrap_or(false) {
cmd.arg("--lib");
}
if self.bins.unwrap_or(false) {
cmd.arg("--bins");
}
if let Some(bin) = &self.bin {
cmd.arg("--bin").arg(bin);
}
if self.examples.unwrap_or(false) {
cmd.arg("--examples");
}
if let Some(example) = &self.example {
cmd.arg("--example").arg(example);
}
if let Some(features) = &self.features {
cmd.arg("--features").arg(features.join(","));
}
if self.all_features.unwrap_or(false) {
cmd.arg("--all-features");
}
if self.no_default_features.unwrap_or(false) {
cmd.arg("--no-default-features");
}
if self.release.unwrap_or(false) {
cmd.arg("--release");
}
if let Some(profile) = &self.profile {
cmd.arg("--profile").arg(profile);
}
if let Some(jobs) = self.jobs {
cmd.arg("--jobs").arg(jobs.to_string());
}
if self.keep_going.unwrap_or(false) {
cmd.arg("--keep-going");
}
if let Some(target) = &self.target {
cmd.arg("--target").arg(target);
}
if let Some(target_dir) = &self.target_dir {
cmd.arg("--target-dir").arg(target_dir);
}
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(), "locked")?;
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);
if let Some(message_format) = &self.message_format {
cmd.arg("--message-format").arg(message_format);
}
Ok(cmd)
}
fn get_doc_path(&self) -> Option<String> {
let base_dir = self.target_dir.as_deref().unwrap_or("target");
let doc_dir = if let Some(target) = &self.target {
format!("{base_dir}/{target}/doc")
} else {
format!("{base_dir}/doc")
};
let absolute_doc_dir = if let Some(workspace_root) = get_workspace_root() {
workspace_root.join(&doc_dir)
} else {
Path::new(&doc_dir).to_path_buf()
};
if let Some(packages) = &self.package
&& let Some(first_package) = packages.first()
{
let package_path_name = first_package.replace('-', "_");
let package_index = absolute_doc_dir.join(&package_path_name).join("index.html");
if package_index.exists() {
return Some(Self::normalize_path(&package_index));
}
}
if absolute_doc_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&absolute_doc_dir) {
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
let index_path = entry.path().join("index.html");
if index_path.exists() {
return Some(Self::normalize_path(&index_path));
}
}
}
}
let top_index = absolute_doc_dir.join("index.html");
if top_index.exists() {
return Some(Self::normalize_path(&top_index));
}
}
None
}
fn normalize_path(path: &Path) -> String {
let absolute_path = match path.canonicalize() {
Ok(canonical) => canonical,
Err(_) => path.to_path_buf(),
};
absolute_path.to_string_lossy().into_owned()
}
}
pub struct CargoDocRmcpTool;
impl Tool for CargoDocRmcpTool {
const NAME: &'static str = "cargo-doc";
const TITLE: &'static str = "Build Rust documentation";
const DESCRIPTION: &'static str = "Build documentation for a Rust package using Cargo. Recommended to use with no_deps and specific package for faster builds. Returns path to generated documentation index.";
type RequestArgs = CargoDocRequest;
fn call_rmcp_tool(&self, request: Self::RequestArgs) -> Result<crate::Response, ErrorData> {
use rmcp::model::{AnnotateAble, Annotations, Role};
let cmd = request.build_cmd()?;
let start_time = std::time::Instant::now();
let output = execute_command(cmd, Self::NAME)?;
let duration = start_time.elapsed();
if !output.success() {
return Ok(output.into());
}
let mut response: crate::Response = output.into();
let doc_path = request.get_doc_path();
let doc_info = if let Some(doc_path) = doc_path {
format!(
"Documentation generated successfully!\nDocumentation file: {doc_path}\nOpen this file in your browser to view the docs"
)
} else {
"Documentation generated successfully!".to_owned()
};
let mut annotations = Annotations::default();
annotations.audience = Some(vec![Role::User, Role::Assistant]);
annotations.priority = Some(0.5);
response.add_content(RawContent::text(doc_info).annotate(annotations));
if duration.as_secs() >= 30 && !request.no_deps.unwrap_or(false) {
response.add_recommendation(
"For faster documentation builds, consider using `no_deps: true` to build only local documentation"
);
}
Ok(response)
}
}