use super::*;
use crate::{
integrations::{
analyze_client::{AnalyzeClientError, AnalyzeHealthResponse, ComplexityHotspot, Smell},
search_client::{
EmbedderState, HealthResponse, IndexInfo, SearchClientError, SearchResult,
},
},
llm::{LlmError, LlmProvider, LlmRequest, LlmResponse},
models::ReviewStatus,
};
use async_trait::async_trait;
use std::path::PathBuf;
struct FakeLlm {
response: String,
error: Option<String>,
}
impl FakeLlm {
fn approves() -> Self {
Self {
response: r#"Looks good.
```json
{"verdict":"APPROVE","summary":"LGTM","findings":[]}
```"#
.to_string(),
error: None,
}
}
fn request_changes() -> Self {
Self {
response: r#"There is a bug.
```json
{"verdict":"REQUEST_CHANGES","summary":"SQL injection","findings":[{"title":"SQL injection","body":"line 42","severity":"medium","confidence":0.9,"file":"src/a.rs","line":42}]}
```"#
.to_string(),
error: None,
}
}
fn errors(msg: impl Into<String>) -> Self {
Self {
response: String::new(),
error: Some(msg.into()),
}
}
}
#[async_trait]
impl LlmProvider for FakeLlm {
fn name(&self) -> &str {
"fake"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
if let Some(ref err) = self.error {
return Err(LlmError::Transport(err.clone()));
}
Ok(LlmResponse {
text: self.response.clone(),
model: req.model.clone(),
input_tokens: 100,
output_tokens: 50,
latency_ms: 42,
cost_usd: 0.000042,
})
}
}
struct FakeVerifier {
judgment: &'static str,
}
#[async_trait]
impl LlmProvider for FakeVerifier {
fn name(&self) -> &str {
"fake-verifier"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
Ok(LlmResponse {
text: format!(r#"{{"judgment":"{}","reason":"test"}}"#, self.judgment),
model: req.model.clone(),
input_tokens: 5,
output_tokens: 3,
latency_ms: 1,
cost_usd: 0.0,
})
}
}
struct FakeSearch;
#[async_trait]
impl SearchClient for FakeSearch {
async fn health(&self) -> Result<HealthResponse, SearchClientError> {
Ok(HealthResponse {
status: "ok".to_string(),
embedder: EmbedderState::Bool(true),
})
}
async fn list_indexes(&self) -> Result<Vec<IndexInfo>, SearchClientError> {
Ok(vec![IndexInfo {
id: "main".to_string(),
name: None,
root_path: None,
}])
}
async fn search(
&self,
_index_id: &str,
_query: &str,
_top_k: Option<u32>,
) -> Result<Vec<SearchResult>, SearchClientError> {
Ok(vec![SearchResult {
file: "src/auth.rs".to_string(),
snippet: Some("pub fn authenticate() {}".to_string()),
score: 0.9,
start_line: None,
end_line: None,
}])
}
}
struct FailingSearch;
#[async_trait]
impl SearchClient for FailingSearch {
async fn health(&self) -> Result<HealthResponse, SearchClientError> {
Err(SearchClientError::Unavailable("down".to_string()))
}
async fn list_indexes(&self) -> Result<Vec<IndexInfo>, SearchClientError> {
Err(SearchClientError::Unavailable("down".to_string()))
}
async fn search(
&self,
_: &str,
_: &str,
_: Option<u32>,
) -> Result<Vec<SearchResult>, SearchClientError> {
Err(SearchClientError::Transport("refused".to_string()))
}
}
struct FakeAnalyze;
#[async_trait]
impl AnalyzeClient for FakeAnalyze {
async fn health(&self) -> Result<AnalyzeHealthResponse, AnalyzeClientError> {
Err(AnalyzeClientError::Unavailable("not running".to_string()))
}
async fn has_analysis(&self, _: &str) -> bool {
false
}
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![])
}
}
struct ReadyAnalyze;
#[async_trait]
impl AnalyzeClient for ReadyAnalyze {
async fn health(&self) -> Result<AnalyzeHealthResponse, AnalyzeClientError> {
Ok(AnalyzeHealthResponse {
status: "ok".to_string(),
search_reachable: true,
})
}
async fn has_analysis(&self, _: &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 ready_deps(llm: Arc<dyn LlmProvider>, verifier: Option<Arc<dyn LlmProvider>>) -> ReviewDeps {
ReviewDeps {
llm,
verifier,
search: Arc::new(FakeSearch),
analyze: Some(Arc::new(ReadyAnalyze)),
dedup: None,
}
}
fn local_diff_source(diff: &str) -> (DiffSource, tempfile::NamedTempFile) {
use std::io::Write as _;
let mut tmp = tempfile::NamedTempFile::new().expect("tempfile");
tmp.write_all(diff.as_bytes()).expect("write");
let path = tmp.path().to_path_buf();
(DiffSource::LocalFile { path }, tmp)
}
fn default_config() -> ReviewConfig {
ReviewConfig::load(None)
}
#[tokio::test]
async fn run_review_with_fake_provider_approves() {
let diff = "+fn hello() { println!(\"hi\"); }\n";
let (source, _tmp) = local_diff_source(diff);
let config = default_config();
let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(Arc::new(FakeLlm::approves()), None);
let result = run_review(&config, input, deps).await;
assert_eq!(result.verdict, Verdict::Approve);
assert!(
result.error.is_none(),
"no error expected: {:?}",
result.error
);
assert_eq!(
result.status,
ReviewStatus::Completed,
"both deps healthy → authoritative Completed status"
);
assert!(result.dry_run, "MVP must always be dry-run");
assert_eq!(result.findings.len(), 0);
}
#[tokio::test]
async fn run_review_request_changes_parsed_correctly() {
let (source, _tmp) = local_diff_source(
"+fn bad_query(id: &str) { db.exec(format!(\"SELECT * FROM users WHERE id={id}\")) }\n",
);
let config = default_config();
let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(Arc::new(FakeLlm::request_changes()), None);
let result = run_review(&config, input, deps).await;
assert_eq!(result.verdict, Verdict::RequestChanges);
assert_eq!(result.findings.len(), 1);
assert_eq!(result.findings[0].kind, "SQL injection");
}
#[tokio::test]
async fn run_review_fail_safe_on_llm_error() {
let (source, _tmp) = local_diff_source("+fn x() {}\n");
let config = default_config();
let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(Arc::new(FakeLlm::errors("simulated transport error")), None);
let result = run_review(&config, input, deps).await;
assert_eq!(
result.verdict,
Verdict::Approve,
"LLM error must fall back to APPROVE"
);
assert!(
result.error.is_some(),
"error field must be set when LLM fails"
);
}
#[tokio::test]
async fn run_review_search_down_skips_when_required() {
let (source, _tmp) = local_diff_source("+fn x() {}\n");
let config = default_config(); let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ReviewDeps {
llm: Arc::new(FakeLlm::approves()), verifier: None,
search: Arc::new(FailingSearch), analyze: Some(Arc::new(ReadyAnalyze)),
dedup: None,
};
let result = run_review(&config, input, deps).await;
assert_eq!(
result.status,
ReviewStatus::Skipped,
"search down + required must SKIP, not silently APPROVE"
);
assert!(!result.posted, "a skipped review must never be posted live");
assert!(result.dry_run, "a skipped review is dry-run");
let err = result.error.expect("skip must set an actionable error");
assert!(
err.contains("trusty-search"),
"error must name the dep: {err}"
);
assert!(
err.contains("start"),
"error must be actionable (how to fix): {err}"
);
assert_ne!(
result.verdict,
Verdict::Approve,
"a skip must not masquerade as APPROVE"
);
}
#[tokio::test]
async fn run_review_analyze_down_skips_when_required() {
let (source, _tmp) = local_diff_source("+fn x() {}\n");
let config = default_config(); let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ReviewDeps {
llm: Arc::new(FakeLlm::approves()),
verifier: None,
search: Arc::new(FakeSearch), analyze: Some(Arc::new(FakeAnalyze)), dedup: None,
};
let result = run_review(&config, input, deps).await;
assert_eq!(
result.status,
ReviewStatus::Skipped,
"analyze down + required must SKIP"
);
let err = result.error.expect("skip must set an actionable error");
assert!(
err.contains("trusty-analyze"),
"error must name the dep: {err}"
);
}
#[tokio::test]
async fn run_review_search_down_degraded_when_optout() {
let (source, _tmp) = local_diff_source("+fn x() {}\n");
let mut config = default_config();
config.context.require_search = false; let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ReviewDeps {
llm: Arc::new(FakeLlm::approves()),
verifier: None,
search: Arc::new(FailingSearch), analyze: Some(Arc::new(ReadyAnalyze)),
dedup: None,
};
let result = run_review(&config, input, deps).await;
assert_eq!(
result.status,
ReviewStatus::Degraded,
"opted-out + search down must PROCEED but be tagged Degraded"
);
assert!(
!result.status.is_authoritative(),
"a degraded review must not be authoritative"
);
assert!(
result.review_body.contains("NOT AUTHORITATIVE"),
"degraded body must carry a loud banner: {:?}",
result.review_body
);
let err = result
.error
.expect("degraded run must record a non-authoritative reason");
assert!(err.contains("degraded"), "reason must say degraded: {err}");
}
#[tokio::test]
async fn run_review_both_healthy_completes_authoritative() {
let (source, _tmp) = local_diff_source("+fn x() {}\n");
let config = default_config();
let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(Arc::new(FakeLlm::approves()), None);
let result = run_review(&config, input, deps).await;
assert_eq!(result.status, ReviewStatus::Completed);
assert!(result.status.is_authoritative());
assert_eq!(result.verdict, Verdict::Approve);
assert!(
result.error.is_none(),
"healthy run sets no error: {:?}",
result.error
);
assert!(
!result.review_body.contains("NOT AUTHORITATIVE"),
"authoritative review must not carry the degraded banner"
);
}
#[tokio::test]
async fn run_review_local_diff_skips_github() {
let diff = "+fn local_fn() {}\n";
let (source, _tmp) = local_diff_source(diff);
let config = default_config();
let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-nano-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(Arc::new(FakeLlm::approves()), None);
let result = run_review(&config, input, deps).await;
assert_eq!(result.owner, "local");
assert_eq!(result.verdict, Verdict::Approve);
}
#[tokio::test]
async fn run_review_missing_diff_file_sets_error() {
let config = default_config();
let input = ReviewInput {
diff_source: DiffSource::LocalFile {
path: PathBuf::from("/nonexistent/path/nope.diff"),
},
reviewer_model: "openai/gpt-5.4-nano-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ReviewDeps {
llm: Arc::new(FakeLlm::approves()),
verifier: None,
search: Arc::new(FakeSearch),
analyze: None,
dedup: None,
};
let result = run_review(&config, input, deps).await;
assert!(
result.error.is_some(),
"missing diff file must set error field"
);
}
#[tokio::test]
async fn run_review_local_diff_is_dry_run_and_not_posted() {
let (source, _tmp) = local_diff_source("+fn x() {}\n");
let mut config = default_config();
config.dry_run = false; let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-nano-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::ForceLive, run_mode: RunMode::Serve,
allow_posting: true,
};
let deps = ready_deps(Arc::new(FakeLlm::approves()), None);
let result = run_review(&config, input, deps).await;
assert!(
result.dry_run,
"local-diff source must never post — always dry-run"
);
assert!(!result.posted, "local-diff must not be marked posted");
}
#[tokio::test]
async fn run_review_writes_dry_run_log_on_log_only_path() {
let dir = tempfile::tempdir().expect("tempdir");
let (source, _tmp) = local_diff_source("+fn x() {}\n");
let mut config = default_config();
config.log_dir = dir.path().to_path_buf();
let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-nano-20260317".to_string(),
write_log: true,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(Arc::new(FakeLlm::approves()), None);
let _result = run_review(&config, input, deps).await;
let json_count = std::fs::read_dir(dir.path())
.expect("read_dir")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|x| x == "json").unwrap_or(false))
.count();
assert_eq!(json_count, 1, "a dry-run JSON log must be written");
}
#[tokio::test]
async fn run_review_verification_refutes_and_relaxes_verdict() {
let (source, _tmp) = local_diff_source("+fn bad() {}\n");
let config = default_config();
let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(
Arc::new(FakeLlm::request_changes()), Some(Arc::new(FakeVerifier {
judgment: "REFUTED",
})),
);
let result = run_review(&config, input, deps).await;
assert_eq!(
result.verdict,
Verdict::Approve,
"refuting the sole finding must relax REQUEST_CHANGES to APPROVE"
);
assert_eq!(
result.findings.len(),
1,
"the finding is demoted, not dropped"
);
}
#[tokio::test]
async fn run_review_verification_confirms_and_preserves_verdict() {
let (source, _tmp) = local_diff_source("+fn bad() {}\n");
let config = default_config();
let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(
Arc::new(FakeLlm::request_changes()),
Some(Arc::new(FakeVerifier {
judgment: "CONFIRMED",
})),
);
let result = run_review(&config, input, deps).await;
assert_eq!(
result.verdict,
Verdict::RequestChanges,
"a confirmed finding must preserve the REQUEST_CHANGES verdict"
);
}
#[tokio::test]
async fn run_review_verification_disabled_skips_round() {
let (source, _tmp) = local_diff_source("+fn bad() {}\n");
let mut config = default_config();
config.verification.enabled = false; let input = ReviewInput {
diff_source: source,
reviewer_model: "openai/gpt-5.4-mini-20260317".to_string(),
write_log: false,
print_result: false,
trigger: TriggerDecision::None,
run_mode: RunMode::Cli,
allow_posting: false,
};
let deps = ready_deps(
Arc::new(FakeLlm::request_changes()),
Some(Arc::new(FakeVerifier {
judgment: "REFUTED",
})),
);
let result = run_review(&config, input, deps).await;
assert_eq!(
result.verdict,
Verdict::RequestChanges,
"with verification disabled the verdict must remain REQUEST_CHANGES"
);
assert!(
result.findings[0].verified.is_none(),
"disabled verification must not mark any finding"
);
}
#[tokio::test]
#[ignore = "requires a live GitHub PR + credentials"]
async fn run_review_live_post_and_dedup_skip_integration() {
}