use rs_guard::config::Config;
use rs_guard::pipeline::{run_pipeline, PipelineResult};
use serde_json::json;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn ci_config(pr_number: u64, provider: &str, api_key: &str) -> Config {
let mut c = Config::empty();
c.is_ci = true;
c.pr_number = Some(pr_number);
c.repo_owner = Some("test-owner".into());
c.repo_name = Some("test-repo".into());
c.github_token = Some(api_key.into());
c.provider = provider.into();
c.model = "test-model".into();
c.temperature = 0.1;
c.prompt = "You are a code reviewer.".into();
c.api_key = "test-llm-key".into();
c
}
fn local_config() -> Config {
let mut c = Config::empty();
c.is_ci = false;
c.provider = "deepseek".into();
c.model = "test-model".into();
c.temperature = 0.1;
c.prompt = "You are a code reviewer.".into();
c.api_key = "test-llm-key".into();
c
}
const VALID_DIFF: &str =
"diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1 +1,2 @@\n+line1\n line0";
const POSITIVE_RESPONSE: &str = "Looks good.\n\n[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nCriticalIssues: 0\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0";
const NEGATIVE_RESPONSE: &str = "Found issues.\n\n[RS_GUARD_VERDICT_METADATA]\nVerdict: NEGATIVE\nCriticalIssues: 2\nSecurityIssues: 1\nImportantIssues: 0\nSuggestions: 0";
const IMPORTANT_ISSUES_RESPONSE: &str = "Review complete.\n\n[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nCriticalIssues: 0\nSecurityIssues: 0\nImportantIssues: 2\nSuggestions: 1";
#[tokio::test]
async fn test_full_pipeline_ci_approve() {
let github = MockServer::start().await;
let llm = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(VALID_DIFF))
.mount(&github)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": POSITIVE_RESPONSE}}]
})))
.mount(&llm)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&github)
.await;
let mut config = ci_config(42, "deepseek", "test-token");
config.github_base_url = github.uri();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let result = run_pipeline(config, None).await;
assert!(matches!(result, Ok(PipelineResult::Success)));
}
#[tokio::test]
async fn test_full_pipeline_ci_request_changes() {
let github = MockServer::start().await;
let llm = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(VALID_DIFF))
.mount(&github)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": NEGATIVE_RESPONSE}}]
})))
.mount(&llm)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&github)
.await;
let mut config = ci_config(42, "deepseek", "test-token");
config.github_base_url = github.uri();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let result = run_pipeline(config, None).await;
assert!(matches!(result, Ok(PipelineResult::Success)));
}
#[tokio::test]
async fn test_full_pipeline_ci_dismisses_previous_reviews() {
let github = MockServer::start().await;
let llm = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(VALID_DIFF))
.mount(&github)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": POSITIVE_RESPONSE}}]
})))
.mount(&llm)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&github)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([{
"id": 1,
"state": "CHANGES_REQUESTED",
"body": "Previous review\n\n<!-- rs-guard-bot -->"
}])))
.mount(&github)
.await;
Mock::given(method("PUT"))
.and(path_regex(
r"/repos/test-owner/test-repo/pulls/\d+/reviews/\d+/dismissals",
))
.respond_with(ResponseTemplate::new(200))
.mount(&github)
.await;
let mut config = ci_config(42, "deepseek", "test-token");
config.github_base_url = github.uri();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let result = run_pipeline(config, None).await;
assert!(matches!(result, Ok(PipelineResult::Success)));
}
#[tokio::test]
async fn test_full_pipeline_local_approve() {
let llm = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": POSITIVE_RESPONSE}}]
})))
.mount(&llm)
.await;
let mut config = local_config();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let dir = tempfile::tempdir().unwrap();
let diff_path = dir.path().join("test.diff");
std::fs::write(&diff_path, VALID_DIFF).unwrap();
let result = run_pipeline(config, Some(diff_path.to_str().unwrap())).await;
assert!(matches!(result, Ok(PipelineResult::Success)));
}
#[tokio::test]
async fn test_full_pipeline_empty_diff() {
let github = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.mount(&github)
.await;
let mut config = ci_config(42, "deepseek", "test-token");
config.github_base_url = github.uri();
config.no_cache = true;
let result = run_pipeline(config, None).await;
assert!(matches!(result, Ok(PipelineResult::Success)));
}
#[tokio::test]
#[serial_test::serial]
async fn test_full_pipeline_cache_hit() {
let cache_dir = std::path::Path::new(".rs-guard/cache");
if cache_dir.exists() {
let _ = std::fs::remove_dir_all(cache_dir);
}
let github = MockServer::start().await;
let llm = MockServer::start().await;
let unique_diff = format!(
"diff --git a/unique{}.rs b/unique{}.rs\n+line{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
42
);
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(&unique_diff))
.mount(&github)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": POSITIVE_RESPONSE}}]
})))
.expect(1) .mount(&llm)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.expect(2) .mount(&github)
.await;
let mut config1 = ci_config(42, "deepseek", "test-token");
config1.github_base_url = github.uri();
config1.provider_config.base_url = Some(llm.uri());
let result1 = run_pipeline(config1, None).await;
assert!(matches!(result1, Ok(PipelineResult::Success)));
let mut config2 = ci_config(42, "deepseek", "test-token");
config2.github_base_url = github.uri();
config2.provider_config.base_url = Some(llm.uri());
let result2 = run_pipeline(config2, None).await;
assert!(matches!(result2, Ok(PipelineResult::Success)));
}
#[tokio::test]
async fn test_full_pipeline_chunked_diff() {
let github = MockServer::start().await;
let llm = MockServer::start().await;
let large_diff: String = (0..20)
.map(|i| {
format!(
"diff --git a/file{}.rs b/file{}.rs\n--- a/file{}.rs\n+++ b/file{}.rs\n@@ -1,1 +1,1 @@\n-old line {}\n+new line {}\n",
i, i, i, i, i, i
)
})
.collect::<Vec<_>>()
.join("\n");
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(large_diff))
.mount(&github)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": POSITIVE_RESPONSE}}]
})))
.mount(&llm)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&github)
.await;
let mut config = ci_config(42, "deepseek", "test-token");
config.github_base_url = github.uri();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let result = run_pipeline(config, None).await;
assert!(matches!(result, Ok(PipelineResult::Success)));
}
#[tokio::test]
#[serial_test::serial]
async fn test_full_pipeline_metrics_file_created() {
let github = MockServer::start().await;
let llm = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(VALID_DIFF))
.mount(&github)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": POSITIVE_RESPONSE}}]
})))
.mount(&llm)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&github)
.await;
let mut config = ci_config(42, "deepseek", "test-token");
config.github_base_url = github.uri();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let metrics_file = tempfile::NamedTempFile::new().unwrap();
let metrics_path = metrics_file.path();
std::env::set_var("RS_GUARD_METRICS_PATH", metrics_path);
let result = run_pipeline(config, None).await;
assert!(matches!(result, Ok(PipelineResult::Success)));
let content = std::fs::read_to_string(metrics_path).unwrap();
assert!(content.contains("provider"));
assert!(content.contains("estimated_tokens_in"));
assert!(content.contains("estimated_tokens_out"));
assert!(content.contains("latency_secs"));
assert!(content.contains("estimated_cost_cents"));
std::env::remove_var("RS_GUARD_METRICS_PATH");
}
#[tokio::test]
async fn test_full_pipeline_local_blocked() {
let llm = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": NEGATIVE_RESPONSE}}]
})))
.mount(&llm)
.await;
let mut config = local_config();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let dir = tempfile::tempdir().unwrap();
let diff_path = dir.path().join("test.diff");
std::fs::write(&diff_path, VALID_DIFF).unwrap();
let result = run_pipeline(config, Some(diff_path.to_str().unwrap())).await;
assert!(matches!(result, Ok(PipelineResult::ReviewBlocked)));
}
#[tokio::test]
async fn test_full_pipeline_llm_retries_exhausted() {
let github = MockServer::start().await;
let llm = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(VALID_DIFF))
.mount(&github)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.mount(&llm)
.await;
let mut config = ci_config(42, "deepseek", "test-token");
config.github_base_url = github.uri();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let result = run_pipeline(config, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_full_pipeline_ci_important_issues_yield_comment_not_blocked() {
let github = MockServer::start().await;
let llm = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_string(VALID_DIFF))
.mount(&github)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{"message": {"content": IMPORTANT_ISSUES_RESPONSE}}]
})))
.mount(&llm)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/test-owner/test-repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&github)
.await;
let mut config = ci_config(42, "deepseek", "test-token");
config.github_base_url = github.uri();
config.provider_config.base_url = Some(llm.uri());
config.no_cache = true;
let result = run_pipeline(config, None).await;
assert!(matches!(result, Ok(PipelineResult::Success)));
let requests = github.received_requests().await.unwrap_or_default();
let review_request = requests
.iter()
.find(|r| r.method == wiremock::http::Method::POST && r.url.path().ends_with("/reviews"))
.expect("expected a POST to /reviews");
let body: serde_json::Value =
serde_json::from_slice(&review_request.body).expect("review body is valid JSON");
assert_eq!(
body["event"].as_str(),
Some("COMMENT"),
"expected COMMENT event, got: {}",
body["event"]
);
}