use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::{
config::ReviewConfig,
integrations::{
analyze_client::{
AnalyzeClient, AnalyzeClientError, AnalyzeHealthResponse, ComplexityHotspot, Smell,
},
search_client::{
EmbedderState, HealthResponse as SearchHealth, IndexInfo, SearchClient,
SearchClientError, SearchResult,
},
},
llm::{LlmError, LlmProvider, LlmRequest, LlmResponse},
service::AppState,
};
use super::{ToolError, call_tool};
struct ApproveLlm;
#[async_trait]
impl LlmProvider for ApproveLlm {
fn name(&self) -> &str {
"approve-dispatch-stub"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
Ok(LlmResponse {
text: r#"Looks good.
```json
{"verdict":"APPROVE","summary":"LGTM","findings":[]}
```"#
.into(),
model: req.model.clone(),
input_tokens: 5,
output_tokens: 5,
latency_ms: 0,
cost_usd: 0.0,
finish_reason: None,
})
}
}
struct FakeSearchDispatch;
#[async_trait]
impl SearchClient for FakeSearchDispatch {
async fn health(&self) -> Result<SearchHealth, SearchClientError> {
Ok(SearchHealth {
status: "ok".into(),
embedder: EmbedderState::Bool(true),
})
}
async fn list_indexes(&self) -> Result<Vec<IndexInfo>, SearchClientError> {
Ok(vec![])
}
async fn search(
&self,
_: &str,
_: &str,
_: Option<u32>,
) -> Result<Vec<SearchResult>, SearchClientError> {
Ok(vec![])
}
}
struct ReadyAnalyzeDispatch;
#[async_trait]
impl AnalyzeClient for ReadyAnalyzeDispatch {
async fn health(&self) -> Result<AnalyzeHealthResponse, AnalyzeClientError> {
Ok(AnalyzeHealthResponse {
status: "ok".into(),
search_reachable: true,
})
}
async fn has_analysis(&self, _path: &str) -> bool {
true
}
async fn complexity_hotspots(
&self,
_: &str,
_: Option<u32>,
) -> Result<Vec<ComplexityHotspot>, AnalyzeClientError> {
Ok(vec![])
}
async fn smells(&self, _: &str) -> Result<Vec<Smell>, AnalyzeClientError> {
Ok(vec![])
}
}
fn offline_state() -> AppState {
let mut config = ReviewConfig::load(None);
config.context.require_search = false;
config.context.require_analyze = false;
AppState::new(
config,
Arc::new(ApproveLlm),
Arc::new(FakeSearchDispatch),
Some(Arc::new(ReadyAnalyzeDispatch)),
)
}
#[tokio::test]
async fn call_tool_review_diff_returns_non_empty_verdict() {
let state = offline_state();
let args = json!({
"diff": "+fn hello() { println!(\"hi\"); }\n",
"context": "test: trivial add"
});
let result = call_tool("review_diff", &args, &state)
.await
.expect("call_tool must not return ToolError for a valid review_diff call");
assert_eq!(
result["isError"],
json!(false),
"review_diff must return isError:false for a valid diff"
);
let text = result["content"][0]["text"]
.as_str()
.expect("content[0].text must be a string");
let review: Value = serde_json::from_str(text).expect("content text must be valid JSON");
let verdict = review["verdict"]
.as_str()
.expect("verdict field must be a string");
assert!(
!verdict.is_empty(),
"verdict must be non-empty, got: {verdict:?}"
);
}
#[tokio::test]
async fn call_tool_review_pr_no_token_returns_error() {
let mut config = ReviewConfig::load(None);
config.github_token = String::new();
config.github_app_id = None;
config.github_app_private_key = None;
config.context.require_search = false;
config.context.require_analyze = false;
let state = AppState::new(
config,
Arc::new(ApproveLlm),
Arc::new(FakeSearchDispatch),
None,
);
let args = json!({
"owner": "test-owner",
"repo": "test-repo",
"pr": 1
});
let result = call_tool("review_pr", &args, &state).await;
match result {
Ok(envelope) => {
assert_eq!(
envelope["isError"],
json!(true),
"review_pr with no token must return isError:true, got: {envelope}"
);
let text = envelope["content"][0]["text"]
.as_str()
.expect("content[0].text must be a string");
let lower = text.to_lowercase();
assert!(
lower.contains("auth") || lower.contains("token") || lower.contains("github"),
"error text must mention auth/token: {text:?}"
);
}
Err(ToolError::InvalidParams(msg)) => {
let lower = msg.to_lowercase();
assert!(
lower.contains("auth") || lower.contains("token") || lower.contains("github"),
"InvalidParams must mention auth/token: {msg:?}"
);
}
Err(ToolError::UnknownTool) => {
panic!("review_pr must be a known tool");
}
}
}
fn bedrock_startup_state() -> AppState {
use crate::config::Provider;
let mut config = ReviewConfig::load(None);
config.context.require_search = false;
config.context.require_analyze = false;
config.role_models.reviewer.provider = Provider::Bedrock;
config.openrouter_api_key = "dummy-openrouter-key-for-tests".to_string(); AppState::new(
config,
Arc::new(ApproveLlm), Arc::new(FakeSearchDispatch),
Some(Arc::new(ReadyAnalyzeDispatch)),
)
}
#[tokio::test]
async fn deps_from_state_openrouter_override_switches_provider() {
let state = bedrock_startup_state();
let (deps, fallback) =
super::deps_from_state(&state, "openrouter/openai/gpt-5.4-mini-20260317").await;
assert_eq!(
deps.llm.name(),
"openrouter",
"an openrouter/ override must route to the OpenRouter backend (#1233)"
);
assert!(
fallback.is_none(),
"a successful override build must NOT report a fallback (#1357)"
);
}
#[tokio::test]
async fn deps_from_state_no_override_reuses_startup_provider() {
let state = bedrock_startup_state();
let (deps, fallback) = super::deps_from_state(&state, "us.anthropic.claude-sonnet-4-6").await;
assert_ne!(
deps.llm.name(),
"openrouter",
"a bare model id must reuse the startup (Bedrock) provider, not switch"
);
assert!(
fallback.is_none(),
"the no-override path must NOT report a fallback (#1357)"
);
}
#[tokio::test]
async fn deps_from_state_build_failure_reports_fallback() {
let mut state = bedrock_startup_state();
state.config.openrouter_api_key = String::new();
let (deps, fallback) =
super::deps_from_state(&state, "openrouter/openai/gpt-5.4-mini-20260317").await;
assert_ne!(
deps.llm.name(),
"openrouter",
"a failed override build must fall back to the startup provider"
);
let reason = fallback.expect("a failed override build must report a fallback reason (#1357)");
assert!(
reason.contains("reviewer_model") && reason.contains("fell back"),
"fallback reason must be actionable: {reason}"
);
}