use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use axum::{
Json, Router,
extract::{Query, State},
http::{StatusCode, header},
response::{Html, IntoResponse, Response},
routing::get,
};
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use crate::cli_output::JsonHistory;
use crate::history::analyze_ref;
use crate::manifest::{ManifestContext, build_manifest};
use crate::report::{TextReportOptions, generate_report_with_options};
use super::graph;
use super::server::AppState;
#[derive(RustEmbed)]
#[folder = "web-assets/"]
struct Assets;
#[derive(Serialize)]
struct FrontendConfig {
api_endpoint: Option<String>,
}
#[derive(Deserialize)]
struct GraphQuery {
#[serde(rename = "ref")]
git_ref: Option<String>,
}
#[derive(Deserialize)]
struct SourceQuery {
path: String,
line: Option<usize>,
context: Option<usize>,
#[serde(rename = "ref")]
git_ref: Option<String>,
}
#[derive(Serialize)]
struct SourceResponse {
file_path: String,
file_name: String,
start_line: usize,
end_line: usize,
highlight_line: Option<usize>,
lines: Vec<SourceLine>,
total_lines: usize,
}
#[derive(Serialize)]
struct SourceLine {
number: usize,
content: String,
is_highlight: bool,
}
#[derive(Deserialize)]
struct ModuleQuery {
name: String,
}
pub fn api_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/graph", get(get_graph))
.route("/api/report", get(get_report))
.route("/api/history", get(get_history))
.route("/api/config", get(get_config))
.route("/api/health", get(health_check))
.route("/api/source", get(get_source))
.route("/api/module", get(get_module))
}
pub fn static_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(index_html))
.route("/{*path}", get(static_handler))
}
async fn get_graph(
State(state): State<Arc<AppState>>,
Query(query): Query<GraphQuery>,
) -> impl IntoResponse {
if let Some(git_ref) = query.git_ref.filter(|value| !value.trim().is_empty()) {
match analyze_ref(
&state.analysis_path,
&state.analysis_config,
&state.thresholds,
git_ref.trim(),
state.git_months,
!state.no_git,
) {
Ok(analysis) => {
let graph = graph::project_to_graph(&analysis.metrics, &state.thresholds);
Json(graph).into_response()
}
Err(e) => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
} else {
let graph = graph::project_to_graph(&state.metrics, &state.thresholds);
Json(graph).into_response()
}
}
async fn get_report(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let manifest = build_manifest(&ManifestContext {
git_used: !state.no_git
&& (!state.metrics.file_changes.is_empty()
|| !state.metrics.temporal_couplings.is_empty()),
tests_excluded: state.analysis_config.exclude_tests,
parse_failures: state.metrics.parse_failures,
skipped_crates: state.metrics.skipped_crates.clone(),
boundary_skipped_files: state.metrics.boundary_skipped_files,
dead_config_patterns: state.metrics.dead_config_patterns.clone(),
});
let mut output = Vec::new();
match generate_report_with_options(
&state.metrics,
&state.thresholds,
&manifest,
TextReportOptions::default(),
&mut output,
) {
Ok(()) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/markdown; charset=utf-8")
.body(axum::body::Body::from(output))
.unwrap(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn get_history(State(state): State<Arc<AppState>>) -> Json<JsonHistory> {
Json(state.history.clone())
}
async fn get_config(State(state): State<Arc<AppState>>) -> Json<FrontendConfig> {
Json(FrontendConfig {
api_endpoint: state.api_endpoint.clone(),
})
}
async fn health_check() -> &'static str {
"ok"
}
async fn get_source(
State(state): State<Arc<AppState>>,
Query(query): Query<SourceQuery>,
) -> impl IntoResponse {
let path = PathBuf::from(&query.path);
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Only .rs files are allowed"})),
)
.into_response();
}
let canonical_path = match path.canonicalize() {
Ok(path) => path,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("Invalid source path: {}", e)})),
)
.into_response();
}
};
if !canonical_path.starts_with(&state.source_root) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "Source path is outside the analyzed project"})),
)
.into_response();
}
let content = match read_source_content(&canonical_path, query.git_ref.as_deref()) {
Ok(c) => c,
Err(e) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("File not found: {}", e)})),
)
.into_response();
}
};
let all_lines: Vec<&str> = content.lines().collect();
let total_lines = all_lines.len();
let context = query.context.unwrap_or(10);
let highlight_line = query.line.filter(|&l| l > 0);
let (start_line, end_line) = if let Some(line) = highlight_line {
let start = line.saturating_sub(context).max(1);
let end = (line + context).min(total_lines);
(start, end)
} else {
(1, total_lines.min(30))
};
let lines: Vec<SourceLine> = all_lines
.iter()
.enumerate()
.filter(|(i, _)| {
let line_num = i + 1;
line_num >= start_line && line_num <= end_line
})
.map(|(i, content)| {
let line_num = i + 1;
SourceLine {
number: line_num,
content: (*content).to_string(),
is_highlight: highlight_line == Some(line_num),
}
})
.collect();
let file_name = canonical_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
Json(SourceResponse {
file_path: query.path,
file_name,
start_line,
end_line,
highlight_line,
lines,
total_lines,
})
.into_response()
}
fn read_source_content(path: &Path, git_ref: Option<&str>) -> Result<String, String> {
let Some(git_ref) = git_ref.filter(|value| !value.trim().is_empty()) else {
return fs::read_to_string(path).map_err(|e| e.to_string());
};
let repo_root = git_repo_root(path)?;
let relative = path
.strip_prefix(&repo_root)
.map_err(|_| "source path is outside the git repository".to_string())?
.to_string_lossy()
.replace('\\', "/");
let object = format!("{}:{}", git_ref.trim(), relative);
let output = Command::new("git")
.args(["show", &object])
.current_dir(&repo_root)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
fn git_repo_root(path: &Path) -> Result<PathBuf, String> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(dir)
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim().to_string());
Ok(root.canonicalize().unwrap_or(root))
}
async fn get_module(
State(state): State<Arc<AppState>>,
Query(query): Query<ModuleQuery>,
) -> impl IntoResponse {
let graph = graph::project_to_graph(&state.metrics, &state.thresholds);
if let Some(node) = graph
.nodes
.iter()
.find(|n| n.id == query.name || n.label == query.name)
{
Json(serde_json::json!({
"id": node.id,
"label": node.label,
"file_path": node.file_path,
"items": node.items,
"metrics": node.metrics,
"in_cycle": node.in_cycle,
}))
.into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Module '{}' not found", query.name)})),
)
.into_response()
}
}
async fn index_html() -> impl IntoResponse {
match Assets::get("index.html") {
Some(content) => Html(content.data.into_owned()).into_response(),
None => (StatusCode::NOT_FOUND, "index.html not found").into_response(),
}
}
async fn static_handler(
axum::extract::Path(path): axum::extract::Path<String>,
) -> impl IntoResponse {
let path = path.trim_start_matches('/');
match Assets::get(path) {
Some(content) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.body(axum::body::Body::from(content.data.into_owned()))
.unwrap()
}
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(axum::body::Body::from(format!("File not found: {}", path)))
.unwrap(),
}
}