use std::collections::HashMap;
use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
response::Json,
};
use serde::{Deserialize, Serialize};
use crate::core::complexity::{compute_complexity_for, detect_smells_with_thresholds};
use crate::core::{analyze_refactor, quality, RefactorSuggestion, Severity};
use crate::service::events::{fetch_chunks, AnalyzerAppState, ApiError};
use crate::types::CodeChunk;
#[derive(Deserialize)]
pub struct HotspotsParams {
#[serde(default = "default_top_n")]
pub top_n: usize,
}
fn default_top_n() -> usize {
20
}
fn default_limit() -> usize {
500
}
fn default_offset() -> usize {
0
}
fn default_omit_content() -> bool {
true
}
#[derive(Deserialize)]
pub struct SmellsParams {
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default = "default_offset")]
pub offset: usize,
#[serde(default = "default_omit_content")]
pub omit_content: bool,
}
#[derive(Debug, Serialize)]
pub struct SmellItem {
pub id: String,
pub file: String,
pub start_line: usize,
pub end_line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function_name: Option<String>,
pub match_reason: String,
}
impl SmellItem {
fn from_chunk(chunk: &CodeChunk, include_content: bool) -> Self {
Self {
id: chunk.id.clone(),
file: chunk.file.clone(),
start_line: chunk.start_line,
end_line: chunk.end_line,
content: if include_content {
Some(chunk.content.clone())
} else {
None
},
function_name: chunk.function_name.clone(),
match_reason: chunk.match_reason.clone(),
}
}
}
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>,
Query(params): Query<SmellsParams>,
) -> Result<Json<serde_json::Value>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
let smelly = quality::smelly_chunks(&chunks);
let total = smelly.len();
let page: Vec<SmellItem> = smelly
.iter()
.skip(params.offset)
.take(params.limit)
.map(|c| SmellItem::from_chunk(c, !params.omit_content))
.collect();
let returned = page.len();
let truncated = (params.offset + returned) < total;
Ok(Json(serde_json::json!({
"index_id": id,
"total": total,
"offset": params.offset,
"limit": params.limit,
"returned": returned,
"truncated": truncated,
"chunks": page,
})))
}
#[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_with_thresholds(&chunk.content, &state.smell_thresholds);
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>,
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default = "default_offset")]
pub offset: usize,
}
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 root_path = state
.search
.index_status_root_path(&id)
.await
.ok()
.flatten();
let language_filter = params.language.clone();
let report: crate::core::DiagnosticsReport = tokio::task::spawn_blocking(move || {
crate::service::diagnostics_dispatch::run_diagnostics_blocking(
by_file,
language_filter,
tool_filter,
root_path,
)
})
.await
.map_err(|e| ApiError::internal(format!("diagnostics task panicked: {e}")))?;
let total = report.diagnostics.len();
let page: Vec<&crate::core::ToolDiagnostic> = report
.diagnostics
.iter()
.skip(params.offset)
.take(params.limit)
.collect();
let returned = page.len();
let truncated = (params.offset + returned) < total;
Ok(Json(serde_json::json!({
"index_id": id,
"total": total,
"offset": params.offset,
"limit": params.limit,
"returned": returned,
"truncated": truncated,
"tools_run": report.tools_run,
"tools_unavailable": report.tools_unavailable,
"diagnostics": page,
})))
}
#[cfg(test)]
#[path = "analysis_tests.rs"]
mod tests;