mod ui;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use crate::core::complexity::{compute_complexity_for, detect_smells};
use crate::core::{
analyze_refactor, bow_embedding, cluster as run_cluster, extract_doc_comments,
extract_kg_from_scip, facts::new_fact, quality, AnalyzerRegistry, ClusterResult, FactStore,
IndexSummary, NerExtractor, RefactorSuggestion, ScipIngestSummary, Severity,
TrustySearchClient,
};
use crate::embedder::{BowEmbedder, Embedder, EmbedderKind};
use crate::types::{KgGraph, KgNode, RawEntity};
use anyhow::Result;
use axum::{
body::Bytes,
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Json, Redirect, Response},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, RwLock};
#[derive(Clone, Debug, serde::Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AnalyzerEvent {
AnalysisStarted {
index_id: String,
},
AnalysisCompleted {
index_id: String,
chunk_count: usize,
duration_ms: u64,
},
FactUpserted {
subject: String,
predicate: String,
},
FactDeleted {
id: String,
},
ScipIngested {
index_id: String,
symbols_ingested: usize,
},
}
pub const DEFAULT_PORT: u16 = 7879;
#[derive(Clone)]
pub struct AnalyzerAppState {
pub search: TrustySearchClient,
pub facts: FactStore,
pub registry: Arc<AnalyzerRegistry>,
pub embedder: Arc<dyn Embedder>,
pub scip_overlays: Arc<RwLock<HashMap<String, KgGraph>>>,
pub events: broadcast::Sender<AnalyzerEvent>,
pub webhook_secret: Option<String>,
pub api_key: Option<String>,
pub llm_model: String,
}
impl AnalyzerAppState {
pub fn new(search: TrustySearchClient, facts: FactStore) -> Self {
let (events_tx, _) = broadcast::channel(128);
Self {
search,
facts,
registry: Arc::new(AnalyzerRegistry::default_registry()),
embedder: Arc::new(BowEmbedder::default()),
scip_overlays: Arc::new(RwLock::new(HashMap::new())),
events: events_tx,
webhook_secret: None,
api_key: std::env::var("OPENROUTER_API_KEY").ok(),
llm_model: std::env::var("TRUSTY_LLM_MODEL")
.unwrap_or_else(|_| "openai/gpt-4o-mini".to_string()),
}
}
pub fn with_registry(
search: TrustySearchClient,
facts: FactStore,
registry: Arc<AnalyzerRegistry>,
) -> Self {
let (events_tx, _) = broadcast::channel(128);
Self {
search,
facts,
registry,
embedder: Arc::new(BowEmbedder::default()),
scip_overlays: Arc::new(RwLock::new(HashMap::new())),
events: events_tx,
webhook_secret: None,
api_key: std::env::var("OPENROUTER_API_KEY").ok(),
llm_model: std::env::var("TRUSTY_LLM_MODEL")
.unwrap_or_else(|_| "openai/gpt-4o-mini".to_string()),
}
}
pub fn with_api_key(mut self, key: Option<String>) -> Self {
self.api_key = key;
self
}
pub fn with_llm_model(mut self, model: impl Into<String>) -> Self {
self.llm_model = model.into();
self
}
pub fn with_webhook_secret(mut self, secret: Option<String>) -> Self {
self.webhook_secret = secret;
self
}
pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
self.embedder = embedder;
self
}
pub fn emit(&self, event: AnalyzerEvent) {
let _ = self.events.send(event);
}
}
pub(crate) struct ApiError {
pub status: StatusCode,
pub message: String,
}
impl ApiError {
pub fn bad_request(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: msg.into(),
}
}
#[allow(dead_code)]
pub fn not_found(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: msg.into(),
}
}
pub fn internal(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: msg.into(),
}
}
pub fn bad_gateway(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_GATEWAY,
message: msg.into(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(serde_json::json!({ "error": self.message })),
)
.into_response()
}
}
pub fn build_router(state: AnalyzerAppState) -> Router {
let router = Router::new()
.route("/", get(|| async { Redirect::permanent("/ui/") }))
.route("/health", get(health))
.route("/sse", get(sse_handler))
.route("/indexes", get(list_indexes))
.route(
"/indexes/{id}/complexity_hotspots",
get(complexity_hotspots),
)
.route("/indexes/{id}/smells", get(smells))
.route(
"/indexes/{id}/refactor-suggestions",
get(refactor_suggestions),
)
.route("/indexes/{id}/quality", get(quality_report))
.route("/indexes/{id}/diagnostics", get(diagnostics_for_index))
.route("/indexes/{id}/graph", get(graph_for_index))
.route("/indexes/{id}/entities", get(entities_for_index))
.route("/indexes/{id}/clusters", get(clusters_for_index))
.route("/indexes/{id}/ner", get(ner_for_index))
.route("/indexes/{id}/scip", post(ingest_scip))
.route("/review", post(review_diff_handler))
.route("/review/github-pr", post(review_github_pr_handler))
.route("/analyze/deep", post(deep_analyze_handler))
.route("/webhooks/github", post(github_webhook_handler))
.route("/facts", get(list_facts).post(upsert_fact))
.route("/facts/{id}", delete(delete_fact))
.route("/ui", get(|| async { Redirect::permanent("/ui/") }))
.route("/ui/", get(ui::ui_index_handler))
.route("/ui/{*path}", get(ui::ui_asset_handler))
.with_state(Arc::new(state));
trusty_common::server::with_standard_middleware(router)
}
async fn sse_handler(State(state): State<Arc<AnalyzerAppState>>) -> impl IntoResponse {
use futures::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
let rx = state.events.subscribe();
let initial = futures::stream::once(async {
Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(
"data: {\"type\":\"connected\"}\n\n",
))
});
let events = BroadcastStream::new(rx).map(|res| {
let frame = match res {
Ok(event) => match serde_json::to_string(&event) {
Ok(json) => format!("data: {json}\n\n"),
Err(e) => format!("data: {{\"type\":\"error\",\"message\":\"{e}\"}}\n\n"),
},
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
format!("data: {{\"type\":\"lag\",\"skipped\":{n}}}\n\n")
}
};
Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(frame))
});
let stream = initial.chain(events);
axum::response::Response::builder()
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("X-Accel-Buffering", "no")
.body(axum::body::Body::from_stream(stream))
.expect("valid SSE response")
}
pub async fn serve(state: AnalyzerAppState, start_port: u16) -> Result<()> {
let start_addr: SocketAddr = ([127, 0, 0, 1], start_port).into();
let listener = trusty_common::bind_with_auto_port(start_addr, 64).await?;
let actual = listener.local_addr()?;
trusty_common::write_daemon_addr("trusty-analyze", &actual.to_string())?;
tracing::info!("trusty-analyze listening on http://{actual}");
let app = build_router(state);
axum::serve(listener, app).await?;
Ok(())
}
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
version: &'static str,
search_reachable: bool,
}
async fn health(
State(state): State<Arc<AnalyzerAppState>>,
) -> Result<Json<HealthResponse>, (StatusCode, Json<HealthResponse>)> {
let search_reachable = state.search.health().await.unwrap_or(false);
let response = HealthResponse {
status: if search_reachable { "ok" } else { "degraded" },
version: env!("CARGO_PKG_VERSION"),
search_reachable,
};
if search_reachable {
Ok(Json(response))
} else {
Err((StatusCode::SERVICE_UNAVAILABLE, Json(response)))
}
}
async fn list_indexes(
State(state): State<Arc<AnalyzerAppState>>,
) -> Result<Json<Vec<IndexSummary>>, ApiError> {
state.search.list_indexes().await.map(Json).map_err(|e| {
tracing::warn!("list_indexes proxy failed: {e:#}");
ApiError::bad_gateway(format!("upstream search daemon: {e:#}"))
})
}
#[derive(Deserialize)]
pub struct HotspotsParams {
#[serde(default = "default_top_n")]
pub top_n: usize,
}
fn default_top_n() -> usize {
20
}
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,
})))
}
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>,
}
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 = language_for_path(&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 language_for_path(path: &str) -> &'static str {
let lower = path.to_ascii_lowercase();
if lower.ends_with(".rs") {
"rust"
} else if lower.ends_with(".tsx") {
"tsx"
} else if lower.ends_with(".ts") {
"typescript"
} else if lower.ends_with(".jsx") {
"jsx"
} else if lower.ends_with(".js") {
"javascript"
} else if lower.ends_with(".py") {
"python"
} else if lower.ends_with(".go") {
"go"
} else if lower.ends_with(".java") {
"java"
} else {
"unknown"
}
}
fn min_severity_label(s: &Severity) -> &'static str {
match s {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
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>,
}
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,
})))
}
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 (file, content) in by_file {
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 path = scratch.path().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
}
#[derive(Deserialize)]
pub struct GraphQueryParams {
pub language: Option<String>,
}
async fn graph_for_index(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
Query(params): Query<GraphQueryParams>,
) -> Result<Json<KgGraph>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
let res = state.registry.analyze(&chunks);
let mut graph = res.graph;
if let Some(overlay) = state.scip_overlays.read().await.get(&id).cloned() {
graph.merge(overlay);
graph = crate::core::link(graph);
}
if let Some(lang) = params.language.as_deref() {
let keep_nodes: std::collections::HashSet<String> = graph
.nodes
.iter()
.filter(|n| n.language == lang)
.map(|n| n.id.clone())
.collect();
graph.nodes.retain(|n| keep_nodes.contains(&n.id));
graph
.edges
.retain(|e| keep_nodes.contains(&e.from) && keep_nodes.contains(&e.to));
}
Ok(Json(graph))
}
#[derive(Deserialize)]
pub struct EntitiesQueryParams {
pub kind: Option<String>,
pub language: Option<String>,
}
async fn entities_for_index(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
Query(params): Query<EntitiesQueryParams>,
) -> Result<Json<Vec<KgNode>>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
let res = state.registry.analyze(&chunks);
let mut nodes = res.graph.nodes;
if let Some(lang) = params.language.as_deref() {
nodes.retain(|n| n.language == lang);
}
if let Some(kind) = params.kind.as_deref() {
nodes.retain(|n| format!("{:?}", n.kind) == kind);
}
nodes.sort_by(|a, b| {
format!("{:?}", a.kind)
.cmp(&format!("{:?}", b.kind))
.then_with(|| a.name.cmp(&b.name))
});
Ok(Json(nodes))
}
#[derive(Deserialize)]
pub struct ClusterQueryParams {
pub k: Option<usize>,
#[serde(default)]
pub method: Option<EmbedderKind>,
}
#[derive(Serialize)]
pub struct ClusterResponseItem {
pub id: usize,
pub label: String,
pub members: Vec<String>,
pub cohesion: f32,
pub size: usize,
}
#[derive(Serialize)]
pub struct ClusterResponse {
pub k: usize,
pub method: String,
pub dim: usize,
pub iterations: usize,
pub chunk_count: usize,
pub clusters: Vec<ClusterResponseItem>,
}
fn cluster_items_from(r: ClusterResult) -> Vec<ClusterResponseItem> {
r.clusters
.into_iter()
.map(|c| ClusterResponseItem {
id: c.id,
label: c.label,
size: c.members.len(),
members: c.members,
cohesion: c.cohesion,
})
.collect()
}
async fn clusters_for_index(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
Query(params): Query<ClusterQueryParams>,
) -> Result<Json<ClusterResponse>, ApiError> {
const BOW_DIM: usize = 256;
let k = params.k.unwrap_or(8).clamp(1, 50);
let method = params.method.clone().unwrap_or_default();
let chunks = fetch_chunks(&state, &id).await?;
if chunks.is_empty() {
return Ok(Json(ClusterResponse {
k,
method: method.as_str().to_string(),
dim: 0,
iterations: 0,
chunk_count: 0,
clusters: Vec::new(),
}));
}
let neural_embedder: Arc<dyn Embedder> = state.embedder.clone();
let bow_embedder = BowEmbedder::with_dim(BOW_DIM);
let effective_kind_initial: EmbedderKind = match method {
EmbedderKind::Neural => neural_embedder.kind(),
EmbedderKind::Bow => EmbedderKind::Bow,
};
let owned_texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
let embed_result: anyhow::Result<(Vec<Vec<f32>>, EmbedderKind, usize)> = match method {
EmbedderKind::Neural => {
let embedder_arc = Arc::clone(&neural_embedder);
let dim = embedder_arc.dim();
let texts_for_task = owned_texts.clone();
tokio::task::spawn_blocking(move || {
let refs: Vec<&str> = texts_for_task.iter().map(String::as_str).collect();
embedder_arc.embed_batch(&refs)
})
.await
.unwrap_or_else(|e| Err(anyhow::anyhow!("embed_batch task panicked: {e}")))
.map(|v| (v, EmbedderKind::Neural, dim))
}
EmbedderKind::Bow => {
let vecs: Vec<Vec<f32>> = owned_texts
.iter()
.map(|t| bow_embedding(t, BOW_DIM))
.collect();
Ok((vecs, EmbedderKind::Bow, BOW_DIM))
}
};
let (vecs, effective_kind, dim) = match embed_result {
Ok(triple) => triple,
Err(e) => {
tracing::warn!(
"embedder ({:?}) failed ({e:#}); falling back to BOW",
effective_kind_initial
);
let fallback: Vec<Vec<f32>> = owned_texts
.iter()
.map(|t| bow_embedding(t, BOW_DIM))
.collect();
(fallback, EmbedderKind::Bow, BOW_DIM)
}
};
let _ = &bow_embedder;
let embeddings: Vec<(String, Vec<f32>)> = chunks
.iter()
.zip(vecs)
.map(|(c, v)| (c.id.clone(), v))
.collect();
let result = run_cluster(&embeddings, k, 100, 42);
let iterations = result.iterations;
Ok(Json(ClusterResponse {
k,
method: effective_kind.as_str().to_string(),
dim,
iterations,
chunk_count: chunks.len(),
clusters: cluster_items_from(result),
}))
}
#[derive(Deserialize)]
pub struct NerQueryParams {
pub top_k: Option<usize>,
}
async fn ner_for_index(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
Query(params): Query<NerQueryParams>,
) -> Result<Json<Vec<RawEntity>>, ApiError> {
let chunks = fetch_chunks(&state, &id).await?;
let top_k = params.top_k.unwrap_or(50);
let extractor = NerExtractor::try_load();
let mut entities: Vec<RawEntity> = Vec::new();
for chunk in &chunks {
let docs = extract_doc_comments(&chunk.content);
if docs.is_empty() {
continue;
}
entities.extend(extractor.extract(&docs, &chunk.file));
if entities.len() >= top_k {
break;
}
}
entities.truncate(top_k);
Ok(Json(entities))
}
#[derive(Serialize)]
pub struct ScipIngestResponse {
pub index_id: String,
#[serde(flatten)]
pub summary: ScipIngestSummary,
}
async fn ingest_scip(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<String>,
body: Bytes,
) -> Result<Json<ScipIngestResponse>, ApiError> {
let (graph, summary) = extract_kg_from_scip(&body).map_err(|e| {
tracing::warn!("SCIP ingest for {id} failed: {e:#}");
ApiError::bad_request(format!("invalid SCIP protobuf: {e:#}"))
})?;
let symbols_ingested = summary.kg_nodes;
state.scip_overlays.write().await.insert(id.clone(), graph);
state.emit(AnalyzerEvent::ScipIngested {
index_id: id.clone(),
symbols_ingested,
});
Ok(Json(ScipIngestResponse {
index_id: id,
summary,
}))
}
#[derive(Deserialize)]
pub struct ReviewQueryParams {
pub index_id: Option<String>,
}
async fn review_diff_handler(
State(state): State<Arc<AnalyzerAppState>>,
Query(params): Query<ReviewQueryParams>,
body: Bytes,
) -> Result<Json<crate::core::ReviewReport>, ApiError> {
let index_id = params
.index_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::bad_request("missing required 'index_id' query parameter"))?;
let diff = std::str::from_utf8(&body)
.map_err(|e| ApiError::bad_request(format!("diff body is not valid UTF-8: {e}")))?;
let report = crate::core::analyze_diff_with_client(diff, &state.search, index_id)
.await
.map_err(|e| match e {
crate::core::ReviewError::MalformedHunkHeader(_) => {
ApiError::bad_request(format!("invalid diff: {e}"))
}
crate::core::ReviewError::Search(_) => ApiError::bad_gateway(format!("{e}")),
})?;
Ok(Json(report))
}
async fn review_github_pr_handler(
State(state): State<Arc<AnalyzerAppState>>,
Json(req): Json<crate::core::GithubPrRequest>,
) -> Result<Json<crate::core::ReviewReport>, ApiError> {
let token = std::env::var("GITHUB_TOKEN").map_err(|_| {
ApiError::bad_request("GITHUB_TOKEN environment variable is not set on the daemon")
})?;
let client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("reqwest ClientBuilder is infallible with valid config");
let diff = crate::core::fetch_pr_diff(&client, &req.owner, &req.repo, req.pr, &token)
.await
.map_err(|e| ApiError::bad_gateway(format!("fetch PR diff: {e}")))?;
let report = crate::core::analyze_diff_with_client(&diff, &state.search, &req.index_id)
.await
.map_err(|e| match e {
crate::core::ReviewError::MalformedHunkHeader(_) => {
ApiError::bad_request(format!("invalid diff: {e}"))
}
crate::core::ReviewError::Search(_) => ApiError::bad_gateway(format!("{e}")),
})?;
if req.post_comment {
let markdown = crate::core::format_review_as_markdown(&report);
crate::core::post_pr_comment(&client, &req.owner, &req.repo, req.pr, &markdown, &token)
.await
.map_err(|e| ApiError::bad_gateway(format!("post PR comment: {e}")))?;
}
Ok(Json(report))
}
#[derive(Debug, Deserialize)]
pub struct DeepAnalyzeRequest {
pub index_id: String,
#[serde(default)]
pub report: Option<crate::core::ReviewReport>,
#[serde(default)]
pub model: Option<String>,
}
async fn deep_analyze_handler(
State(state): State<Arc<AnalyzerAppState>>,
Json(req): Json<DeepAnalyzeRequest>,
) -> Result<Json<crate::core::DeepAnalysisReport>, ApiError> {
if req.index_id.trim().is_empty() {
return Err(ApiError::bad_request("missing required 'index_id' field"));
}
let api_key = state.api_key.as_deref().filter(|s| !s.is_empty());
if api_key.is_none() {
return Err(ApiError::bad_request(
"OPENROUTER_API_KEY is not configured on the daemon; cannot run deep analysis",
));
}
let report = match req.report {
Some(r) => r,
None => synthesise_review_from_index(&state, &req.index_id).await?,
};
let frameworks = lookup_frameworks(&state, &req.index_id);
let model_override = req.model.as_deref();
let report = crate::core::deep_analysis(
&req.index_id,
report,
frameworks,
api_key,
model_override.or(Some(&state.llm_model)),
)
.await
.map_err(|e| match e {
crate::core::DeepAnalysisError::MissingApiKey => ApiError::bad_request(format!("{e}")),
crate::core::DeepAnalysisError::Chat(_) => ApiError::bad_gateway(format!("{e}")),
})?;
Ok(Json(report))
}
async fn synthesise_review_from_index(
state: &AnalyzerAppState,
index_id: &str,
) -> Result<crate::core::ReviewReport, ApiError> {
let chunks = state.search.get_chunks(index_id).await.map_err(|e| {
ApiError::bad_gateway(format!("get_chunks({index_id}) for deep analysis: {e:#}"))
})?;
Ok(synthesise_review_from_chunks(&chunks))
}
fn synthesise_review_from_chunks(chunks: &[crate::types::CodeChunk]) -> crate::core::ReviewReport {
use crate::core::complexity::{compute_complexity_for, detect_smells};
use crate::core::review::{FileReview, ReviewComplexity, ReviewSource, SmellHit};
use crate::types::complexity::CodeSmell;
use std::collections::BTreeMap;
fn project(s: &CodeSmell) -> (&'static str, &'static str) {
match s {
CodeSmell::LongFunction { .. } => ("long_method", "medium"),
CodeSmell::DeepNesting { .. } => ("deep_nesting", "high"),
CodeSmell::TooManyParams { .. } => ("too_many_params", "medium"),
CodeSmell::MissingDocstring => ("missing_docstring", "low"),
}
}
let mut by_file: BTreeMap<String, Vec<&crate::types::CodeChunk>> = BTreeMap::new();
for c in chunks {
by_file.entry(c.file.clone()).or_default().push(c);
}
let mut files: Vec<FileReview> = Vec::with_capacity(by_file.len());
let mut total_smells = 0usize;
let mut total_lines = 0usize;
for (path, group) in by_file {
let joined: String = group
.iter()
.map(|c| c.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let lang = match path.rsplit('.').next().unwrap_or("") {
"rs" => "rust",
"ts" => "typescript",
"tsx" => "tsx",
"js" => "javascript",
"jsx" => "jsx",
"py" => "python",
"go" => "go",
"java" => "java",
_ => "unknown",
};
let metrics = compute_complexity_for(&joined, lang);
let raw_smells = detect_smells(&joined);
let smells: Vec<SmellHit> = raw_smells
.iter()
.map(|s| {
let (category, severity) = project(s);
SmellHit {
category: category.to_string(),
line: group.first().map(|c| c.start_line as u32).unwrap_or(0),
severity: severity.to_string(),
}
})
.collect();
total_smells += smells.len();
total_lines += joined.lines().count();
files.push(FileReview {
path,
grade: metrics.grade,
complexity: ReviewComplexity {
cyclomatic: metrics.cyclomatic,
cognitive: metrics.cognitive,
},
smells,
recommendations: Vec::new(),
source: ReviewSource::NewFile,
});
}
let overall_grade = files
.iter()
.map(|f| f.grade)
.max()
.unwrap_or(crate::types::ComplexityGrade::A);
let summary = format!(
"{} file(s) synthesised from index corpus; {} smell(s); overall grade {}",
files.len(),
total_smells,
overall_grade
);
crate::core::ReviewReport {
files,
overall_grade,
changed_lines: total_lines,
smell_count: total_smells,
summary,
}
}
fn lookup_frameworks(state: &AnalyzerAppState, index_id: &str) -> Vec<String> {
use std::collections::BTreeSet;
let Ok(hits) = state
.facts
.query(Some(index_id), Some("uses_framework"), None)
else {
return Vec::new();
};
let mut names: BTreeSet<String> = BTreeSet::new();
for fact in hits {
names.insert(fact.object);
}
names.into_iter().collect()
}
async fn github_webhook_handler(
State(state): State<Arc<AnalyzerAppState>>,
headers: axum::http::HeaderMap,
body: Bytes,
) -> Result<StatusCode, ApiError> {
let secret = state
.webhook_secret
.clone()
.or_else(|| std::env::var("GITHUB_WEBHOOK_SECRET").ok())
.filter(|s| !s.is_empty());
match secret {
Some(secret) => {
let sig = headers
.get("X-Hub-Signature-256")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !crate::core::verify_webhook_signature(&secret, &body, sig) {
return Err(ApiError {
status: StatusCode::UNAUTHORIZED,
message: "X-Hub-Signature-256 verification failed".to_string(),
});
}
}
None => {
tracing::warn!(
"no webhook secret configured — skipping webhook signature verification"
);
}
}
let event = headers
.get("X-GitHub-Event")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if event != "pull_request" {
return Ok(StatusCode::ACCEPTED);
}
let payload: serde_json::Value = serde_json::from_slice(&body)
.map_err(|e| ApiError::bad_request(format!("webhook body is not valid JSON: {e}")))?;
let action = payload.get("action").and_then(|v| v.as_str()).unwrap_or("");
if !matches!(action, "opened" | "synchronize" | "reopened") {
return Ok(StatusCode::ACCEPTED);
}
let pr = payload
.get("pull_request")
.and_then(|p| p.get("number"))
.and_then(|n| n.as_u64());
let owner = payload
.get("repository")
.and_then(|r| r.get("owner"))
.and_then(|o| o.get("login"))
.and_then(|l| l.as_str())
.map(str::to_owned);
let repo = payload
.get("repository")
.and_then(|r| r.get("name"))
.and_then(|n| n.as_str())
.map(str::to_owned);
let head_sha = payload
.get("pull_request")
.and_then(|p| p.get("head"))
.and_then(|h| h.get("sha"))
.and_then(|s| s.as_str())
.unwrap_or("unknown")
.to_string();
let (Some(pr), Some(owner), Some(repo)) = (pr, owner, repo) else {
return Err(ApiError::bad_request(
"webhook payload missing pull_request.number or repository owner/name",
));
};
let search = state.search.clone();
tokio::spawn(async move {
if let Err(e) = process_pr_webhook(search, &owner, &repo, pr, &head_sha).await {
tracing::warn!("github webhook PR {owner}/{repo}#{pr} processing failed: {e:#}");
}
});
Ok(StatusCode::ACCEPTED)
}
async fn process_pr_webhook(
search: TrustySearchClient,
owner: &str,
repo: &str,
pr: u64,
head_sha: &str,
) -> Result<()> {
let token = std::env::var("GITHUB_TOKEN")
.map_err(|_| anyhow::anyhow!("GITHUB_TOKEN not set; cannot process webhook PR"))?;
tracing::info!("processing webhook PR {owner}/{repo}#{pr} (head {head_sha})");
let client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("reqwest ClientBuilder is infallible with valid config");
let diff = crate::core::fetch_pr_diff(&client, owner, repo, pr, &token).await?;
let report = crate::core::analyze_diff_with_client(&diff, &search, repo).await?;
let markdown = crate::core::format_review_as_markdown(&report);
crate::core::post_pr_comment(&client, owner, repo, pr, &markdown, &token).await?;
tracing::info!("posted webhook review comment to {owner}/{repo}#{pr}");
Ok(())
}
async fn fetch_chunks(
state: &AnalyzerAppState,
id: &str,
) -> Result<Vec<crate::types::CodeChunk>, ApiError> {
state.search.get_chunks(id).await.map_err(|e| {
tracing::warn!("get_chunks({id}) failed: {e:#}");
ApiError::bad_gateway(format!("get_chunks({id}): {e:#}"))
})
}
#[derive(Deserialize)]
pub struct FactQueryParams {
pub subject: Option<String>,
pub predicate: Option<String>,
pub object: Option<String>,
}
async fn list_facts(
State(state): State<Arc<AnalyzerAppState>>,
Query(p): Query<FactQueryParams>,
) -> Result<Json<serde_json::Value>, ApiError> {
let facts = state.facts.clone();
let hits = tokio::task::spawn_blocking(move || {
facts.query(
p.subject.as_deref(),
p.predicate.as_deref(),
p.object.as_deref(),
)
})
.await
.map_err(|e| ApiError::internal(format!("query facts task panicked: {e}")))?
.map_err(|e| ApiError::internal(format!("query facts: {e:#}")))?;
let count = hits.len();
Ok(Json(serde_json::json!({ "facts": hits, "count": count })))
}
#[derive(Deserialize)]
pub struct UpsertFactRequest {
pub subject: String,
pub predicate: String,
pub object: String,
pub index_id: String,
#[serde(default = "default_confidence")]
pub confidence: f32,
#[serde(default)]
pub provenance: Vec<String>,
}
fn default_confidence() -> f32 {
1.0
}
async fn upsert_fact(
State(state): State<Arc<AnalyzerAppState>>,
Json(req): Json<UpsertFactRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let subject = req.subject.clone();
let predicate = req.predicate.clone();
let mut fact = new_fact(req.subject, req.predicate, req.object, req.index_id);
fact.confidence = req.confidence.clamp(0.0, 1.0);
fact.provenance = req.provenance;
let id = fact.id;
let facts = state.facts.clone();
tokio::task::spawn_blocking(move || facts.upsert(fact))
.await
.map_err(|e| ApiError::internal(format!("upsert fact task panicked: {e}")))?
.map_err(|e| ApiError::internal(format!("upsert fact: {e:#}")))?;
state.emit(AnalyzerEvent::FactUpserted { subject, predicate });
Ok(Json(serde_json::json!({ "id": id, "upserted": true })))
}
async fn delete_fact(
State(state): State<Arc<AnalyzerAppState>>,
Path(id): Path<u64>,
) -> Result<Json<serde_json::Value>, ApiError> {
let facts = state.facts.clone();
let removed = tokio::task::spawn_blocking(move || facts.delete(id))
.await
.map_err(|e| ApiError::internal(format!("delete fact task panicked: {e}")))?
.map_err(|e| ApiError::internal(format!("delete fact: {e:#}")))?;
if removed {
state.emit(AnalyzerEvent::FactDeleted { id: id.to_string() });
}
Ok(Json(serde_json::json!({ "id": id, "removed": removed })))
}
pub use crate::types::FactRecord as PublicFactRecord;
#[cfg(test)]
mod tests {
use super::*;
use axum::body::{to_bytes, Body};
use axum::http::{Method, Request};
use tempfile::TempDir;
use tower::ServiceExt;
fn make_state() -> (AnalyzerAppState, TempDir) {
let tmp = TempDir::new().unwrap();
let facts = FactStore::open(&tmp.path().join("facts.redb")).unwrap();
let search = TrustySearchClient::new("http://127.0.0.1:1");
(AnalyzerAppState::new(search, facts), tmp)
}
async fn json_get(app: Router, uri: &str) -> (StatusCode, serde_json::Value) {
let resp = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = to_bytes(resp.into_body(), 1024 * 1024).await.unwrap();
let value = if bytes.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
};
(status, value)
}
#[tokio::test]
async fn health_degraded_when_search_unreachable() {
let (state, _tmp) = make_state();
let app = build_router(state);
let (status, body) = json_get(app, "/health").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body["status"], "degraded");
assert_eq!(body["search_reachable"], false);
}
#[tokio::test]
async fn health_response_includes_version() {
let (state, _tmp) = make_state();
let app = build_router(state);
let (_status, body) = json_get(app, "/health").await;
assert!(body["version"].is_string());
assert!(!body["version"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn sse_subscriber_receives_emitted_event() {
let (state, _tmp) = make_state();
let mut rx = state.events.subscribe();
state.emit(AnalyzerEvent::FactUpserted {
subject: "fn auth".into(),
predicate: "uses".into(),
});
let evt = rx
.recv()
.await
.expect("subscriber should receive emitted event");
match evt {
AnalyzerEvent::FactUpserted { subject, predicate } => {
assert_eq!(subject, "fn auth");
assert_eq!(predicate, "uses");
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn sse_route_returns_event_stream_content_type() {
let (state, _tmp) = make_state();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/sse")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(ct.starts_with("text/event-stream"), "got {ct}");
}
#[test]
fn run_diagnostics_blocking_skips_unknown_languages() {
let mut by_file = HashMap::new();
by_file.insert("notes.txt".to_string(), "hello world".to_string());
let diags = run_diagnostics_blocking(by_file, None, None);
assert!(diags.is_empty());
}
#[test]
fn run_diagnostics_blocking_respects_language_filter() {
let mut by_file = HashMap::new();
by_file.insert("main.rs".to_string(), "fn main() {}".to_string());
let diags = run_diagnostics_blocking(by_file, Some("python".to_string()), None);
assert!(diags.is_empty());
}
#[tokio::test]
async fn diagnostics_endpoint_surfaces_search_failure_as_502() {
let (state, _tmp) = make_state();
let app = build_router(state);
let (status, _body) = json_get(app, "/indexes/demo/diagnostics").await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn upsert_then_list_facts_round_trip() {
let (state, _tmp) = make_state();
let app = build_router(state);
let body = serde_json::json!({
"subject": "fn search",
"predicate": "implements",
"object": "trait Searcher",
"index_id": "test"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/facts")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let (status, listing) = json_get(app, "/facts").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(listing["count"], 1);
}
#[tokio::test]
async fn scip_ingest_accepts_valid_index_and_stores_overlay() {
use protobuf::{EnumOrUnknown, Message};
use scip::types::{
symbol_information::Kind as ScipKind, Document, Index, Occurrence, SymbolInformation,
};
let (state, _tmp) = make_state();
let overlays = state.scip_overlays.clone();
let app = build_router(state);
let mut sym = SymbolInformation::new();
sym.symbol = "rust . . hello().".into();
sym.kind = EnumOrUnknown::new(ScipKind::Function);
sym.display_name = "hello".into();
let mut occ = Occurrence::new();
occ.symbol = sym.symbol.clone();
occ.symbol_roles = 0x1;
occ.range = vec![1, 0, 5];
let mut doc = Document::new();
doc.relative_path = "src/lib.rs".into();
doc.language = "rust".into();
doc.symbols.push(sym);
doc.occurrences.push(occ);
let mut index = Index::new();
index.documents.push(doc);
let bytes = index.write_to_bytes().expect("encode scip index");
let resp = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/indexes/myidx/scip")
.header("content-type", "application/octet-stream")
.body(Body::from(bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), 1 << 20).await.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["index_id"], "myidx");
assert_eq!(parsed["documents"], 1);
assert_eq!(parsed["kg_nodes"], 1);
let overlays = overlays.read().await;
let g = overlays.get("myidx").expect("overlay stored");
assert_eq!(g.node_count(), 1);
assert_eq!(g.nodes[0].name, "hello");
}
#[tokio::test]
async fn scip_ingest_rejects_garbage_bytes() {
let (state, _tmp) = make_state();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/indexes/x/scip")
.header("content-type", "application/octet-stream")
.body(Body::from(vec![0xFF, 0xFF, 0xFF, 0xFF]))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn review_endpoint_requires_index_id() {
let (state, _tmp) = make_state();
let app = build_router(state);
let diff = "+++ b/src/foo.rs\n@@ -0,0 +1,2 @@\n+/// doc\n+fn f() {}\n";
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/review")
.header("content-type", "text/x-patch")
.body(Body::from(diff))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn review_endpoint_surfaces_search_failure_as_502() {
let (state, _tmp) = make_state();
let app = build_router(state);
let diff = "+++ b/src/foo.rs\n@@ -0,0 +1,2 @@\n+/// doc\n+fn f() {}\n";
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/review?index_id=my-idx")
.header("content-type", "text/x-patch")
.body(Body::from(diff))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn review_endpoint_rejects_malformed_diff() {
let (state, _tmp) = make_state();
let app = build_router(state);
let diff = "+++ b/x.rs\n@@ totally bogus @@\n+fn x() {}\n";
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/review?index_id=my-idx")
.header("content-type", "text/x-patch")
.body(Body::from(diff))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn deep_endpoint_requires_index_id() {
let (state, _tmp) = make_state();
let state = state.with_api_key(Some("test-key".into()));
let app = build_router(state);
let body = serde_json::json!({ "index_id": "" }).to_string();
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/analyze/deep")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn deep_endpoint_requires_api_key() {
let (state, _tmp) = make_state();
let state = state.with_api_key(None);
let app = build_router(state);
let body = serde_json::json!({ "index_id": "my-idx" }).to_string();
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/analyze/deep")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn synthesise_review_from_chunks_groups_by_file() {
use crate::core::review::ReviewSource;
use crate::types::CodeChunk;
let chunks = vec![
CodeChunk {
id: "a:1:5".into(),
file: "src/a.rs".into(),
start_line: 1,
end_line: 5,
content: "fn a() {}".into(),
..Default::default()
},
CodeChunk {
id: "a:10:20".into(),
file: "src/a.rs".into(),
start_line: 10,
end_line: 20,
content: "fn aa() {}".into(),
..Default::default()
},
CodeChunk {
id: "b:1:3".into(),
file: "src/b.rs".into(),
start_line: 1,
end_line: 3,
content: "fn b() {}".into(),
..Default::default()
},
];
let report = synthesise_review_from_chunks(&chunks);
assert_eq!(report.files.len(), 2);
let paths: Vec<&str> = report.files.iter().map(|f| f.path.as_str()).collect();
assert!(paths.contains(&"src/a.rs"));
assert!(paths.contains(&"src/b.rs"));
for f in &report.files {
assert_eq!(f.source, ReviewSource::NewFile);
assert!(f.recommendations.is_empty());
}
}
#[test]
fn synthesise_review_from_chunks_empty_corpus_is_grade_a() {
let report = synthesise_review_from_chunks(&[]);
assert!(report.files.is_empty());
assert_eq!(report.overall_grade, crate::types::ComplexityGrade::A);
assert_eq!(report.smell_count, 0);
}
#[test]
fn lookup_frameworks_reads_stored_facts() {
use crate::core::facts::new_fact;
let (state, _tmp) = make_state();
for fw in ["React", "Next.js"] {
let f = new_fact(
"my-idx".to_string(),
"uses_framework".to_string(),
fw.to_string(),
"my-idx".to_string(),
);
state.facts.upsert(f).unwrap();
}
let mut got = lookup_frameworks(&state, "my-idx");
got.sort();
assert_eq!(got, vec!["Next.js".to_string(), "React".to_string()]);
}
#[tokio::test]
async fn webhook_ignores_non_pr_event() {
let (state, _tmp) = make_state();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/webhooks/github")
.header("X-GitHub-Event", "push")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn webhook_ignores_non_actionable_pr_action() {
let (state, _tmp) = make_state();
let app = build_router(state);
let body = serde_json::json!({ "action": "closed" }).to_string();
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/webhooks/github")
.header("X-GitHub-Event", "pull_request")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn webhook_rejects_bad_signature() {
let (state, _tmp) = make_state();
let state = state.with_webhook_secret(Some("test-secret".to_string()));
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/webhooks/github")
.header("X-GitHub-Event", "pull_request")
.header("X-Hub-Signature-256", "sha256=deadbeef")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_accepts_valid_signature() {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let (state, _tmp) = make_state();
let state = state.with_webhook_secret(Some("test-secret".to_string()));
let app = build_router(state);
let body = serde_json::json!({ "action": "closed" }).to_string();
let mut mac = Hmac::<Sha256>::new_from_slice(b"test-secret").unwrap();
mac.update(body.as_bytes());
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/webhooks/github")
.header("X-GitHub-Event", "pull_request")
.header("X-Hub-Signature-256", &sig)
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn webhook_rejects_malformed_pr_payload() {
let (state, _tmp) = make_state();
let app = build_router(state);
let body = serde_json::json!({ "action": "opened" }).to_string();
let resp = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/webhooks/github")
.header("X-GitHub-Event", "pull_request")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn list_indexes_proxies_failure_to_502() {
let (state, _tmp) = make_state();
let app = build_router(state);
let (status, _) = json_get(app, "/indexes").await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
}
}