use std::path::Path;
use std::process::Command;
use serde_json::Value;
use wiremock::MockServer;
use crate::test_support::mount_sse;
use crate::test_support::sse;
use crate::test_support::write_executable;
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")
}
async fn mount_llm(server: &MockServer, body: &str) {
mount_sse(
server,
wiremock::ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
)
.await;
}
#[test]
fn text_output_for_a_known_finding_is_exactly_the_expected_string() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let (dir, _server) = runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let server = MockServer::start().await;
mount_llm(
&server,
r#"{"issues":[{"line":1,"severity":"critical","category":"bug","message":"test message","suggestion":"fix it"}]}"#,
)
.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).into_owned();
assert_eq!(
output.status.code(),
Some(0),
"expected Clean exit, stderr was {:?}",
String::from_utf8_lossy(&output.stderr)
);
let expected = "lib.py:1: error [llm/bug] test message\n suggestion: fix it\n";
assert_eq!(
stdout, expected,
"rendered text must equal the exact expected string"
);
}
#[test]
fn clean_run_text_output_is_exactly_no_issues_found() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let (dir, _server) = runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let server = MockServer::start().await;
mount_llm(&server, 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");
(dir, server)
});
let output = run_drep(dir.path(), &["check", "lib.py"]);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert_eq!(
output.status.code(),
Some(0),
"expected Clean exit, stderr was {:?}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
stdout, "No issues found.\n",
"clean run text must be exactly 'No issues found.\\n'"
);
}
#[test]
fn json_clean_run_has_unanalyzed_key_present_as_empty_array() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let (dir, _server) = runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let server = MockServer::start().await;
mount_llm(&server, 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");
(dir, server)
});
let output = run_drep(dir.path(), &["check", "--format", "json", "lib.py"]);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert_eq!(
output.status.code(),
Some(0),
"expected Clean exit, stderr was {:?}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value = serde_json::from_str(&stdout).expect("json parses");
let obj = parsed.as_object().expect("json object");
assert!(
obj.get("unanalyzed").is_some(),
"unanalyzed must be present in clean-run JSON; got {stdout:?}"
);
assert_eq!(
obj["unanalyzed"],
Value::Array(Vec::new()),
"unanalyzed must be [] on a clean run, got {:?}",
obj["unanalyzed"]
);
}
#[test]
fn json_findings_distinguish_tool_from_llm_via_source_field() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let (dir, _server) = runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let server = MockServer::start().await;
mount_llm(
&server,
r#"{"issues":[{"line":2,"severity":"high","category":"perf","message":"llm-msg","suggestion":""}]}"#,
)
.await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
std::fs::write(dir.path().join("pyproject.toml"), "").expect("pyproject");
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\":\"lib.py\",\"location\":{\"row\":1,\"column\":1},\"message\":\"tool-msg\"}]'\n",
);
std::fs::write(dir.path().join("lib.py"), "x = 1\ny = 2\n").expect("lib.py");
(dir, server)
});
let output = run_drep(dir.path(), &["check", "--format", "json", "lib.py"]);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert_eq!(
output.status.code(),
Some(1),
"expected FoundIssues exit (tool finding blocks), stderr was {:?}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value = serde_json::from_str(&stdout).expect("json parses");
let findings = parsed["findings"]
.as_array()
.expect("findings array")
.clone();
let mut saw_tool = false;
let mut saw_llm = false;
for f in &findings {
match f["source"].as_str() {
Some("tool") => saw_tool = true,
Some("llm") => saw_llm = true,
other => panic!("unexpected source {other:?} in finding {f:?}"),
}
}
assert!(saw_tool, "must have a tool finding, got {findings:?}");
assert!(saw_llm, "must have an llm finding, got {findings:?}");
}
#[test]
fn json_exit_matches_returned_exit_code_on_a_run_with_failures() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let (dir, _server) = runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let server = MockServer::start().await;
mount_llm(&server, r#"{"issues":[]}"#).await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
std::fs::write(dir.path().join("bad.py"), [0xFFu8, 0xFE, 0xFD]).expect("bad.py");
(dir, server)
});
let output = run_drep(dir.path(), &["check", "--format", "json", "bad.py"]);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert_eq!(
output.status.code(),
Some(2),
"expected Unanalyzed exit, stderr was {:?}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value = serde_json::from_str(&stdout).expect("json parses");
assert_eq!(
parsed["exit"].as_u64(),
Some(2),
"JSON exit must match the returned Exit::Unanalyzed code"
);
}
#[test]
fn json_exit_matches_the_gate_when_an_llm_finding_does_not_block() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let (dir, _server) = runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let server = MockServer::start().await;
mount_llm(
&server,
r#"{"issues":[{"line":1,"severity":"high","category":"bug","message":"m"}],"summary":""}"#,
)
.await;
crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
std::fs::write(dir.path().join("only.py"), "x = 1\n").expect("only.py");
(dir, server)
});
let output = run_drep(dir.path(), &["check", "--format", "json", "only.py"]);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let parsed: Value = serde_json::from_str(&stdout).expect("json parses");
assert_eq!(
output.status.code(),
Some(0),
"an LLM finding must not block without --fail-on; stderr {:?}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
parsed["exit"].as_u64(),
Some(0),
"the JSON exit must be the gate's verdict, not a recomputation that \
treats any finding as blocking; got {stdout}"
);
assert!(
!parsed["findings"]
.as_array()
.expect("findings array")
.is_empty(),
"the finding must still be reported, just not blocking: {stdout}"
);
}
#[test]
fn each_suggestion_follows_its_own_finding() {
use crate::analysis::findings::{Finding, Severity};
use crate::cli::OutputFormat;
let finding = |line: u32, message: &str, suggestion: &str| Finding {
kind: "bug".to_owned(),
severity: Severity::Error,
file_path: "src/lib.rs".to_owned(),
line,
column: None,
message: message.to_owned(),
suggestion: Some(suggestion.to_owned()),
};
let outcome = super::support::outcome_with_tool_findings(vec![
finding(1, "first", "fix one"),
finding(2, "second", "fix two"),
]);
let text = super::support::rendered(&outcome, OutputFormat::Text);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines,
vec![
"src/lib.rs:1: error [tool/bug] first",
" suggestion: fix one",
"src/lib.rs:2: error [tool/bug] second",
" suggestion: fix two",
],
"each suggestion must directly follow its own finding, got:\n{text}"
);
}