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),
}
#[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>) -> Self {
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()
.expect("reqwest::Client::build failed");
let analysis_http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(180))
.build()
.expect("reqwest::Client::build failed");
Self {
base_url,
probe_http,
analysis_http,
}
}
pub fn from_config(config: &crate::config::ReviewConfig) -> Self {
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)]
mod tests {
use super::*;
#[test]
fn analyze_client_trait_object_compiles() {
fn _accepts_dyn(_c: &dyn AnalyzeClient) {}
}
#[test]
fn http_analyze_client_url_is_configurable() {
let client = HttpAnalyzeClient::new("http://127.0.0.1:7879");
assert_eq!(client.base_url(), "http://127.0.0.1:7879");
}
#[test]
fn http_analyze_client_strips_trailing_slash() {
let client = HttpAnalyzeClient::new("http://127.0.0.1:7879/");
assert_eq!(client.base_url(), "http://127.0.0.1:7879");
}
#[test]
fn http_analyze_client_from_config() {
let mut config = crate::config::ReviewConfig::load(None);
config.analyzer_url = "http://localhost:8888".to_string();
let client = HttpAnalyzeClient::from_config(&config);
assert_eq!(client.base_url(), "http://localhost:8888");
}
#[test]
fn analyze_health_response_is_healthy() {
let resp = AnalyzeHealthResponse {
status: "ok".to_string(),
search_reachable: true,
};
assert!(resp.is_healthy());
}
#[test]
fn analyze_health_response_not_ok() {
let resp = AnalyzeHealthResponse {
status: "starting".to_string(),
search_reachable: false,
};
assert!(!resp.is_healthy());
}
#[test]
fn analyze_health_search_not_reachable() {
let resp = AnalyzeHealthResponse {
status: "ok".to_string(),
search_reachable: false,
};
assert!(
!resp.is_healthy(),
"is_healthy must be false when search_reachable is false"
);
}
#[test]
fn analyze_health_response_deserialises() {
let json = r#"{"status":"ok","search_reachable":true}"#;
let resp: AnalyzeHealthResponse = serde_json::from_str(json).unwrap();
assert!(resp.is_healthy());
}
#[test]
fn analyze_index_info_deserialises() {
let json = r#"{"id":"main"}"#;
let info: AnalyzeIndexInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.id, "main");
}
#[test]
fn hotspot_deserialises() {
let json = r#"{
"file": "src/service/mod.rs",
"function_name": "handle_webhook",
"cyclomatic": 18,
"cognitive": 22
}"#;
let h: ComplexityHotspot = serde_json::from_str(json).unwrap();
assert_eq!(h.file, "src/service/mod.rs");
assert_eq!(h.function_name.as_deref(), Some("handle_webhook"));
assert_eq!(h.cyclomatic, 18);
}
#[test]
fn smell_deserialises() {
let json = r#"{"file":"src/main.rs","category":"long_method","severity":"high","line":42}"#;
let s: Smell = serde_json::from_str(json).unwrap();
assert_eq!(s.file, "src/main.rs");
assert_eq!(s.category, "long_method");
assert_eq!(s.line, Some(42));
}
#[test]
fn analyze_error_display() {
let err = AnalyzeClientError::Transport("connection refused".to_string());
assert!(err.to_string().contains("connection refused"));
let err = AnalyzeClientError::Unavailable("timeout".to_string());
assert!(err.to_string().contains("timeout"));
}
#[test]
fn two_step_probe_never_calls_quality() {
let source = include_str!("analyze_client.rs");
let in_has_analysis: Vec<&str> = {
let mut capturing = false;
let mut brace_depth: i32 = 0;
let mut lines = Vec::new();
for line in source.lines() {
let trimmed = line.trim_start();
if !capturing && trimmed.contains("async fn has_analysis") {
capturing = true;
}
if capturing {
lines.push(line);
brace_depth += line.chars().filter(|&c| c == '{').count() as i32;
brace_depth -= line.chars().filter(|&c| c == '}').count() as i32;
if brace_depth <= 0 && lines.len() > 1 {
break;
}
}
}
lines
};
let quality_url_in_body = in_has_analysis
.iter()
.filter(|l| !l.trim_start().starts_with("//"))
.any(|l| l.contains("/quality\"") || l.contains("/quality?"));
assert!(
!quality_url_in_body,
"has_analysis must NEVER construct a URL to /quality (spec REV-441, lesson §12.3)"
);
assert!(
!in_has_analysis.is_empty(),
"could not locate has_analysis fn body in analyze_client.rs — test is broken"
);
}
#[tokio::test]
async fn two_step_probe_returns_false_on_transport_error() {
let client = HttpAnalyzeClient::new("http://127.0.0.1:1");
let result = client.has_analysis("main").await;
assert!(
!result,
"has_analysis must return false on transport error, not panic"
);
}
#[tokio::test]
async fn complexity_hotspots_transport_error_propagates() {
let client = HttpAnalyzeClient::new("http://127.0.0.1:1");
let result = client.complexity_hotspots("main", Some(5)).await;
assert!(
result.is_err(),
"transport error must surface as Err from complexity_hotspots"
);
}
#[tokio::test]
async fn smells_transport_error_propagates() {
let client = HttpAnalyzeClient::new("http://127.0.0.1:1");
let result = client.smells("main").await;
assert!(
result.is_err(),
"transport error must surface as Err from smells"
);
}
}