use std::collections::HashMap;
use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
response::Json,
};
use serde::Deserialize;
use crate::core::complexity::{compute_complexity_for, detect_smells};
use crate::core::{analyze_refactor, quality, RefactorSuggestion, Severity};
use crate::service::events::{fetch_chunks, AnalyzerAppState, ApiError};
#[derive(Deserialize)]
pub struct HotspotsParams {
#[serde(default = "default_top_n")]
pub top_n: usize,
}
fn default_top_n() -> usize {
20
}
pub async fn complexity_hotspots(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
Query(params): Query<HotspotsParams>,
) -> Result<Json<serde_json::Value>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
let hotspots = quality::complexity_hotspots(&chunks, params.top_n);
Ok(Json(serde_json::json!({
"index_id": id,
"top_n": params.top_n,
"hotspots": hotspots,
})))
}
pub async fn smells(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
let smelly = quality::smelly_chunks(&chunks);
Ok(Json(serde_json::json!({
"index_id": id,
"count": smelly.len(),
"chunks": smelly,
})))
}
#[derive(Deserialize)]
pub struct RefactorParams {
pub file: Option<String>,
pub min_severity: Option<String>,
pub top_k: Option<usize>,
}
pub async fn refactor_suggestions(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
Query(params): Query<RefactorParams>,
) -> Result<Json<serde_json::Value>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
let min_severity = params
.min_severity
.as_deref()
.and_then(Severity::parse)
.unwrap_or(Severity::Low);
let top_k = params.top_k.unwrap_or(20);
let mut out: Vec<RefactorSuggestion> = Vec::new();
for chunk in &chunks {
if let Some(file) = params.file.as_deref() {
if chunk.file != file {
continue;
}
}
let lang = super::lang_for_extension(&chunk.file);
let metrics = compute_complexity_for(&chunk.content, lang);
let smells = detect_smells(&chunk.content);
let mut suggestions = analyze_refactor(
&chunk.id,
&chunk.file,
chunk.start_line as u32,
chunk.end_line as u32,
chunk.function_name.as_deref(),
&metrics,
&smells,
);
suggestions.retain(|s| s.severity >= min_severity);
out.extend(suggestions);
}
out.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| b.complexity_before.cmp(&a.complexity_before))
});
out.truncate(top_k);
Ok(Json(serde_json::json!({
"index_id": id,
"count": out.len(),
"min_severity": min_severity_label(&min_severity),
"suggestions": out,
})))
}
fn min_severity_label(s: &Severity) -> &'static str {
match s {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
pub async fn quality_report(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
) -> Result<Json<quality::QualityReport>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
Ok(Json(quality::aggregate_quality(&chunks)))
}
#[derive(Deserialize)]
pub struct DiagnosticsParams {
pub language: Option<String>,
pub tools: Option<String>,
}
pub async fn diagnostics_for_index(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
Query(params): Query<DiagnosticsParams>,
) -> Result<Json<serde_json::Value>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
let tool_filter: Option<Vec<String>> = params.tools.as_ref().map(|s| {
s.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
});
let mut by_file: HashMap<String, String> = HashMap::new();
for chunk in &chunks {
let entry = by_file.entry(chunk.file.clone()).or_default();
if chunk.content.len() > entry.len() {
*entry = chunk.content.clone();
}
}
let language_filter = params.language.clone();
let diagnostics: Vec<crate::core::ToolDiagnostic> = tokio::task::spawn_blocking(move || {
run_diagnostics_blocking(by_file, language_filter, tool_filter)
})
.await
.map_err(|e| ApiError::internal(format!("diagnostics task panicked: {e}")))?;
Ok(Json(serde_json::json!({
"index_id": id,
"count": diagnostics.len(),
"diagnostics": diagnostics,
})))
}
pub(crate) fn run_diagnostics_blocking(
by_file: HashMap<String, String>,
language_filter: Option<String>,
tool_filter: Option<Vec<String>>,
) -> Vec<crate::core::ToolDiagnostic> {
use crate::core::global_registry;
use crate::lang::LanguageDetector;
let registry = global_registry();
let scratch = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => {
tracing::warn!("failed to create scratch dir for diagnostics: {e}");
return Vec::new();
}
};
let mut out = Vec::new();
for (idx, (file, content)) in by_file.into_iter().enumerate() {
let Some(lang) = LanguageDetector::detect_file(&file) else {
continue;
};
if let Some(want) = &language_filter {
if &lang != want {
continue;
}
}
if registry.tools_for(&lang).is_empty() {
continue;
}
let name = std::path::Path::new(&file)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "chunk.txt".to_string());
let file_dir = scratch.path().join(idx.to_string());
if let Err(e) = std::fs::create_dir_all(&file_dir) {
tracing::warn!("failed to create scratch subdir for {name}: {e}");
continue;
}
let path = file_dir.join(&name);
if let Err(e) = std::fs::write(&path, &content) {
tracing::warn!("failed to write scratch file {name}: {e}");
continue;
}
let result = match &tool_filter {
Some(names) => registry.run_named(&lang, names, &path, &content),
None => registry.run_all(&lang, &path, &content),
};
match result {
Ok(mut diags) => {
for d in &mut diags {
d.file = file.clone();
}
out.extend(diags);
}
Err(e) => tracing::warn!("diagnostics for {file} failed: {e:#}"),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_diagnostics_blocking_two_files_same_basename() {
let mut by_file = HashMap::new();
by_file.insert("src/a/main.rs".to_string(), "fn a() {}".to_string());
by_file.insert("src/b/main.rs".to_string(), "fn b() {}".to_string());
let _result = run_diagnostics_blocking(by_file, None, None);
}
}