use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum AnalyzeClientError {
#[error("trusty-analyze transport error: {0}")]
Transport(String),
#[error("trusty-analyze API returned {status}: {body}")]
Api {
status: u16,
body: String,
},
#[error("trusty-analyze response parse error: {0}")]
Parse(String),
#[error("trusty-analyze unavailable: {0}")]
Unavailable(String),
#[error("failed to build HTTP client: {0}")]
ClientInit(String),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AnalyzeHealthResponse {
pub status: String,
#[serde(default)]
pub search_reachable: bool,
}
impl AnalyzeHealthResponse {
pub fn is_healthy(&self) -> bool {
self.status == "ok" && self.search_reachable
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AnalyzeIndexInfo {
pub id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ComplexityHotspot {
pub file: String,
#[serde(default)]
pub function_name: Option<String>,
#[serde(default)]
pub cyclomatic: u32,
#[serde(default)]
pub cognitive: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Smell {
pub file: String,
pub category: String,
#[serde(default)]
pub severity: String,
#[serde(default)]
pub line: Option<u32>,
}
#[async_trait]
pub trait AnalyzeClient: Send + Sync {
async fn health(&self) -> Result<AnalyzeHealthResponse, AnalyzeClientError>;
async fn has_analysis(&self, index_id: &str) -> bool;
async fn complexity_hotspots(
&self,
index_id: &str,
top_k: Option<u32>,
) -> Result<Vec<ComplexityHotspot>, AnalyzeClientError>;
async fn smells(&self, index_id: &str) -> Result<Vec<Smell>, AnalyzeClientError>;
}
pub struct HttpAnalyzeClient {
base_url: String,
probe_http: reqwest::Client,
analysis_http: reqwest::Client,
}
impl HttpAnalyzeClient {
pub fn new(base_url: impl Into<String>) -> Result<Self, AnalyzeClientError> {
let raw = base_url.into();
let base_url = raw.trim_end_matches('/').to_string();
let probe_http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| AnalyzeClientError::ClientInit(e.to_string()))?;
let analysis_http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(180))
.build()
.map_err(|e| AnalyzeClientError::ClientInit(e.to_string()))?;
Ok(Self {
base_url,
probe_http,
analysis_http,
})
}
pub fn from_config(config: &crate::config::ReviewConfig) -> Result<Self, AnalyzeClientError> {
Self::new(config.analyzer_url.clone())
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
#[async_trait]
impl AnalyzeClient for HttpAnalyzeClient {
async fn health(&self) -> Result<AnalyzeHealthResponse, AnalyzeClientError> {
let url = format!("{}/health", self.base_url);
let resp = self
.probe_http
.get(&url)
.send()
.await
.map_err(|e| AnalyzeClientError::Unavailable(format!("GET {url}: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| AnalyzeClientError::Transport(format!("read body of {url}: {e}")))?;
if !status.is_success() {
return Err(AnalyzeClientError::Unavailable(format!(
"GET {url} returned {status}: {body}"
)));
}
serde_json::from_str(&body)
.map_err(|e| AnalyzeClientError::Parse(format!("health response: {e}")))
}
async fn has_analysis(&self, index_id: &str) -> bool {
let health = match self.health().await {
Ok(h) => h,
Err(e) => {
tracing::debug!("trusty-analyze health check failed (optional): {e}");
return false;
}
};
if !health.is_healthy() {
tracing::debug!(
status = %health.status,
search_reachable = health.search_reachable,
"trusty-analyze health indicates not ready"
);
return false;
}
let url = format!("{}/indexes", self.base_url);
let indexes_resp = match self.probe_http.get(&url).send().await {
Ok(r) => r,
Err(e) => {
tracing::debug!("trusty-analyze GET /indexes failed (optional): {e}");
return false;
}
};
if !indexes_resp.status().is_success() {
tracing::debug!(
status = %indexes_resp.status(),
"trusty-analyze GET /indexes returned non-2xx"
);
return false;
}
let body = match indexes_resp.text().await {
Ok(b) => b,
Err(e) => {
tracing::debug!("trusty-analyze read /indexes body failed: {e}");
return false;
}
};
let indexes: Vec<AnalyzeIndexInfo> = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
tracing::debug!("trusty-analyze /indexes parse failed: {e}");
return false;
}
};
let found = indexes.iter().any(|i| i.id == index_id);
if !found {
tracing::debug!(
index_id,
"trusty-analyze has no matching index — analyze context unavailable"
);
}
found
}
async fn complexity_hotspots(
&self,
index_id: &str,
top_k: Option<u32>,
) -> Result<Vec<ComplexityHotspot>, AnalyzeClientError> {
let mut url = format!("{}/indexes/{index_id}/complexity_hotspots", self.base_url);
if let Some(k) = top_k {
url.push_str(&format!("?top_k={k}"));
}
let resp = self
.analysis_http
.get(&url)
.send()
.await
.map_err(|e| AnalyzeClientError::Transport(format!("GET {url}: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| AnalyzeClientError::Transport(format!("read body of {url}: {e}")))?;
if !status.is_success() {
return Err(AnalyzeClientError::Api {
status: status.as_u16(),
body,
});
}
serde_json::from_str(&body)
.map_err(|e| AnalyzeClientError::Parse(format!("complexity_hotspots response: {e}")))
}
async fn smells(&self, index_id: &str) -> Result<Vec<Smell>, AnalyzeClientError> {
let url = format!("{}/indexes/{index_id}/smells", self.base_url);
let resp = self
.analysis_http
.get(&url)
.send()
.await
.map_err(|e| AnalyzeClientError::Transport(format!("GET {url}: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| AnalyzeClientError::Transport(format!("read body of {url}: {e}")))?;
if !status.is_success() {
return Err(AnalyzeClientError::Api {
status: status.as_u16(),
body,
});
}
serde_json::from_str(&body)
.map_err(|e| AnalyzeClientError::Parse(format!("smells response: {e}")))
}
}
#[cfg(test)]
#[path = "analyze_client_tests.rs"]
mod tests;