use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::analysis::code_quality::CodeQualityAnalyzer;
use crate::analysis::prompt::build_analysis_prompt;
use crate::languages::definitions::PYTHON;
use crate::llm::chain::ProviderChain;
use super::support::analyzer_with_fast_retry;
use super::support::{analyzer_for, python_hunk};
use crate::test_support::{cfg_for, request_count, sse, temp_cache};
#[tokio::test]
async fn analyze_files_merges_findings_and_unions_failures() {
let server = MockServer::start().await;
for (file, line, failing) in [
("a.py", 100, true),
("b.py", 200, true),
("c.py", 300, false),
] {
let bad = if failing {
format!(
", {{\"line\": {line}, \"severity\": \"blocker\", \"category\": \"bug\", \"message\": \"d\"}}"
)
} else {
String::new()
};
let body = format!(
"{{\"issues\": [{{\"line\": {line}, \"severity\": \"high\", \"category\": \"bug\", \"message\": \"{file}\"}}{bad}], \"summary\": \"\"}}"
);
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(body_string_contains(format!("src/{file}")))
.respond_with(
ResponseTemplate::new(200).set_body_raw(sse(&[&body]), "text/event-stream"),
)
.mount(&server)
.await;
}
let (analyzer, _dir) = analyzer_for(&server);
let by_file = vec![
vec![python_hunk("src/a.py", 100)],
vec![python_hunk("src/b.py", 200)],
vec![python_hunk("src/c.py", 300)],
];
let result = analyzer.analyze_files(&by_file).await;
let mut messages: Vec<&str> = result.findings.iter().map(|f| f.message.as_str()).collect();
messages.sort_unstable();
assert_eq!(
messages,
vec!["a.py", "b.py", "c.py"],
"every file's finding must reach the merged result"
);
let mut failed: Vec<PathBuf> = result.failed_files.keys().cloned().collect();
failed.sort();
assert_eq!(
failed,
vec![PathBuf::from("src/a.py"), PathBuf::from("src/b.py")],
"exactly the two failing files, by identity - a blanket insert would \
also name src/c.py"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn analyze_file_holds_a_limiter_slot_for_the_llm_call() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(
sse(&["{\"issues\": [], \"summary\": \"ok\"}"]),
"text/event-stream",
)
.set_delay(std::time::Duration::from_millis(40)),
)
.mount(&server)
.await;
let (cache, _dir) = temp_cache();
let mut cfg = cfg_for(&server, "m", 1);
cfg.max_concurrent = 1;
let analyzer = analyzer_with_fast_retry(&cfg, cache);
let limiter = analyzer.chain().providers()[0].limiter().clone();
let by_file = vec![
vec![python_hunk("src/a.py", 100)],
vec![python_hunk("src/b.py", 200)],
vec![python_hunk("src/c.py", 300)],
vec![python_hunk("src/d.py", 400)],
];
assert_eq!(limiter.available(), 1, "one permit before the run starts");
let done = Arc::new(AtomicBool::new(false));
let done_for_sampler = Arc::clone(&done);
let analysis = async {
let result = analyzer.analyze_files(&by_file).await;
done.store(true, Ordering::SeqCst);
result
};
let sampler = async {
let mut lowest = usize::MAX;
while !done_for_sampler.load(Ordering::SeqCst) {
lowest = lowest.min(limiter.available());
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
lowest
};
let (result, lowest_available) = tokio::join!(analysis, sampler);
assert!(result.findings.is_empty());
assert!(result.failed_files.is_empty());
assert_eq!(
lowest_available, 0,
"the sole permit must be held while a request is in flight; the lowest \
observed count was {lowest_available}, so the LLM call is not going \
through the limiter"
);
assert_eq!(
limiter.available(),
1,
"every permit must be returned once the run finishes"
);
}
#[tokio::test]
async fn cache_hit_does_not_acquire_a_limiter_slot() {
let server = MockServer::start().await;
let body = "{\"issues\": [], \"summary\": \"ok\"}";
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"))
.expect(1)
.mount(&server)
.await;
let (cache, _dir) = temp_cache();
let mut cfg = cfg_for(&server, "m", 1);
cfg.max_concurrent = 1;
let chain = ProviderChain::new(&[&cfg]).expect("chain builds");
let analyzer = CodeQualityAnalyzer::new(chain, cache);
let hunks = vec![python_hunk("src/lib.py", 100)];
let first = analyzer.analyze_file(&hunks).await;
assert!(first.failed_files.is_empty(), "first call should succeed");
let second = analyzer.analyze_file(&hunks).await;
assert!(second.failed_files.is_empty(), "cache hit should succeed");
assert_eq!(
first.findings, second.findings,
"a cache hit must return what the live call returned"
);
assert_eq!(
request_count(&server).await,
1,
"the second call must be served from the cache; only one HTTP request should occur"
);
let limiter = analyzer.chain().providers()[0].limiter().clone();
let held = limiter.acquire().await;
assert_eq!(limiter.available(), 0, "the test holds the only permit");
let third = tokio::time::timeout(
std::time::Duration::from_secs(5),
analyzer.analyze_file(&hunks),
)
.await
.expect("a cache hit must not wait on a limiter permit");
drop(held);
assert!(third.failed_files.is_empty());
assert_eq!(
third.findings, first.findings,
"the cached result must match the live one"
);
assert_eq!(
request_count(&server).await,
1,
"still only one HTTP request after three calls"
);
}
#[test]
fn analyzer_uses_build_analysis_prompt_for_the_system_message() {
let prompt = build_analysis_prompt(&PYTHON);
assert!(prompt.contains("expert Python"));
assert!(prompt.contains("specific concerns"));
}