#[derive(Debug, Deserialize, Serialize)]
struct AnalyzeComplexityArgs {
project_path: Option<String>,
toolchain: Option<String>,
format: Option<String>,
max_cyclomatic: Option<u16>,
max_cognitive: Option<u16>,
include: Option<Vec<String>>,
top_files: Option<usize>,
}
fn parse_complexity_args(arguments: serde_json::Value) -> Result<AnalyzeComplexityArgs, String> {
serde_json::from_value(arguments)
.map_err(|e| format!("Invalid analyze_complexity arguments: {e}"))
}
struct ComplexityAnalysisContext {
project_path: PathBuf,
toolchain: String,
_thresholds: crate::services::complexity::ComplexityThresholds,
}
fn prepare_complexity_analysis(
args: &AnalyzeComplexityArgs,
) -> Result<ComplexityAnalysisContext, String> {
let project_path = resolve_project_path_complexity(args.project_path.clone())?;
let toolchain = detect_toolchain(&args.toolchain, &project_path);
let thresholds = build_complexity_thresholds(args);
Ok(ComplexityAnalysisContext {
project_path,
toolchain,
_thresholds: thresholds,
})
}
async fn perform_complexity_analysis(
context: &ComplexityAnalysisContext,
args: &AnalyzeComplexityArgs,
) -> (crate::services::complexity::ComplexityReport, usize) {
use crate::services::complexity::aggregate_results;
let (file_metrics, file_count) =
analyze_project_files(&context.project_path, &context.toolchain, args).await;
let report = aggregate_results(file_metrics);
(report, file_count)
}
fn generate_complexity_content(
report: &crate::services::complexity::ComplexityReport,
file_metrics: &[crate::services::complexity::FileComplexityMetrics],
args: &AnalyzeComplexityArgs,
) -> String {
if let Some(top_files_count) = args.top_files {
if top_files_count > 0 {
generate_ranked_content(file_metrics, top_files_count, args)
} else {
format_complexity_output(report, args)
}
} else {
format_complexity_output(report, args)
}
}
fn generate_ranked_content(
file_metrics: &[crate::services::complexity::FileComplexityMetrics],
top_files_count: usize,
args: &AnalyzeComplexityArgs,
) -> String {
use crate::services::ranking::{rank_files_by_complexity, ComplexityRanker};
let ranker = ComplexityRanker::default();
let rankings = rank_files_by_complexity(file_metrics, top_files_count, &ranker);
format_complexity_rankings(&rankings, args)
}
fn build_complexity_response(
request_id: serde_json::Value,
content_text: String,
report: &crate::services::complexity::ComplexityReport,
toolchain: &str,
file_count: usize,
args: &AnalyzeComplexityArgs,
) -> McpResponse {
let result = json!({
"content": [{
"type": "text",
"text": content_text
}],
"report": report,
"toolchain": toolchain,
"files_analyzed": file_count,
"format": args.format.as_deref().unwrap_or("summary"),
"top_files": args.top_files,
});
McpResponse::success(request_id, result)
}
async fn handle_analyze_complexity(
request_id: serde_json::Value,
arguments: serde_json::Value,
) -> McpResponse {
let args = match parse_complexity_args(arguments) {
Ok(args) => args,
Err(e) => return McpResponse::error(request_id, -32602, e),
};
let context = match prepare_complexity_analysis(&args) {
Ok(ctx) => ctx,
Err(msg) => return McpResponse::error(request_id, -32602, msg),
};
info!(
"Analyzing complexity for {:?} using {} toolchain",
context.project_path, context.toolchain
);
let (file_metrics, file_count) =
analyze_project_files(&context.project_path, &context.toolchain, &args).await;
let report = crate::services::complexity::aggregate_results(file_metrics.clone());
let content_text = generate_complexity_content(&report, &file_metrics, &args);
build_complexity_response(
request_id,
content_text,
&report,
&context.toolchain,
file_count,
&args,
)
}
fn resolve_project_path_complexity(project_path_arg: Option<String>) -> Result<PathBuf, String> {
let _validated = require_project_path(project_path_arg.clone())?;
let raw = project_path_arg
.as_deref()
.expect("require_project_path returned Ok for None");
match resolve_project_path_with_globs(raw) {
ResolvedProjectPath::Concrete(p) => Ok(p),
e @ ResolvedProjectPath::EmptyGlob(_) => Err(e.into_error_message()),
}
}
fn detect_toolchain(toolchain_arg: &Option<String>, project_path: &Path) -> String {
if let Some(t) = toolchain_arg {
t.clone()
} else if project_path.join("Cargo.toml").exists() {
"rust".to_string()
} else if project_path.join("package.json").exists() || project_path.join("deno.json").exists()
{
"deno".to_string()
} else if project_path.join("pyproject.toml").exists()
|| project_path.join("requirements.txt").exists()
{
"python-uv".to_string()
} else {
"rust".to_string() }
}
fn build_complexity_thresholds(
args: &AnalyzeComplexityArgs,
) -> crate::services::complexity::ComplexityThresholds {
use crate::services::complexity::ComplexityThresholds;
let mut thresholds = ComplexityThresholds::default();
if let Some(max) = args.max_cyclomatic {
thresholds.cyclomatic_error = max;
thresholds.cyclomatic_warn = (max * 3 / 4).max(1);
}
if let Some(max) = args.max_cognitive {
thresholds.cognitive_error = max;
thresholds.cognitive_warn = (max * 3 / 4).max(1);
}
thresholds
}
async fn analyze_project_files(
project_path: &Path,
toolchain: &str,
args: &AnalyzeComplexityArgs,
) -> (
Vec<crate::services::complexity::FileComplexityMetrics>,
usize,
) {
use crate::services::file_discovery::ProjectFileDiscovery;
let mut file_metrics = Vec::with_capacity(256);
let mut file_count = 0;
let discovery = ProjectFileDiscovery::new(project_path.to_path_buf());
let discovered_files = match discovery.discover_files() {
Ok(files) => files,
Err(e) => {
error!("Failed to discover files: {}", e);
return (file_metrics, file_count);
}
};
for path in discovered_files {
if path.is_dir() || !should_analyze_file(&path, toolchain) {
continue;
}
if !matches_include_filters(&path, &args.include) {
continue;
}
file_count += 1;
if let Some(metrics) = analyze_file_complexity(&path, toolchain).await {
file_metrics.push(metrics);
}
}
(file_metrics, file_count)
}
fn should_analyze_file(path: &Path, toolchain: &str) -> bool {
match toolchain {
"rust" => path.extension().and_then(|s| s.to_str()) == Some("rs"),
"deno" => matches!(
path.extension().and_then(|s| s.to_str()),
Some("ts" | "tsx" | "js" | "jsx")
),
"python-uv" => path.extension().and_then(|s| s.to_str()) == Some("py"),
_ => false,
}
}
fn matches_include_filters(path: &Path, include_patterns: &Option<Vec<String>>) -> bool {
let Some(ref patterns) = include_patterns else {
return true;
};
if patterns.is_empty() {
return true;
}
let path_str = path.to_string_lossy();
patterns
.iter()
.any(|pattern| matches_pattern(&path_str, pattern))
}
fn matches_pattern(path_str: &str, pattern: &str) -> bool {
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
path_str.contains(parts[1].trim_start_matches('/'))
} else {
false
}
} else if pattern.starts_with("*.") {
path_str.ends_with(&pattern[1..])
} else {
path_str.contains(pattern)
}
}
async fn analyze_file_complexity(
path: &Path,
toolchain: &str,
) -> Option<crate::services::complexity::FileComplexityMetrics> {
match toolchain {
"rust" => {
use crate::services::ast_rust;
ast_rust::analyze_rust_file_with_complexity(path).await.ok()
}
"deno" => {
#[cfg(feature = "typescript-ast")]
{
use crate::services::ast_typescript;
ast_typescript::analyze_typescript_file_with_complexity(path)
.await
.ok()
}
#[cfg(not(feature = "typescript-ast"))]
None
}
"python-uv" => {
#[cfg(feature = "python-ast")]
{
use crate::services::ast_python;
ast_python::analyze_python_file_with_complexity(path, None)
.await
.ok()
}
#[cfg(not(feature = "python-ast"))]
None
}
_ => None,
}
}
fn format_complexity_output(
report: &crate::services::complexity::ComplexityReport,
args: &AnalyzeComplexityArgs,
) -> String {
use crate::services::complexity::{
format_as_sarif, format_complexity_report, format_complexity_summary,
};
let format = args.format.as_deref().unwrap_or("summary");
match format {
"full" => format_complexity_report(report),
"json" => serde_json::to_string_pretty(report).unwrap_or_default(),
"sarif" => match format_as_sarif(report) {
Ok(sarif) => sarif,
Err(_) => "Error generating SARIF format".to_string(),
},
_ => format_complexity_summary(report), }
}
fn format_complexity_rankings(
rankings: &[(String, crate::services::ranking::CompositeComplexityScore)],
args: &AnalyzeComplexityArgs,
) -> String {
use crate::services::ranking::{ComplexityRanker, FileRanker};
let format = args.format.as_deref().unwrap_or("summary");
if format == "json" {
let ranker = ComplexityRanker::default();
let rankings_json = serde_json::json!({
"analysis_type": ranker.ranking_type(),
"timestamp": chrono::Utc::now().to_rfc3339(),
"top_files": {
"requested": rankings.len(),
"returned": rankings.len()
},
"rankings": rankings.iter().enumerate().map(|(i, (file, score))| {
serde_json::json!({
"rank": i + 1,
"file": file,
"metrics": {
"functions": score.function_count,
"max_cyclomatic": score.cyclomatic_max,
"avg_cognitive": score.cognitive_avg,
"halstead_effort": score.halstead_effort,
"total_score": score.total_score
}
})
}).collect::<Vec<_>>()
});
serde_json::to_string_pretty(&rankings_json).unwrap_or_default()
} else {
let mut output = String::with_capacity(1024);
output.push_str(&format!("## Top {} Complexity Files\n\n", rankings.len()));
output.push_str("| Rank | File | Functions | Max Cyclomatic | Avg Cognitive | Halstead | Score |\n");
output.push_str("|------|------------------------------------|-----------|-------------- |---------------|----------|-------|\n");
for (i, (file, score)) in rankings.iter().enumerate() {
output.push_str(&format!(
"| {:>4} | {:<50} | {:>9} | {:>14} | {:>13.1} | {:>11.1} | {:>11.1} |\n",
i + 1,
file,
score.function_count,
score.cyclomatic_max,
score.cognitive_avg,
score.halstead_effort,
score.total_score
));
}
output.push('\n');
output
}
}
#[cfg(test)]
mod extended_tools_complexity_tests {
use super::*;
fn args(format: Option<&str>, top_files: Option<usize>) -> AnalyzeComplexityArgs {
AnalyzeComplexityArgs {
project_path: Some(".".to_string()),
toolchain: None,
format: format.map(String::from),
max_cyclomatic: None,
max_cognitive: None,
include: None,
top_files,
}
}
#[test]
fn test_parse_complexity_args_minimal() {
let json = serde_json::json!({"project_path": "."});
let parsed = parse_complexity_args(json).unwrap();
assert_eq!(parsed.project_path, Some(".".to_string()));
assert_eq!(parsed.toolchain, None);
}
#[test]
fn test_parse_complexity_args_full() {
let json = serde_json::json!({
"project_path": "src",
"toolchain": "rust",
"format": "json",
"max_cyclomatic": 20,
"max_cognitive": 30,
"include": ["src/**", "lib/**"],
"top_files": 10
});
let parsed = parse_complexity_args(json).unwrap();
assert_eq!(parsed.toolchain, Some("rust".to_string()));
assert_eq!(parsed.max_cyclomatic, Some(20));
assert_eq!(parsed.top_files, Some(10));
}
#[test]
fn test_parse_complexity_args_invalid_json_returns_err() {
let json = serde_json::json!({"max_cyclomatic": "not-a-number"});
let result = parse_complexity_args(json);
assert!(result.is_err());
}
#[test]
fn test_detect_toolchain_explicit_arg_wins() {
let path = Path::new("/nonexistent");
let toolchain = detect_toolchain(&Some("python-uv".to_string()), path);
assert_eq!(toolchain, "python-uv");
}
#[test]
fn test_detect_toolchain_falls_back_to_rust_on_unknown_dir() {
let path = Path::new("/tmp/__nonexistent_dir_xyz_test_123__");
let toolchain = detect_toolchain(&None, path);
assert_eq!(toolchain, "rust");
}
#[test]
fn test_detect_toolchain_uses_cwd_for_rust_detection() {
let path = Path::new(".");
let toolchain = detect_toolchain(&None, path);
assert_eq!(toolchain, "rust");
}
#[test]
fn test_build_complexity_thresholds_default_when_no_overrides() {
let a = args(None, None);
let t = build_complexity_thresholds(&a);
assert!(t.cyclomatic_error > 0);
assert!(t.cognitive_error > 0);
}
#[test]
fn test_build_complexity_thresholds_overrides_set_warn_to_three_quarters() {
let mut a = args(None, None);
a.max_cyclomatic = Some(20);
a.max_cognitive = Some(40);
let t = build_complexity_thresholds(&a);
assert_eq!(t.cyclomatic_error, 20);
assert_eq!(t.cyclomatic_warn, 15); assert_eq!(t.cognitive_error, 40);
assert_eq!(t.cognitive_warn, 30); }
#[test]
fn test_build_complexity_thresholds_min_one_warn_when_max_is_one() {
let mut a = args(None, None);
a.max_cyclomatic = Some(1);
let t = build_complexity_thresholds(&a);
assert_eq!(t.cyclomatic_warn, 1);
}
#[test]
fn test_should_analyze_file_rust_arm() {
assert!(should_analyze_file(Path::new("src/foo.rs"), "rust"));
assert!(!should_analyze_file(Path::new("src/foo.py"), "rust"));
}
#[test]
fn test_should_analyze_file_deno_arm_accepts_ts_tsx_js_jsx() {
assert!(should_analyze_file(Path::new("a.ts"), "deno"));
assert!(should_analyze_file(Path::new("a.tsx"), "deno"));
assert!(should_analyze_file(Path::new("a.js"), "deno"));
assert!(should_analyze_file(Path::new("a.jsx"), "deno"));
assert!(!should_analyze_file(Path::new("a.rs"), "deno"));
}
#[test]
fn test_should_analyze_file_python_arm() {
assert!(should_analyze_file(Path::new("a.py"), "python-uv"));
assert!(!should_analyze_file(Path::new("a.rs"), "python-uv"));
}
#[test]
fn test_should_analyze_file_unknown_toolchain_returns_false() {
assert!(!should_analyze_file(Path::new("a.rs"), "unknown"));
}
#[test]
fn test_matches_include_filters_none_includes_all() {
assert!(matches_include_filters(Path::new("any/file.rs"), &None));
}
#[test]
fn test_matches_include_filters_empty_vec_includes_all() {
assert!(matches_include_filters(
Path::new("any/file.rs"),
&Some(vec![])
));
}
#[test]
fn test_matches_include_filters_any_match_passes() {
let patterns = Some(vec!["**/never".to_string(), "*.rs".to_string()]);
assert!(matches_include_filters(
Path::new("src/foo.rs"),
&patterns
));
}
#[test]
fn test_matches_include_filters_no_match_filters_out() {
let patterns = Some(vec!["*.py".to_string()]);
assert!(!matches_include_filters(
Path::new("src/foo.rs"),
&patterns
));
}
#[test]
fn test_matches_pattern_double_star_with_suffix() {
assert!(matches_pattern("a/foo/b.rs", "**/foo"));
assert!(!matches_pattern("a/bar/b.rs", "**/foo"));
}
#[test]
fn test_matches_pattern_extension_glob() {
assert!(matches_pattern("src/foo.rs", "*.rs"));
assert!(!matches_pattern("src/foo.py", "*.rs"));
}
#[test]
fn test_matches_pattern_substring_match() {
assert!(matches_pattern("src/handlers/foo.rs", "handlers"));
assert!(!matches_pattern("src/lib/foo.rs", "handlers"));
}
#[test]
fn test_matches_pattern_three_double_stars_returns_false() {
assert!(!matches_pattern("any/path", "**/foo/**"));
}
#[test]
fn test_resolve_project_path_complexity_rejects_none() {
let r = resolve_project_path_complexity(None);
assert!(r.is_err());
}
#[test]
fn test_resolve_project_path_complexity_rejects_empty_string() {
let r = resolve_project_path_complexity(Some(String::new()));
assert!(r.is_err());
}
#[test]
fn test_resolve_project_path_complexity_accepts_concrete_path() {
let r = resolve_project_path_complexity(Some(".".to_string()));
assert!(r.is_ok());
}
}