use std::path::PathBuf;
use rmcp::{
handler::server::wrapper::Parameters, model::CallToolResult, schemars, tool, tool_router,
ErrorData as McpError,
};
use serde::Deserialize;
use tokio_util::sync::CancellationToken;
use super::cancel::spawn_blocking_cancellable;
use super::git_tools::build_truncated_result;
use super::server::OmniDevServer;
use crate::cli::coverage::diff::{DiffCommand, DiffOutcome, OutputFormatArg, ReportFormat};
#[derive(Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum CoverageReportFormat {
#[default]
Auto,
Lcov,
LlvmCovJson,
Cobertura,
}
impl From<CoverageReportFormat> for ReportFormat {
fn from(value: CoverageReportFormat) -> Self {
match value {
CoverageReportFormat::Auto => Self::Auto,
CoverageReportFormat::Lcov => Self::Lcov,
CoverageReportFormat::LlvmCovJson => Self::LlvmCovJson,
CoverageReportFormat::Cobertura => Self::Cobertura,
}
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum CoverageOutputFormat {
#[default]
Markdown,
Yaml,
Json,
}
impl From<CoverageOutputFormat> for OutputFormatArg {
fn from(value: CoverageOutputFormat) -> Self {
match value {
CoverageOutputFormat::Markdown => Self::Markdown,
CoverageOutputFormat::Yaml => Self::Yaml,
CoverageOutputFormat::Json => Self::Json,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CoverageDiffParams {
pub report: String,
#[serde(default)]
pub report_format: CoverageReportFormat,
#[serde(default)]
pub base_ref: Option<String>,
#[serde(default)]
pub head_ref: Option<String>,
#[serde(default)]
pub baseline_report: Option<String>,
#[serde(default)]
pub baseline_report_format: CoverageReportFormat,
#[serde(default)]
pub format: CoverageOutputFormat,
#[serde(default)]
pub fail_under_patch: Option<f64>,
#[serde(default)]
pub strip_prefix: Option<String>,
#[serde(default)]
pub ignore_filename_regex: Vec<String>,
#[serde(default)]
pub collapse_ranges: bool,
#[serde(default)]
pub all_files: bool,
#[serde(default)]
pub artifact_url: Option<String>,
#[serde(default)]
pub run_url: Option<String>,
#[serde(default)]
pub base_sha: Option<String>,
#[serde(default)]
pub head_sha: Option<String>,
#[serde(default)]
pub commit_url: Option<String>,
#[serde(default)]
pub repo_path: Option<String>,
}
#[allow(missing_docs)] #[tool_router(router = coverage_tool_router, vis = "pub")]
impl OmniDevServer {
#[tool(
description = "Analyze diff/patch coverage from a per-line coverage report plus the git \
diff, and return the rendered report with the patch-coverage percentage \
and gate result as YAML. Read-only. Mirrors `omni-dev coverage diff`. \
`report` is a required filesystem path to the head coverage report \
(lcov / llvm-cov-json / cobertura, auto-detected). `format` renders the \
report as `markdown` (default), `yaml`, or `json`. Unlike the CLI this \
tool never fails the call on a low `fail_under_patch`; it reports \
`below_gate: true` instead."
)]
pub async fn coverage_diff(
&self,
Parameters(params): Parameters<CoverageDiffParams>,
cancellation: CancellationToken,
) -> Result<CallToolResult, McpError> {
let repo_path = params.repo_path.clone().map(PathBuf::from);
let cmd = DiffCommand {
report: PathBuf::from(params.report),
report_format: params.report_format.into(),
base_ref: params.base_ref,
head_ref: params.head_ref,
baseline_report: params.baseline_report.map(PathBuf::from),
baseline_report_format: params.baseline_report_format.into(),
output: params.format.into(),
format: None,
fail_under_patch: params.fail_under_patch,
strip_prefix: params.strip_prefix.map(PathBuf::from),
ignore_filename_regex: params.ignore_filename_regex,
collapse_ranges: params.collapse_ranges,
all_files: params.all_files,
artifact_url: params.artifact_url,
run_url: params.run_url,
base_sha: params.base_sha,
head_sha: params.head_sha,
commit_url: params.commit_url,
};
let payload = spawn_blocking_cancellable(&cancellation, move || {
let outcome = cmd.run(repo_path.as_deref())?;
Ok(format_coverage_payload(&outcome))
})
.await?;
Ok(build_truncated_result(payload))
}
}
fn format_coverage_payload(outcome: &DiffOutcome) -> String {
let patch = outcome
.patch_percent
.map_or_else(|| "null".to_string(), |p| format!("{p:.4}"));
let rendered = outcome
.rendered
.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"# coverage_diff outcome\npatch_percent: {patch}\nbelow_gate: {}\nrendered: |\n{rendered}",
outcome.below_gate,
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn params_require_report() {
assert!(serde_json::from_str::<CoverageDiffParams>("{}").is_err());
let p: CoverageDiffParams = serde_json::from_str(r#"{"report": "cov.info"}"#).unwrap();
assert_eq!(p.report, "cov.info");
assert!(matches!(p.report_format, CoverageReportFormat::Auto));
assert!(matches!(p.format, CoverageOutputFormat::Markdown));
}
#[test]
fn report_format_parses_kebab_case() {
let p: CoverageDiffParams =
serde_json::from_str(r#"{"report": "c", "report_format": "llvm-cov-json"}"#).unwrap();
assert!(matches!(p.report_format, CoverageReportFormat::LlvmCovJson));
}
#[test]
fn format_payload_wraps_rendered_as_block_scalar() {
let outcome = DiffOutcome {
rendered: "line1\nline2".to_string(),
patch_percent: Some(87.5),
below_gate: false,
};
let payload = format_coverage_payload(&outcome);
assert!(payload.contains("patch_percent: 87.5000"));
assert!(payload.contains("below_gate: false"));
assert!(payload.contains(" line1"));
assert!(payload.contains(" line2"));
}
#[test]
fn format_payload_renders_null_patch_percent() {
let outcome = DiffOutcome {
rendered: "x".to_string(),
patch_percent: None,
below_gate: true,
};
let payload = format_coverage_payload(&outcome);
assert!(payload.contains("patch_percent: null"));
assert!(payload.contains("below_gate: true"));
}
#[test]
fn report_format_into_covers_all_variants() {
assert_eq!(
ReportFormat::from(CoverageReportFormat::Auto),
ReportFormat::Auto
);
assert_eq!(
ReportFormat::from(CoverageReportFormat::Lcov),
ReportFormat::Lcov
);
assert_eq!(
ReportFormat::from(CoverageReportFormat::LlvmCovJson),
ReportFormat::LlvmCovJson
);
assert_eq!(
ReportFormat::from(CoverageReportFormat::Cobertura),
ReportFormat::Cobertura
);
}
#[test]
fn output_format_into_covers_all_variants() {
assert_eq!(
OutputFormatArg::from(CoverageOutputFormat::Markdown),
OutputFormatArg::Markdown
);
assert_eq!(
OutputFormatArg::from(CoverageOutputFormat::Yaml),
OutputFormatArg::Yaml
);
assert_eq!(
OutputFormatArg::from(CoverageOutputFormat::Json),
OutputFormatArg::Json
);
}
#[tokio::test]
async fn coverage_diff_handler_bad_repo_returns_tool_error() {
use crate::mcp::server::OmniDevServer;
use rmcp::handler::server::wrapper::Parameters;
use tokio_util::sync::CancellationToken;
let server = OmniDevServer::new();
let params = CoverageDiffParams {
report: "/no/such/report.info".to_string(),
report_format: CoverageReportFormat::Auto,
base_ref: None,
head_ref: None,
baseline_report: None,
baseline_report_format: CoverageReportFormat::Auto,
format: CoverageOutputFormat::Markdown,
fail_under_patch: None,
strip_prefix: None,
ignore_filename_regex: Vec::new(),
collapse_ranges: false,
all_files: false,
artifact_url: None,
run_url: None,
base_sha: None,
head_sha: None,
commit_url: None,
repo_path: Some("/no/such/repo/for/mcp/test".to_string()),
};
let err = server
.coverage_diff(Parameters(params), CancellationToken::new())
.await
.unwrap_err();
assert!(!err.message.is_empty());
}
}