use std::path::Path;
use std::process::Command;
use wiremock::MockServer;
use crate::analysis::findings::Severity;
use crate::cli::OutputFormat;
use crate::cli::check::{self, CheckArgs};
use crate::llm::cache::Cache;
use crate::test_support::mount_sse;
use crate::test_support::sse;
use crate::test_support::write_executable;
async fn setup_mock(body: &str) -> (tempfile::TempDir, MockServer) {
let dir = tempfile::tempdir().expect("tempdir");
let server = MockServer::start().await;
mount_sse(
&server,
wiremock::ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
(dir, server)
}
fn args(paths: Vec<std::path::PathBuf>, fail_on: Option<Severity>) -> CheckArgs {
CheckArgs {
paths,
staged: false,
diff: None,
tip: None,
format: OutputFormat::Text,
fail_on,
}
}
async fn run_paths_with(args: &CheckArgs, dir: &Path) -> check::Exit {
let cache = Cache::new(dir.join("test-cache"), 30, 8 * 1024 * 1024);
check::run_with(args, dir, cache)
.await
.expect("check::run succeeds")
}
async fn run_paths(path: std::path::PathBuf, dir: &Path) -> check::Exit {
run_paths_with(&args(vec![path], None), dir).await
}
fn run_drep(dir: &Path, args: &[&str]) -> std::process::Output {
let bin = assert_cmd::cargo::cargo_bin("drep");
Command::new(bin)
.args(args)
.current_dir(dir)
.env("HOME", dir)
.env("XDG_CACHE_HOME", dir)
.output()
.expect("drep spawns")
}
#[tokio::test]
async fn clean_run_returns_clean() {
let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");
let exit = run_paths(dir.path().join("lib.py"), dir.path()).await;
assert_eq!(exit, check::Exit::Clean, "clean run must return Clean");
assert_eq!(exit.code(), 0, "Clean must map to exit 0");
}
#[tokio::test]
async fn a_completed_run_enforces_the_cache_size_ceiling() {
let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
let source = dir.path().join("lib.py");
std::fs::write(&source, "x = 1\n").expect("lib.py");
let cache_root = dir.path().join("tiny-cache");
let cache = Cache::new(cache_root.clone(), 30, 1);
let exit = check::run_with(&args(vec![source], None), dir.path(), cache)
.await
.expect("check succeeds");
assert_eq!(exit, check::Exit::Clean);
let bytes = crate::test_support::two_level_tree_size(&cache_root);
assert!(bytes <= 1, "cache retained {bytes} bytes past its ceiling");
}
#[tokio::test]
async fn tool_finding_blocks_with_no_fail_on() {
let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
let bin = dir.path().join("venv/bin/ruff");
std::fs::create_dir_all(bin.parent().unwrap()).expect("bin dir");
write_executable(
&bin,
"#!/bin/sh\nprintf '%s' '[{\"code\":\"F401\",\"filename\":\"src/lib.py\",\"location\":{\"row\":1,\"column\":1},\"message\":\"unused import\"}]'\n",
);
std::fs::write(dir.path().join("pyproject.toml"), "").expect("pyproject");
std::fs::create_dir_all(dir.path().join("src")).expect("src dir");
std::fs::write(dir.path().join("src/lib.py"), "import os\n").expect("lib.py");
let exit = run_paths(dir.path().join("src/lib.py"), dir.path()).await;
assert_eq!(
exit,
check::Exit::FoundIssues,
"a tool finding with no --fail-on must return FoundIssues"
);
assert_eq!(exit.code(), 1, "FoundIssues must map to exit 1");
}
#[test]
fn llm_finding_does_not_block_without_fail_on_but_renders() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("runtime");
let (dir, _server) = runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let server = MockServer::start().await;
let body = r#"{"issues":[{"line":1,"severity":"critical","category":"bug","message":"something smells"}]}"#;
mount_sse(
&server,
wiremock::ResponseTemplate::new(200)
.set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");
(dir, server)
});
let output = run_drep(dir.path(), &["check", "lib.py"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
output.status.code(),
Some(check::Exit::Clean.code() as i32),
"no --fail-on means LLM findings inform, not block; stderr was {:?}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
stdout.contains("something smells"),
"the LLM finding must appear in rendered output, got {stdout:?}"
);
}
#[tokio::test]
async fn llm_finding_at_error_blocks_under_fail_on_error() {
let (dir, server) = setup_mock(
r#"{"issues":[{"line":1,"severity":"critical","category":"bug","message":"bad"}]}"#,
)
.await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");
let args = CheckArgs {
paths: vec![dir.path().join("lib.py")],
staged: false,
diff: None,
tip: None,
format: OutputFormat::Text,
fail_on: Some(Severity::Error),
};
let exit = run_paths_with(&args, dir.path()).await;
assert_eq!(
exit,
check::Exit::FoundIssues,
"an error-level LLM finding under --fail-on error must block"
);
}
#[tokio::test]
async fn llm_finding_at_warning_does_not_block_under_fail_on_error() {
let (dir, server) = setup_mock(
r#"{"issues":[{"line":1,"severity":"medium","category":"style","message":"meh"}]}"#,
)
.await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");
let args = CheckArgs {
paths: vec![dir.path().join("lib.py")],
staged: false,
diff: None,
tip: None,
format: OutputFormat::Text,
fail_on: Some(Severity::Error),
};
let exit = run_paths_with(&args, dir.path()).await;
assert_eq!(
exit,
check::Exit::Clean,
"warning under --fail-on error must not block"
);
}
#[tokio::test]
async fn failure_outranks_finding() {
let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
let bin = dir.path().join("venv/bin/ruff");
std::fs::create_dir_all(bin.parent().unwrap()).expect("bin dir");
write_executable(
&bin,
"#!/bin/sh\nprintf '%s' '[{\"code\":\"F401\",\"filename\":\"src/lib.py\",\"location\":{\"row\":1,\"column\":1},\"message\":\"unused import\"}]'\n",
);
std::fs::write(dir.path().join("pyproject.toml"), "").expect("pyproject");
std::fs::create_dir_all(dir.path().join("src")).expect("src dir");
std::fs::write(dir.path().join("src/lib.py"), "import os\n").expect("lib.py");
std::fs::write(dir.path().join("src/bad.py"), [0xFFu8, 0xFE]).expect("bad.py");
let args = CheckArgs {
paths: vec![dir.path().join("src/lib.py"), dir.path().join("src/bad.py")],
staged: false,
diff: None,
tip: None,
format: OutputFormat::Text,
fail_on: None,
};
let exit = run_paths_with(&args, dir.path()).await;
assert_eq!(
exit,
check::Exit::Unanalyzed,
"a run with a blocking tool finding AND an unanalyzed file must \
exit 2, not 1"
);
assert_eq!(exit.code(), 2, "Unanalyzed must map to exit 2");
}
#[test]
fn unreachable_endpoint_exits_2_and_does_not_print_clean() {
let dir = tempfile::tempdir().expect("tempdir");
crate::test_support::write_drep_toml(dir.path(), "http://127.0.0.1:1/v1");
std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");
let output = run_drep(dir.path(), &["check", "lib.py"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
output.status.code(),
Some(check::Exit::Unanalyzed.code() as i32),
"an unreachable endpoint must exit 2, stderr was {:?}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!stdout.contains("No issues found."),
"an unanalyzed run must not print 'No issues found.', got {stdout:?}"
);
}