use std::path::{Path, PathBuf};
use std::process::Command;
fn git_init(dir: &Path) {
assert!(Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.expect("git init")
.status
.success());
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.expect("git config name");
}
fn git_add(dir: &Path, path: &Path) {
assert!(Command::new("git")
.arg("add")
.arg(path)
.current_dir(dir)
.output()
.expect("git add")
.status
.success());
}
fn git_commit(dir: &Path, message: &str) {
assert!(Command::new("git")
.args(["commit", "-m", message])
.current_dir(dir)
.output()
.expect("git commit")
.status
.success());
}
fn pretender_bin() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../target/debug/pretender")
}
fn neutral_git_config_global() -> &'static std::path::Path {
static NEUTRAL: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
NEUTRAL
.get_or_init(|| {
let p = std::env::temp_dir().join(format!(
"pretender-test-empty-gitconfig-{}",
std::process::id()
));
std::fs::write(&p, "").expect("write neutral gitconfig");
p
})
.as_path()
}
fn pretender_cmd() -> Command {
let mut cmd = Command::new(pretender_bin());
cmd.env("GIT_CONFIG_GLOBAL", neutral_git_config_global());
cmd
}
#[test]
fn test_meter_gate_hook_stops_commit() {
let meter = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../scripts/meter-gate-hook.sh");
let output = Command::new("sh")
.arg(meter)
.env("PRETENDER_BIN", pretender_bin())
.env_remove("GIT_CONFIG_GLOBAL")
.output()
.expect("run meter script");
assert!(
output.status.success(),
"meter failed; stdout+stderr: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn source_fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/fixtures")
.join(name)
}
fn stage_fixture(name: &str) -> (PathBuf, PathBuf) {
let dir = tempdir();
let dest = dir.join(name);
std::fs::copy(source_fixture(name), &dest).expect("copy fixture");
(dir, dest)
}
fn write_temp_file(relative: &str, source: &str) -> (PathBuf, PathBuf) {
let dir = tempdir();
let dest = dir.join(relative);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(&dest, source).expect("write temp source");
(dir, dest)
}
fn tempdir() -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("pretender-cli-{pid}-{nanos}"));
std::fs::create_dir_all(&dir).expect("create tempdir");
dir
}
fn check(path: &Path) -> Command {
let mut cmd = pretender_cmd();
cmd.arg("check").arg(path).env("NO_COLOR", "1");
cmd
}
fn check_default(dir: &Path) -> Command {
let mut cmd = pretender_cmd();
cmd.arg("check").env("NO_COLOR", "1");
cmd.current_dir(dir);
cmd
}
fn check_in(dir: &Path, path: &Path) -> Command {
let mut cmd = check(path);
cmd.current_dir(dir);
cmd
}
fn report_in(dir: &Path) -> Command {
let mut cmd = pretender_cmd();
cmd.arg("report").current_dir(dir).env("NO_COLOR", "1");
cmd
}
fn ci_generate_in(dir: &Path, provider: &str) -> Command {
let mut cmd = pretender_cmd();
cmd.arg("ci")
.arg("generate")
.arg(provider)
.current_dir(dir)
.env("NO_COLOR", "1");
cmd
}
fn hooks_in(dir: &Path, action: &str) -> Command {
let mut cmd = pretender_cmd();
cmd.arg("hooks")
.arg(action)
.current_dir(dir)
.env("NO_COLOR", "1");
cmd
}
fn hooks_in_named(dir: &Path, action: &str, hook: &str) -> Command {
let mut cmd = pretender_cmd();
cmd.arg("hooks")
.arg(action)
.arg(hook)
.current_dir(dir)
.env("NO_COLOR", "1");
cmd
}
fn init_in(dir: &Path) -> Command {
let mut cmd = pretender_cmd();
cmd.arg("init").current_dir(dir).env("NO_COLOR", "1");
cmd
}
#[test]
fn test_complexity_command() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("python_simple.py"))
.output()
.expect("failed to execute process");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("complex_func: 6") || !stdout.contains("simple: 1") {
panic!(
"Output did not contain expected results.\nSTDOUT:\n{}\nSTDERR:\n{}",
stdout,
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn test_complexity_multi_path() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("python_simple.py"))
.arg(source_fixture("rust_simple.rs"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("python_simple.py:"),
"should show file header for python file; stdout: {stdout}"
);
assert!(
stdout.contains("rust_simple.rs:"),
"should show file header for rust file; stdout: {stdout}"
);
}
#[test]
fn test_complexity_directory_path() {
let dir = source_fixture("");
let output = pretender_cmd()
.arg("complexity")
.arg(&dir)
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"complexity should succeed on a directory; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"should have analyzed fixture files; stdout: {stdout}"
);
}
#[test]
fn test_complexity_directory_with_non_source_files() {
let (dir, _src) = write_temp_file("src_simple.py", "def foo():\n pass\n");
std::fs::write(dir.join(".gitignore"), "target/\n").expect("write gitignore");
std::fs::write(dir.join("README.md"), "# docs\n").expect("write readme");
std::fs::create_dir_all(dir.join("sub")).expect("mkdir");
std::fs::write(dir.join("sub/note.txt"), "hi\n").expect("write note");
let output = pretender_cmd()
.arg("complexity")
.arg(&dir)
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"complexity should skip non-source files in a directory walk; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("foo"),
"should have analyzed the python source; stdout: {stdout}"
);
}
#[test]
fn test_complexity_shows_threshold_marker_on_violation() {
let (dir, path) = write_temp_file(
"high_complexity.py",
r#"
def very_complex(x):
if x == 1: return 1
if x == 2: return 2
if x == 3: return 3
if x == 4: return 4
if x == 5: return 5
if x == 6: return 6
if x == 7: return 7
if x == 8: return 8
if x == 9: return 9
if x == 10: return 10
return 0
"#,
);
let _ = dir;
let output = pretender_cmd()
.arg("complexity")
.arg(&path)
.output()
.expect("failed to execute process");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("✗"),
"should mark function exceeding threshold with ✗; stdout: {stdout}"
);
assert!(
stdout.contains("threshold: 10"),
"should show threshold value; stdout: {stdout}"
);
}
#[test]
fn test_check_defaults_to_current_dir() {
let dir = tempdir();
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/fixtures")
.join("python_simple.py");
let dest = dir.join("python_simple.py");
std::fs::copy(&src, &dest).expect("copy fixture");
let output = check_default(&dir)
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("python_simple.py"), "stdout: {stdout}");
}
#[test]
fn test_check_command_human_output() {
let (_dir, staged) = stage_fixture("python_simple.py");
let output = check(&staged).output().expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("python_simple.py"), "stdout: {stdout}");
assert!(
!stdout.contains("cyclomatic="),
"default should not show per-function metrics for clean files; stdout: {stdout}"
);
}
#[test]
fn test_check_command_json_output() {
let (_dir, staged) = stage_fixture("python_simple.py");
let output = check(&staged)
.arg("--format")
.arg("json")
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid json");
assert_eq!(json["data"]["files"].as_array().map(Vec::len), Some(1));
assert_eq!(
json["data"]["files"][0]["path"].as_str(),
Some(staged.to_string_lossy().as_ref())
);
assert_eq!(
json["data"]["files"][0]["units"][0]["name"].as_str(),
Some("simple")
);
assert_eq!(
json["data"]["files"][0]["units"][2]["metrics"]["cyclomatic"].as_u64(),
Some(6)
);
}
#[test]
fn test_check_json_output_has_envelope_shape() {
let (_dir, staged) = stage_fixture("python_simple.py");
let output = check(&staged)
.arg("--format")
.arg("json")
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid json");
assert!(json.get("ok").is_some(), "envelope must have 'ok' field");
assert_eq!(json["ok"], true, "successful check should have ok=true");
assert_eq!(
json["envelope_version"], "0.1",
"envelope_version should be 0.1"
);
assert_eq!(
json["envelope_kind"], "check",
"envelope_kind should be 'check'"
);
assert!(
json.get("cli_version").is_some(),
"envelope must have 'cli_version'"
);
assert!(
json.get("data").is_some(),
"envelope must have 'data' field"
);
assert!(
json.get("warnings").is_some(),
"envelope must have 'warnings' field"
);
assert!(
json.get("hints").is_some(),
"envelope must have 'hints' field"
);
assert!(
json.get("meta").is_some(),
"envelope must have 'meta' field"
);
assert_eq!(
json["data"]["files"].as_array().map(Vec::len),
Some(1),
"data.files should have 1 file"
);
}
#[test]
fn test_doctor_json_output_has_envelope_shape() {
let dir = tempdir();
git_init(&dir);
let output = doctor_in(&dir, &["--json"]).output().expect("run doctor");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("output should be valid JSON");
assert!(json.get("ok").is_some(), "envelope must have 'ok' field");
assert_eq!(json["ok"], true, "doctor ran successfully, ok=true");
assert_eq!(
json["envelope_version"], "0.1",
"envelope_version should be 0.1"
);
assert_eq!(
json["envelope_kind"], "doctor",
"envelope_kind should be 'doctor'"
);
assert!(
json.get("data").is_some(),
"envelope must have 'data' field"
);
let checks = json["data"]["checks"]
.as_array()
.expect("data.checks should be array");
let config_present = checks
.iter()
.find(|c| c["name"] == "Config present")
.expect("Config present check missing");
assert_eq!(
config_present["status"], "fail",
"Config present should have status=fail"
);
}
#[test]
fn test_check_warns_when_no_config() {
let dir = tempdir();
let source = dir.join("hello.py");
std::fs::write(&source, "def hello():\n pass\n").expect("write source");
let output = check_in(&dir, &source)
.output()
.expect("failed to execute process");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("no pretender.toml found"),
"expected warning about missing config; stderr: {stderr}"
);
assert!(
stderr.contains("pretender init"),
"expected suggestion to run pretender init; stderr: {stderr}"
);
}
#[test]
fn test_check_no_warning_when_config_present() {
let dir = tempdir();
let source = dir.join("hello.py");
std::fs::write(&source, "def hello():\n pass\n").expect("write source");
std::fs::write(dir.join("pretender.toml"), "[pretender]\n").expect("write config");
let output = check_in(&dir, &source)
.output()
.expect("failed to execute process");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("no pretender.toml found"),
"unexpected config warning when toml is present; stderr: {stderr}"
);
}
#[test]
fn test_check_exits_zero_when_clean() {
let (_dir, staged) = stage_fixture("python_simple.py");
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(
output.status.code(),
Some(0),
"expected exit 0 on clean fixture; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_tiered_mode_exits_zero_on_violation() {
let (_dir, staged) = stage_fixture("python_violator.py");
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(
output.status.code(),
Some(0),
"expected exit 0 in default tiered mode; stdout: {} stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_tiered_mode_shows_advisory_not_violation() {
let (_dir, staged) = stage_fixture("python_violator.py");
let output = check(&staged)
.arg("--mode")
.arg("tiered")
.output()
.expect("failed to execute process");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("ADVISORY"),
"tiered mode should show ADVISORY; stdout: {stdout}"
);
assert!(
!stdout.contains("VIOLATION"),
"tiered mode should not show VIOLATION; stdout: {stdout}"
);
assert!(
!stdout.contains("✗"),
"tiered mode should not show ✗; stdout: {stdout}"
);
}
#[test]
fn test_check_gate_mode_shows_violation() {
let (_dir, staged) = stage_fixture("python_violator.py");
let output = check(&staged)
.arg("--mode")
.arg("gate")
.output()
.expect("failed to execute process");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("VIOLATION"),
"gate mode should show VIOLATION; stdout: {stdout}"
);
}
#[test]
fn test_check_output_flag_writes_to_file() {
let (_dir, staged) = stage_fixture("python_simple.py");
let out_path = staged.with_file_name("report.json");
let output = check(&staged)
.arg("--format")
.arg("json")
.arg("--output")
.arg(&out_path)
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"stdout should be empty when --output is set; got: {}",
String::from_utf8_lossy(&output.stdout)
);
let written = std::fs::read_to_string(&out_path).expect("report file should exist");
let json: serde_json::Value =
serde_json::from_str(&written).expect("report file should be valid json");
assert_eq!(json["data"]["files"].as_array().map(Vec::len), Some(1));
}
#[test]
fn test_check_human_output_to_file_is_uncolored() {
let (_dir, staged) = stage_fixture("python_simple.py");
let out_path = staged.with_file_name("report.txt");
let output = check(&staged)
.arg("--output")
.arg(&out_path)
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let written = std::fs::read_to_string(&out_path).expect("report file should exist");
assert!(
!written.contains('\u{1b}'),
"report written to file must not contain ANSI escape sequences: {written:?}"
);
}
#[test]
fn test_check_human_output_surfaces_violations() {
let (_dir, staged) = stage_fixture("python_violator.py");
let output = check(&staged).output().expect("failed to execute process");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("⚠"), "stdout: {stdout}");
assert!(stdout.contains("python_violator.py"), "stdout: {stdout}");
assert!(
stdout.contains("ADVISORY"),
"tiered mode should mark violations as advisory; got: {stdout}"
);
assert!(
stdout.contains("params"),
"human output should name the violated metric; got: {stdout}"
);
assert!(
!stdout.contains('\u{1b}'),
"NO_COLOR should suppress ANSI codes; got: {stdout:?}"
);
}
#[test]
fn test_check_human_output_reports_cognitive_violations() {
let (_dir, staged) = stage_fixture("python_cognitive.py");
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("deeply_nested"), "stdout: {stdout}");
assert!(stdout.contains("cognitive"), "stdout: {stdout}");
}
#[test]
fn test_check_reports_min_assertions_for_test_role() {
let (_dir, staged) = write_temp_file(
"tests/test_no_assertions.py",
"def test_missing_assertion():\n helper()\n",
);
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("test_missing_assertion"),
"stdout: {stdout}"
);
assert!(stdout.contains("min_assertions"), "stdout: {stdout}");
}
#[test]
fn test_check_exempts_fixture_unit_from_min_assertions() {
let (_dir, staged) = write_temp_file(
"tests/test_fixture_sample.py",
"def simple():\n return 1\n\ndef with_branch(x):\n if x:\n return 1\n return 0\n",
);
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("min_assertions"),
"fixture units must be exempt from min_assertions; stdout: {stdout}"
);
}
#[test]
fn test_check_exempts_helper_unit_from_min_assertions() {
let (_dir, staged) = write_temp_file(
"tests/test_helpers_sample.py",
"def tempdir():\n return 1\n",
);
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("min_assertions"),
"helper units must be exempt from min_assertions; stdout: {stdout}"
);
}
#[test]
fn test_check_rust_test_attribute_keeps_min_assertions() {
let (_dir, staged) = write_temp_file(
"tests/sample_test.rs",
"#[test]\nfn adds() {\n let a = 2;\n}\n",
);
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("min_assertions"),
"#[test] fn must stay subject to min_assertions; stdout: {stdout}"
);
}
#[test]
fn test_check_exempts_unattributed_rust_unit_from_min_assertions() {
let (_dir, staged) = write_temp_file(
"tests/sample_probe_test.rs",
"fn probe_helper() {\n let a = 2;\n}\n",
);
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("min_assertions"),
"unattributed units must be exempt from min_assertions; stdout: {stdout}"
);
}
#[test]
fn test_check_accepts_test_role_when_assertion_present() {
let (_dir, staged) = write_temp_file(
"tests/test_has_assertion.py",
"def test_has_assertion():\n assert True\n",
);
let output = check(&staged).output().expect("failed to execute process");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.contains("min_assertions"), "stdout: {stdout}");
}
#[test]
fn test_check_guidance_mode_exits_zero_on_violation() {
let (_dir, staged) = stage_fixture("python_violator.py");
let output = check(&staged)
.arg("--mode")
.arg("guidance")
.output()
.expect("failed to execute process");
assert_eq!(
output.status.code(),
Some(0),
"guidance mode must exit 0 even when violations exist; stdout: {}",
String::from_utf8_lossy(&output.stdout),
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("ADVISORY"),
"guidance mode should surface violations as advisories; got: {stdout}",
);
}
#[test]
fn test_check_gate_mode_from_config_fails_on_violation() {
let dir = tempdir();
let staged = dir.join("python_violator.py");
std::fs::copy(source_fixture("python_violator.py"), &staged).expect("copy fixture");
std::fs::write(dir.join("pretender.toml"), "[pretender]\nmode = \"gate\"\n")
.expect("write config");
let output = check_in(&dir, &staged)
.output()
.expect("failed to execute process");
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn test_check_tiered_human_output_highlights_yellow_band() {
let dir = tempdir();
let staged = dir.join("yellow.py");
std::fs::write(
dir.join("pretender.toml"),
"[thresholds]\ncyclomatic_max = 99\ncognitive_max = 99\n",
)
.expect("write config");
std::fs::write(
&staged,
"def yellow_band(value):\n if value > 0:\n pass\n if value > 1:\n pass\n if value > 2:\n pass\n if value > 3:\n pass\n if value > 4:\n pass\n if value > 5:\n pass\n if value > 6:\n pass\n if value > 7:\n pass\n if value > 8:\n pass\n if value > 9:\n pass\n if value > 10:\n pass\n return value\n",
)
.expect("write source");
let output = check_in(&dir, &staged)
.output()
.expect("failed to execute process");
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("⚠"), "stdout: {stdout}");
assert!(
stdout.contains("yellow_band(): cyclomatic 12 (yellow)"),
"stdout: {stdout}"
);
}
#[test]
fn test_check_gate_mode_fails_on_violation() {
let (_dir, staged) = stage_fixture("python_violator.py");
let output = check(&staged)
.arg("--mode")
.arg("gate")
.output()
.expect("failed to execute process");
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn test_check_sarif_output_structure() {
let (_dir, staged) = stage_fixture("python_violator.py");
let output = check(&staged)
.arg("--format")
.arg("sarif")
.output()
.expect("failed to execute process");
assert!(
output.status.success() || output.status.code() == Some(1),
"sarif format should not exit with error 2; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let sarif: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid SARIF JSON");
assert_eq!(
sarif["version"].as_str(),
Some("2.1.0"),
"version must be 2.1.0"
);
assert!(sarif["$schema"].as_str().is_some(), "must have $schema");
let runs = sarif["runs"].as_array().expect("must have runs array");
assert_eq!(runs.len(), 1, "must have exactly one run");
let run = &runs[0];
assert_eq!(
run["tool"]["driver"]["name"].as_str(),
Some("pretender"),
"tool.driver.name must be 'pretender'"
);
let rules = run["tool"]["driver"]["rules"]
.as_array()
.expect("must have rules array");
assert!(
!rules.is_empty(),
"rules must not be empty when violations exist"
);
for rule in rules {
assert!(rule["id"].as_str().is_some(), "each rule must have an id");
}
let results = run["results"].as_array().expect("must have results array");
assert!(
!results.is_empty(),
"results must not be empty for a file with violations"
);
for result in results {
assert!(
result["ruleId"].as_str().is_some(),
"each result must have ruleId"
);
assert!(
result["message"]["text"].as_str().is_some(),
"each result must have message.text"
);
let locations = result["locations"]
.as_array()
.expect("each result must have locations");
assert!(
!locations.is_empty(),
"each result must have at least one location"
);
let phys = &locations[0]["physicalLocation"];
assert!(
phys["artifactLocation"]["uri"].as_str().is_some(),
"must have uri"
);
assert!(
phys["region"]["startLine"].as_i64().is_some(),
"must have startLine"
);
}
}
#[test]
fn test_mutation_dry_run_python() {
let output = pretender_cmd()
.args([
"mutation",
"--dry-run",
source_fixture("python_simple.py").to_str().unwrap(),
])
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"dry-run should exit 0; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("mutmut"),
"dry-run should name the tool; got: {stdout}"
);
assert!(
stdout.contains("Planned mutants") || stdout.contains("No mutation sites"),
"dry-run should list planned mutants; got: {stdout}"
);
}
#[test]
fn test_mutation_dry_run_directory() {
let dir = source_fixture("");
let output = pretender_cmd()
.args(["mutation", "--dry-run", dir.to_str().unwrap()])
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"mutation --dry-run should succeed on a directory; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_stub_subcommands_exit_two() {
let cmd = vec!["plugins", "list"];
let output = pretender_cmd()
.args(&cmd)
.output()
.expect("failed to execute process");
assert_eq!(
output.status.code(),
Some(2),
"{cmd:?} should exit 2 (not yet implemented); stderr: {}",
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not yet implemented"),
"{cmd:?} stderr should explain status; got: {stderr}",
);
}
#[test]
fn test_typo_suggestion_for_misspelled_command() {
let output = pretender_cmd()
.args(["complxity"])
.output()
.expect("failed to execute process");
assert!(!output.status.success(), "typo should exit non-zero");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Did you mean") && stderr.contains("complexity"),
"stderr should contain 'Did you mean complexity?'; got: {stderr}"
);
}
#[test]
fn test_feedback_dry_run_prints_body_and_gh_line() {
let cache = tempdir().join("cache");
std::fs::create_dir_all(&cache).expect("create cache dir");
let _ = pretender_cmd()
.args(["explain", "not_a_real_metric"])
.env("XDG_CACHE_HOME", &cache)
.output();
let output = pretender_cmd()
.args(["feedback", "bug", "--dry-run", "--from-last-error"])
.env("XDG_CACHE_HOME", &cache)
.output()
.expect("failed to execute process");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("gh issue create"),
"dry run should print gh command; got stderr: {stderr}"
);
}
#[test]
fn test_non_zero_exit_shows_feedback_footer() {
let output = pretender_cmd()
.args(["explain", "not_a_real_metric"])
.output()
.expect("failed to execute process");
assert!(
!output.status.success(),
"unknown metric should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Feedback") || stderr.contains("feedback bug"),
"stderr should suggest using feedback; got: {stderr}"
);
}
#[test]
fn test_empty_config_file_falls_back_to_defaults_for_check() {
let dir = tempdir();
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/fixtures")
.join("python_simple.py");
std::fs::copy(&src, dir.join("python_simple.py")).expect("copy fixture");
std::fs::write(dir.join("pretender.toml"), "").expect("write empty config");
let output = check(&dir.join("python_simple.py"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"empty config should fall back to defaults, not fail; stdout: {} stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("failed to read pretender.toml"),
"empty config should not surface a read error; got: {stderr}"
);
}
#[test]
fn test_error_scratch_records_full_argv() {
let cache = tempdir().join("cache");
std::fs::create_dir_all(&cache).expect("create cache dir");
let output = pretender_cmd()
.args(["explain", "not_a_real_metric"])
.env("XDG_CACHE_HOME", &cache)
.output()
.expect("failed to execute process");
assert!(!output.status.success(), "should exit non-zero");
let scratch = cache.join("pretender").join("errors.jsonl");
let content = std::fs::read_to_string(&scratch)
.unwrap_or_else(|e| panic!("scratch file should exist at {}: {e}", scratch.display()));
let parsed: serde_json::Value = serde_json::from_str(content.trim_end_matches('\n'))
.unwrap_or_else(|e| panic!("scratch should be one JSON object: {e}\n{content}"));
let argv = parsed["argv"]
.as_array()
.expect("scratch should have argv array")
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect::<Vec<_>>();
assert!(
argv.iter().any(|a| a == "explain"),
"scratch argv should include the subcommand; got: {argv:?}"
);
assert!(
argv.iter().any(|a| a == "not_a_real_metric"),
"scratch argv should include the metric arg; got: {argv:?}"
);
assert!(
argv.len() > 1,
"scratch argv should capture the full command, not just the tool name; got: {argv:?}"
);
}
#[test]
fn test_explain_known_metric_prints_doc() {
let output = pretender_cmd()
.args(["explain", "cyclomatic"])
.output()
.expect("failed to execute process");
assert_eq!(
output.status.code(),
Some(0),
"explain cyclomatic should succeed"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("cyclomatic"),
"output should include metric name"
);
assert!(stdout.contains("McCabe"), "output should cite McCabe");
assert!(
stdout.contains("10"),
"output should mention default threshold"
);
}
#[test]
fn test_explain_unknown_metric_exits_nonzero() {
let output = pretender_cmd()
.args(["explain", "not_a_real_metric"])
.output()
.expect("failed to execute process");
assert_ne!(output.status.code(), Some(0), "unknown metric should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown metric") || stderr.contains("cyclomatic"),
"stderr should name the error and list available metrics; got: {stderr}",
);
}
#[test]
fn test_rust_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("rust_simple.rs"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("with_branch: 2"),
"expected with_branch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complex_func: 5"),
"expected complex_func: 5 in stdout: {stdout}"
);
}
#[test]
fn test_rust_nested_function_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("rust_nested.rs"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("outer: 2"),
"expected outer: 2 (nested fn branches excluded); got stdout: {stdout}"
);
assert!(
stdout.contains("inner: 2"),
"expected inner: 2; got stdout: {stdout}"
);
}
#[test]
fn test_javascript_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("js_simple.js"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("withBranch: 2"),
"expected withBranch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complexFunc: 5"),
"expected complexFunc: 5 in stdout: {stdout}"
);
}
#[test]
fn test_javascript_nested_function_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("js_nested.js"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("outer: 2"),
"expected outer: 2 (nested fn branches excluded); got stdout: {stdout}"
);
assert!(
stdout.contains("inner: 2"),
"expected inner: 2; got stdout: {stdout}"
);
}
#[test]
fn test_typescript_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("ts_sample.ts"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("greet: 1"),
"expected greet: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("withBranch: 2"),
"expected withBranch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complexFunc: 5"),
"expected complexFunc: 5 in stdout: {stdout}"
);
}
#[test]
fn test_check_parallel_results_are_deterministic() {
let dir = tempdir();
for name in ["python_simple.py", "python_violator.py"] {
std::fs::copy(source_fixture(name), dir.join(name)).expect("copy fixture");
}
let run = || {
let output = pretender_cmd()
.arg("check")
.arg(&dir)
.arg("--format")
.arg("json")
.env("NO_COLOR", "1")
.output()
.expect("failed to execute process");
assert!(
output.status.code().is_some(),
"process must exit normally; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).expect("stdout is utf-8")
};
let first = run();
let second = run();
let normalize = |s: &str| {
let v: serde_json::Value = serde_json::from_str(s).expect("valid json");
if let Some(h) = v.pointer("/data/history") {
if let Some(obj) = h.as_object() {
let mut o = obj.clone();
o.remove("resolution");
return serde_json::to_string(&o).expect("serialize");
}
}
s.to_string()
};
assert_eq!(
normalize(&first),
normalize(&second),
"json output must be deterministic across runs (excluding resolution deltas)"
);
}
#[test]
fn test_resolution_tracking_reports_delta_across_runs() {
let dir = tempdir();
let violator = dir.join("violator.py");
std::fs::copy(source_fixture("python_violator.py"), &violator).expect("copy fixture");
let check = || {
pretender_cmd()
.arg("check")
.arg(".")
.current_dir(&dir)
.env("NO_COLOR", "1")
.output()
.expect("run check")
};
let first = check();
let first_out = String::from_utf8_lossy(&first.stdout).to_string();
assert!(
first_out.contains("resolution: 0 fixed, 0 still open, 1 new"),
"run 1 stdout: {first_out}"
);
std::fs::write(
violator.as_path(),
"def fewer_params(a, b, c):\n return a + b + c\n",
)
.expect("fix violation");
let second = check();
let second_out = String::from_utf8_lossy(&second.stdout).to_string();
assert!(
second_out.contains("1 fixed"),
"run 2 must report the fix; stdout: {second_out}"
);
let third = check();
let third_out = String::from_utf8_lossy(&third.stdout).to_string();
assert!(
!third_out.contains("resolution: 1 fixed"),
"clean run 3 must not re-report run 1's fix; stdout: {third_out}"
);
}
#[test]
fn test_resolution_rate_appears_in_json_history() {
let dir = tempdir();
let violator = dir.join("violator.py");
std::fs::copy(source_fixture("python_violator.py"), &violator).expect("copy");
let run = || {
pretender_cmd()
.arg("check")
.arg(".")
.current_dir(&dir)
.arg("--format")
.arg("json")
.env("NO_COLOR", "1")
.output()
.expect("run check")
};
run();
let second = run();
let json: serde_json::Value = serde_json::from_slice(&second.stdout).expect("json");
let resolution = &json["data"]["history"]["resolution"];
assert_eq!(resolution["fixed"], 0);
assert_eq!(resolution["still_open"], 1);
assert_eq!(resolution["new"], 0);
assert_eq!(resolution["rate"], 0.0);
}
#[test]
fn test_init_defaults_flag_is_rejected() {
let dir = tempdir();
let output = init_in(&dir).arg("--defaults").output().expect("run init");
assert!(
!output.status.success(),
"expected failure for removed --defaults flag"
);
}
#[test]
fn test_init_non_interactive_writes_config_with_mode_override() {
let dir = tempdir();
let output = init_in(&dir)
.arg("--non-interactive")
.arg("--mode")
.arg("gate")
.output()
.expect("run init");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let config = std::fs::read_to_string(dir.join("pretender.toml")).expect("config exists");
assert!(config.contains("mode = \"gate\""), "config: {config}");
assert!(
config.contains("languages = [\"auto\"]"),
"config: {config}"
);
assert!(config.contains("[roles.test]"), "config: {config}");
}
#[test]
fn test_init_injects_managed_blocks() {
let dir = tempdir();
let output = init_in(&dir)
.arg("--non-interactive")
.output()
.expect("run init");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let agents_path = dir.join("AGENTS.md");
assert!(agents_path.exists(), "AGENTS.md should be created by init");
let content = std::fs::read_to_string(&agents_path).expect("read AGENTS.md");
assert!(
content.contains("<!-- WAI:START -->"),
"AGENTS.md should contain WAI managed block"
);
assert!(
content.contains("<!-- WAI:END -->"),
"AGENTS.md should contain WAI end marker"
);
assert!(
content.contains("<!-- OPENSPEC:START -->"),
"AGENTS.md should contain OPENSPEC managed block"
);
assert!(
content.contains("<!-- OPENSPEC:END -->"),
"AGENTS.md should contain OPENSPEC end marker"
);
assert!(
content.contains("<!-- DONT:START -->"),
"AGENTS.md should contain DONT managed block"
);
assert!(
content.contains("<!-- DONT:END -->"),
"AGENTS.md should contain DONT end marker"
);
}
#[test]
fn test_init_interactive_can_install_hook_and_ci() {
let dir = tempdir();
std::fs::create_dir_all(dir.join(".git/hooks")).expect("git hooks dir");
let output = init_in(&dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write as _;
child
.stdin
.as_mut()
.expect("stdin")
.write_all(b"guidance\npython,rust\ny\ny\n")?;
child.wait_with_output()
})
.expect("run init interactively");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let config = std::fs::read_to_string(dir.join("pretender.toml")).expect("config exists");
assert!(config.contains("mode = \"guidance\""), "config: {config}");
assert!(
config.contains("languages = [\"python\", \"rust\"]"),
"config: {config}"
);
let hook = std::fs::read_to_string(dir.join(".git/hooks/pre-commit")).expect("hook exists");
assert!(hook.contains("Installed by Pretender"), "hook: {hook}");
assert!(
hook.contains("exec pretender check . --staged"),
"hook: {hook}"
);
let workflow = std::fs::read_to_string(dir.join(".github/workflows/pretender.yml"))
.expect("workflow exists");
assert!(
workflow.contains(
"cargo install --git https://github.com/charly-vibes/pretender --locked pretender"
),
"workflow: {workflow}"
);
}
#[test]
fn test_ci_generate_github_writes_workflow() {
let dir = tempdir();
let output = ci_generate_in(&dir, "github")
.output()
.expect("run ci generator");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let workflow = std::fs::read_to_string(dir.join(".github/workflows/pretender.yml"))
.expect("workflow exists");
assert!(
workflow.starts_with("name: Pretender"),
"workflow: {workflow}"
);
assert!(
workflow.contains("uses: actions/checkout@v4"),
"workflow: {workflow}"
);
assert!(
workflow.contains("uses: dtolnay/rust-toolchain@stable"),
"workflow: {workflow}"
);
assert!(
workflow.contains(
"cargo install --git https://github.com/charly-vibes/pretender --locked pretender"
),
"workflow: {workflow}"
);
assert!(
workflow.contains("pretender check . --format=sarif --output=pretender.sarif"),
"workflow: {workflow}"
);
assert!(
workflow.contains("uses: github/codeql-action/upload-sarif@v3"),
"workflow: {workflow}"
);
assert!(
workflow.contains("pretender report --format=markdown >> $GITHUB_STEP_SUMMARY"),
"workflow: {workflow}"
);
}
#[test]
fn test_ci_generate_non_github_stays_stubbed() {
let dir = tempdir();
let output = ci_generate_in(&dir, "gitlab")
.output()
.expect("run ci generator");
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not yet implemented"), "stderr: {stderr}");
}
#[test]
fn test_hooks_install_and_uninstall_manage_pretender_shim() {
let dir = tempdir();
std::fs::create_dir_all(dir.join(".git/hooks")).expect("git hooks dir");
let install = hooks_in(&dir, "install")
.output()
.expect("run hooks install");
assert!(
install.status.success(),
"stderr: {}",
String::from_utf8_lossy(&install.stderr)
);
let hook_path = dir.join(".git/hooks/pre-commit");
let hook = std::fs::read_to_string(&hook_path).expect("hook exists");
assert!(hook.contains("Installed by Pretender"), "hook: {hook}");
assert!(
hook.contains("exec pretender check . --staged"),
"hook: {hook}"
);
let uninstall = hooks_in(&dir, "uninstall")
.output()
.expect("run hooks uninstall");
assert!(
uninstall.status.success(),
"stderr: {}",
String::from_utf8_lossy(&uninstall.stderr)
);
assert!(!hook_path.exists(), "hook should be removed");
}
#[test]
fn test_hooks_install_pre_push_installs_pre_push_hook() {
let dir = tempdir();
std::fs::create_dir_all(dir.join(".git/hooks")).expect("git hooks dir");
let install = hooks_in_named(&dir, "install", "pre-push")
.output()
.expect("run hooks install pre-push");
assert!(
install.status.success(),
"stderr: {}",
String::from_utf8_lossy(&install.stderr)
);
let hook_path = dir.join(".git/hooks/pre-push");
let hook = std::fs::read_to_string(&hook_path).expect("pre-push hook exists");
assert!(hook.contains("Installed by Pretender"), "hook: {hook}");
assert!(
hook.contains("exec pretender check . --staged"),
"hook: {hook}"
);
assert!(
!dir.join(".git/hooks/pre-commit").exists(),
"pre-commit untouched"
);
let uninstall = hooks_in_named(&dir, "uninstall", "pre-push")
.output()
.expect("run hooks uninstall pre-push");
assert!(
uninstall.status.success(),
"stderr: {}",
String::from_utf8_lossy(&uninstall.stderr)
);
assert!(!hook_path.exists(), "pre-push hook should be removed");
}
#[test]
fn test_hooks_install_creates_hooks_dir_when_absent() {
let dir = tempdir();
std::fs::create_dir_all(dir.join(".git")).expect("git dir");
let install = hooks_in(&dir, "install")
.output()
.expect("run hooks install");
assert!(
install.status.success(),
"stderr: {}",
String::from_utf8_lossy(&install.stderr)
);
assert!(
dir.join(".git/hooks/pre-commit").exists(),
"hook should exist even when .git/hooks was absent"
);
}
#[test]
fn test_hooks_uninstall_is_noop_when_no_hook() {
let dir = tempdir();
std::fs::create_dir_all(dir.join(".git/hooks")).expect("git hooks dir");
let uninstall = hooks_in(&dir, "uninstall")
.output()
.expect("run hooks uninstall");
assert!(
uninstall.status.success(),
"uninstall with no hook should succeed silently; stderr: {}",
String::from_utf8_lossy(&uninstall.stderr)
);
}
#[test]
fn test_hooks_uninstall_refuses_foreign_hook() {
let dir = tempdir();
let hooks_dir = dir.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir).expect("git hooks dir");
std::fs::write(
hooks_dir.join("pre-commit"),
"#!/bin/sh\necho 'custom hook'\n",
)
.expect("write foreign hook");
let uninstall = hooks_in(&dir, "uninstall")
.output()
.expect("run hooks uninstall");
assert!(
!uninstall.status.success(),
"should refuse to remove a hook not installed by Pretender"
);
let stderr = String::from_utf8_lossy(&uninstall.stderr);
assert!(
stderr.contains("refusing to remove hook not installed by Pretender"),
"stderr: {stderr}"
);
assert!(
hooks_dir.join("pre-commit").exists(),
"foreign hook should be left intact"
);
}
#[test]
fn test_hooks_install_refuses_foreign_hook() {
let dir = tempdir();
let hooks_dir = dir.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir).expect("git hooks dir");
std::fs::write(
hooks_dir.join("pre-commit"),
"#!/bin/sh\necho 'custom hook'\n",
)
.expect("write foreign hook");
let install = hooks_in(&dir, "install")
.output()
.expect("run hooks install");
assert!(
!install.status.success(),
"should refuse to overwrite a hook not installed by Pretender"
);
let stderr = String::from_utf8_lossy(&install.stderr);
assert!(
stderr.contains("refusing to overwrite hook not installed by Pretender"),
"stderr: {stderr}"
);
let hook = std::fs::read_to_string(hooks_dir.join("pre-commit")).expect("hook still exists");
assert!(
hook.contains("custom hook"),
"foreign hook should be left intact: {hook}"
);
}
#[test]
fn test_hooks_install_is_idempotent() {
let dir = tempdir();
std::fs::create_dir_all(dir.join(".git/hooks")).expect("git hooks dir");
let first = hooks_in(&dir, "install").output().expect("first install");
assert!(
first.status.success(),
"stderr: {}",
String::from_utf8_lossy(&first.stderr)
);
let second = hooks_in(&dir, "install").output().expect("second install");
assert!(
second.status.success(),
"reinstall over Pretender-owned hook should succeed; stderr: {}",
String::from_utf8_lossy(&second.stderr)
);
let hook = std::fs::read_to_string(dir.join(".git/hooks/pre-commit")).expect("hook exists");
assert!(
hook.contains("exec pretender check . --staged"),
"hook: {hook}"
);
}
#[test]
fn test_hooks_install_from_subdirectory() {
let dir = tempdir();
std::fs::create_dir_all(dir.join(".git/hooks")).expect("git hooks dir");
let subdir = dir.join("src/sub/deep");
std::fs::create_dir_all(&subdir).expect("create subdir");
let install = hooks_in(&subdir, "install")
.output()
.expect("run hooks install from subdir");
assert!(
install.status.success(),
"stderr: {}",
String::from_utf8_lossy(&install.stderr)
);
assert!(
dir.join(".git/hooks/pre-commit").exists(),
"hook should be at repo root, not subdir"
);
assert!(
!subdir.join(".git/hooks/pre-commit").exists(),
"hook should NOT be created in subdirectory"
);
}
#[test]
fn test_hooks_uninstall_outside_repo_fails_cleanly() {
let dir = tempdir();
let uninstall = hooks_in(&dir, "uninstall")
.output()
.expect("run hooks uninstall outside repo");
assert!(
!uninstall.status.success(),
"should fail outside a git repo"
);
let stderr = String::from_utf8_lossy(&uninstall.stderr);
assert!(
stderr.contains("git") || stderr.contains("repository"),
"error should mention git/repository; got: {stderr}"
);
}
#[test]
fn test_report_markdown_reads_last_json_check() {
let dir = tempdir();
let staged = dir.join("python_violator.py");
std::fs::copy(source_fixture("python_violator.py"), &staged).expect("copy fixture");
let check_output = check_in(&dir, &staged)
.arg("--format")
.arg("json")
.output()
.expect("run json check");
assert!(
check_output.status.success(),
"json check failed: {}",
String::from_utf8_lossy(&check_output.stderr)
);
let output = report_in(&dir)
.arg("--format")
.arg("markdown")
.output()
.expect("run report");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("# Pretender report"), "stdout: {stdout}");
assert!(stdout.contains("python_violator.py"), "stdout: {stdout}");
assert!(stdout.contains("too_many_params"), "stdout: {stdout}");
assert!(stdout.contains("cyclomatic"), "stdout: {stdout}");
}
#[test]
fn test_report_html_writes_output_file() {
let dir = tempdir();
let staged = dir.join("python_violator.py");
let out_path = dir.join("pretender-report.html");
std::fs::copy(source_fixture("python_violator.py"), &staged).expect("copy fixture");
let check_output = check_in(&dir, &staged)
.arg("--format")
.arg("json")
.output()
.expect("run json check");
assert!(
check_output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&check_output.stderr)
);
let output = report_in(&dir)
.arg("--format")
.arg("html")
.arg("--output")
.arg(&out_path)
.output()
.expect("run report");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let html = std::fs::read_to_string(&out_path).expect("html report exists");
assert!(html.contains("<!doctype html>"), "html: {html}");
assert!(html.contains("python_violator.py"), "html: {html}");
assert!(html.contains("too_many_params"), "html: {html}");
}
#[test]
fn test_report_markdown_reads_last_human_check() {
let dir = tempdir();
let staged = dir.join("python_violator.py");
std::fs::copy(source_fixture("python_violator.py"), &staged).expect("copy fixture");
let check_output = check_in(&dir, &staged).output().expect("run human check");
assert!(
check_output.status.success(),
"human check failed: {}",
String::from_utf8_lossy(&check_output.stderr)
);
let output = report_in(&dir)
.arg("--format")
.arg("markdown")
.output()
.expect("run report");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("# Pretender report"), "stdout: {stdout}");
assert!(stdout.contains("python_violator.py"), "stdout: {stdout}");
}
#[test]
fn test_report_fails_without_cached_report() {
let dir = tempdir();
let output = report_in(&dir).output().expect("run report");
assert_eq!(
output.status.code(),
Some(1),
"stdout: {} stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("failed to read last check report"),
"stderr: {stderr}"
);
}
#[test]
fn test_smell_call_weights_elevate_abc() {
let (_dir, path) = stage_fixture("python_smell_calls.py");
let output = pretender_cmd()
.arg("check")
.arg(&path)
.arg("--format")
.arg("json")
.env("NO_COLOR", "1")
.output()
.expect("run pretender");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid json: {e}\nstdout: {stdout}"));
let units = json["data"]["files"][0]["units"]
.as_array()
.expect("units array");
let abc_for = |name: &str| -> f64 {
units
.iter()
.find(|u| u["name"] == name)
.unwrap_or_else(|| panic!("unit {name} not found"))["metrics"]["abc"]
.as_f64()
.unwrap_or_else(|| panic!("abc metric for {name}"))
};
let eval_abc = abc_for("smell_eval");
assert!(
(eval_abc - 5.099).abs() < 0.01,
"smell_eval abc={eval_abc}, expected ≈5.099 (sqrt(26))"
);
let exec_abc = abc_for("smell_exec");
assert!(
(exec_abc - 5.0).abs() < 0.01,
"smell_exec abc={exec_abc}, expected 5.0"
);
let compile_abc = abc_for("smell_compile");
assert!(
(compile_abc - 3.162).abs() < 0.01,
"smell_compile abc={compile_abc}, expected ≈3.162 (sqrt(10))"
);
}
#[test]
fn test_go_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("go_sample.go"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("with_branch: 2"),
"expected with_branch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complex_func: 5"),
"expected complex_func: 5 in stdout: {stdout}"
);
}
#[test]
fn test_go_nested_function_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("go_nested.go"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("outer: 2"),
"expected outer: 2 (closure branches excluded); got stdout: {stdout}"
);
}
#[test]
fn test_java_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("java_sample.java"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("withBranch: 2"),
"expected withBranch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complexFunc: 5"),
"expected complexFunc: 5 in stdout: {stdout}"
);
}
#[test]
fn test_ruby_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("ruby_sample.rb"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("with_branch: 2"),
"expected with_branch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complex_func: 5"),
"expected complex_func: 5 in stdout: {stdout}"
);
}
#[test]
fn test_c_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("c_sample.c"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("with_branch: 2"),
"expected with_branch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complex_func: 5"),
"expected complex_func: 5 in stdout: {stdout}"
);
}
#[test]
fn test_check_staged_only_scans_staged_files() {
let dir = tempdir();
git_init(&dir);
let baseline = dir.join("baseline.py");
std::fs::write(&baseline, "def baseline(): pass\n").unwrap();
git_add(&dir, &baseline);
git_commit(&dir, "baseline");
let file_a = dir.join("file_a.py");
std::fs::write(&file_a, "def func_a(): pass\n").unwrap();
git_add(&dir, &file_a);
let file_b = dir.join("file_b.py");
std::fs::write(&file_b, "def func_b(): pass\n").unwrap();
let output = pretender_cmd()
.args(["check", ".", "--staged"])
.current_dir(&dir)
.env("NO_COLOR", "1")
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("file_a.py"),
"expected file_a.py in stdout: {stdout}"
);
assert!(
!stdout.contains("file_b.py"),
"unexpected file_b.py in stdout (not staged): {stdout}"
);
}
#[test]
fn test_check_staged_empty_staging_area_exits_success() {
let dir = tempdir();
git_init(&dir);
let f = dir.join("committed.py");
std::fs::write(&f, "def f(): pass\n").unwrap();
git_add(&dir, &f);
git_commit(&dir, "init");
let output = pretender_cmd()
.args(["check", ".", "--staged"])
.current_dir(&dir)
.env("NO_COLOR", "1")
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_staged_first_commit_no_head() {
let dir = tempdir();
git_init(&dir);
let f = dir.join("first.py");
std::fs::write(&f, "def first(): pass\n").unwrap();
git_add(&dir, &f);
let output = pretender_cmd()
.args(["check", ".", "--staged"])
.current_dir(&dir)
.env("NO_COLOR", "1")
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("first.py"),
"expected first.py in stdout: {stdout}"
);
}
#[test]
fn test_check_diff_only_filters_to_changed_files() {
let dir = tempdir();
git_init(&dir);
let file_old = dir.join("file_old.py");
std::fs::write(&file_old, "def old(): pass\n").unwrap();
git_add(&dir, &file_old);
git_commit(&dir, "first commit");
let file_new = dir.join("file_new.py");
std::fs::write(&file_new, "def new_func(): pass\n").unwrap();
git_add(&dir, &file_new);
git_commit(&dir, "second commit");
let output = pretender_cmd()
.args(["check", ".", "--diff-only", "--diff-base=HEAD~1"])
.current_dir(&dir)
.env("NO_COLOR", "1")
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("file_new.py"),
"expected file_new.py in stdout: {stdout}"
);
assert!(
!stdout.contains("file_old.py"),
"unexpected file_old.py in stdout (unchanged): {stdout}"
);
}
#[test]
fn test_cpp_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("cpp_sample.cpp"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("with_branch: 2"),
"expected with_branch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complex_func: 5"),
"expected complex_func: 5 in stdout: {stdout}"
);
}
const RUFF_PLUGIN_TOML: &str = r#"
name = "ruff"
extensions = [".py"]
command = ["ruff", "check", "--output-format=json", "--select=E501", "{files}"]
parser = "json"
[mapping]
path = "filename"
line = "location.row"
message = "message"
code = "code"
"#;
#[test]
fn test_external_plugin_ruff_json_findings() {
if std::process::Command::new("ruff")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: ruff not in PATH");
return;
}
let metrics_dir = tempdir();
std::fs::write(metrics_dir.join("ruff.toml"), RUFF_PLUGIN_TOML).expect("write ruff.toml");
let (_dir, staged) = stage_fixture("python_simple.py");
let output = check(&staged)
.arg("--format")
.arg("json")
.env("PRETENDER_METRICS_DIR", &metrics_dir)
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("valid json output");
let files = json["data"]["files"].as_array().expect("files array");
assert_eq!(files.len(), 1);
let external = files[0]["external_findings"]
.as_array()
.expect("external_findings present when ruff fires");
assert!(
!external.is_empty(),
"expected at least one ruff finding; full output: {json}"
);
assert_eq!(
external[0]["source"].as_str(),
Some("ruff"),
"finding source should be 'ruff'"
);
assert_eq!(
external[0]["code"].as_str(),
Some("E501"),
"finding code should be 'E501'"
);
}
#[test]
fn test_check_format_junit_is_rejected() {
let (_dir, staged) = stage_fixture("python_simple.py");
let output = check(&staged)
.arg("--format")
.arg("junit")
.output()
.expect("failed to execute process");
assert!(
!output.status.success(),
"junit format should be rejected (removed until implemented)"
);
}
#[test]
fn test_check_staged_no_files_prints_message() {
let dir = tempdir();
git_init(&dir);
std::fs::write(dir.join("hello.py"), "def hello():\n pass\n").expect("write file");
let output = check_in(&dir, &dir)
.arg("--staged")
.output()
.expect("failed to execute process");
assert_eq!(
output.status.code(),
Some(0),
"should exit 0 with no staged files; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("No staged files to check"),
"should print message when no staged files; stdout: {stdout}"
);
}
#[test]
fn test_check_default_hides_clean_functions() {
let (_dir, staged) = stage_fixture("python_simple.py");
let output = check(&staged)
.arg("--mode")
.arg("gate")
.output()
.expect("failed to execute process");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout
.lines()
.any(|l| l.starts_with(" ") && l.contains("cyclomatic=")),
"clean functions should be hidden by default; stdout: {stdout}"
);
}
#[test]
fn test_check_verbose_shows_all_functions() {
let (_dir, staged) = stage_fixture("python_simple.py");
let output = check(&staged)
.arg("--mode")
.arg("gate")
.arg("--show-all")
.output()
.expect("failed to execute process");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.lines().any(|l| l.contains("cyclomatic=")),
"--show-all should show per-function metrics; stdout: {stdout}"
);
}
#[test]
fn test_check_human_output_rounds_violation_values() {
let (_dir, staged) = stage_fixture("python_violator.py");
let output = check(&staged)
.arg("--mode")
.arg("gate")
.output()
.expect("failed to execute process");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("params 6 > 4"),
"integer violation should display without decimals; stdout: {stdout}"
);
}
#[test]
fn test_check_human_output_abc_rounds_to_two_decimal_places() {
let (dir, path) = write_temp_file(
"abc_fixture.py",
r#"
def complex_function(x, y, z):
a = x + 1
b = y * 2
c = z - 1
if a > 0:
result = some_call(a, b)
result2 = other_call(b, c)
result3 = third_call(a, c)
return result + result2 + result3
elif b > 0:
return another_call(a, b, c)
else:
return fallback(x, y, z)
"#,
);
std::fs::write(dir.join("pretender.toml"), "[thresholds]\nabc_max = 1\n")
.expect("write config");
let output = check_in(&dir, &path)
.arg("--mode")
.arg("gate")
.output()
.expect("failed to execute process");
let stdout = String::from_utf8_lossy(&output.stdout);
let abc_violation = stdout
.lines()
.find(|l| l.contains("VIOLATION") && l.contains("abc"));
assert!(
abc_violation.is_some(),
"expected abc violation; stdout: {stdout}"
);
let line = abc_violation.unwrap();
let has_long_float = line.split_whitespace().any(|tok| {
if let Some(idx) = tok.find('.') {
tok[idx + 1..].len() > 2
} else {
false
}
});
assert!(
!has_long_float,
"abc violation line has excessive decimal places: {line}"
);
}
#[test]
fn test_check_skips_binary_files() {
let dir = tempdir();
let binary = dir.join("artifact.bin");
std::fs::write(&binary, b"\x00\x01\x02\x03\xff\xfe").expect("write binary file");
let output = check(&binary).output().expect("failed to execute process");
assert_eq!(
output.status.code(),
Some(0),
"check should exit 0 when given a binary file; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_check_skips_binary_files_in_directory() {
let dir = tempdir();
let source = dir.join("hello.py");
std::fs::write(&source, "def hello():\n pass\n").expect("write source");
let binary = dir.join("artifact.bin");
std::fs::write(&binary, b"\x00\x01\x02\x03\xff\xfe").expect("write binary file");
let output = check(&dir).output().expect("failed to execute process");
assert_eq!(
output.status.code(),
Some(0),
"check should exit 0 when directory contains binary files; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_version_flag_works() {
let output = pretender_cmd()
.arg("--version")
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"--version should exit 0; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("pretender"),
"--version should print 'pretender'; got: {stdout}"
);
assert!(
stdout.contains(env!("CARGO_PKG_VERSION")),
"--version should include version number; got: {stdout}"
);
}
#[test]
fn test_version_short_flag_works() {
let output = pretender_cmd()
.arg("-V")
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"-V should exit 0; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("pretender"),
"-V should print 'pretender'; got: {stdout}"
);
}
fn neutral_git_config() -> &'static std::path::Path {
neutral_git_config_global()
}
fn doctor_in(dir: &Path, extra_args: &[&str]) -> Command {
let mut cmd = pretender_cmd();
cmd.arg("doctor")
.args(extra_args)
.current_dir(dir)
.env("NO_COLOR", "1")
.env("GIT_CONFIG_GLOBAL", neutral_git_config());
cmd
}
fn write_pretender_hook(dir: &Path) {
let hooks_dir = dir.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir).expect("create hooks dir");
let hook = hooks_dir.join("pre-commit");
std::fs::write(
&hook,
"#!/usr/bin/env sh\n# Installed by Pretender.\nexec pretender check . --staged\n",
)
.expect("write hook");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))
.expect("chmod hook");
}
}
#[test]
fn test_doctor_all_pass_with_valid_config_and_hook() {
let dir = tempdir();
git_init(&dir);
std::fs::write(dir.join("pretender.toml"), "[pretender]\n").expect("write config");
write_pretender_hook(&dir);
let output = doctor_in(&dir, &["--human"]).output().expect("run doctor");
assert_eq!(
output.status.code(),
Some(0),
"doctor should exit 0; stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("checks passed"),
"expected summary; got: {stdout}"
);
}
#[test]
fn test_doctor_missing_config_exits_1_with_correct_skips() {
let dir = tempdir();
git_init(&dir);
let output = doctor_in(&dir, &["--human"]).output().expect("run doctor");
assert_eq!(output.status.code(), Some(1), "should exit 1");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("✗ Config present"),
"Config present should fail; got: {stdout}"
);
assert!(
stdout.contains("⚠ Config valid"),
"Config valid should be skipped; got: {stdout}"
);
assert!(
stdout.contains("⚠ Plugin manifests"),
"Plugin manifests should be skipped; got: {stdout}"
);
}
#[test]
fn test_doctor_json_missing_config_exits_1_with_fail_status() {
let dir = tempdir();
git_init(&dir);
let output = doctor_in(&dir, &["--json"]).output().expect("run doctor");
assert_eq!(output.status.code(), Some(1), "should exit 1");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("output should be valid JSON");
let checks = parsed["data"]["checks"]
.as_array()
.expect("JSON data.checks should be array");
let config_present = checks
.iter()
.find(|c| c["name"] == "Config present")
.expect("Config present check missing");
assert_eq!(
config_present["status"], "fail",
"Config present should have status=fail"
);
}
#[test]
fn test_doctor_unmanaged_hook_fails_installed_skips_executable() {
let dir = tempdir();
git_init(&dir);
std::fs::write(dir.join("pretender.toml"), "[pretender]\n").expect("write config");
let hooks_dir = dir.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir).expect("create hooks dir");
let hook = hooks_dir.join("pre-commit");
std::fs::write(&hook, "#!/usr/bin/env sh\necho 'custom hook'\n").expect("write hook");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))
.expect("chmod hook");
}
let output = doctor_in(&dir, &["--human"]).output().expect("run doctor");
assert_eq!(output.status.code(), Some(1), "should exit 1");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("✗ Hook installed"),
"Hook installed should fail; got: {stdout}"
);
assert!(
stdout.contains("⚠ Hook executable"),
"Hook executable should be skipped; got: {stdout}"
);
}
#[test]
fn test_check_warns_on_unsupported_language_paths() {
let dir = tempdir();
std::fs::write(
dir.join("main.hs"),
"module Main where\nmain :: IO ()\nmain = putStrLn \"hi\"\n",
)
.expect("write haskell file");
let output = pretender_cmd()
.arg("check")
.arg(&dir)
.env("NO_COLOR", "1")
.output()
.expect("failed to execute process");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("no files matched a supported grammar"),
"expected warning about no supported grammar; stderr: {stderr}"
);
assert!(
stderr.contains("warning:"),
"warning should appear on stderr; stderr: {stderr}"
);
}
#[test]
fn test_check_no_warning_when_supported_language_found() {
let dir = tempdir();
std::fs::write(dir.join("main.clj"), "(ns my-app)\n").expect("write clojure file");
std::fs::write(dir.join("main.py"), "def hello(): pass\n").expect("write python file");
let output = pretender_cmd()
.arg("check")
.arg(&dir)
.env("NO_COLOR", "1")
.output()
.expect("failed to execute process");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("no files matched a supported grammar"),
"should not warn when supported files are present; stderr: {stderr}"
);
}
#[test]
fn test_r_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("r_sample.R"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("with_branch: 2"),
"expected with_branch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complex_func: 5"),
"expected complex_func: 5 in stdout: {stdout}"
);
}
#[test]
fn test_julia_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("julia_sample.jl"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("with_branch: 2"),
"expected with_branch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complex_func: 5"),
"expected complex_func: 5 in stdout: {stdout}"
);
}
#[test]
fn test_csharp_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("csharp_sample.cs"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Simple: 1"),
"expected Simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("WithBranch: 2"),
"expected WithBranch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("ComplexFunc: 5"),
"expected ComplexFunc: 5 in stdout: {stdout}"
);
}
#[test]
fn test_csharp_check() {
let (_dir, staged) = stage_fixture("csharp_sample.cs");
let output = check(&staged).output().expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("csharp_sample.cs"),
"expected csharp_sample.cs in output; stdout: {stdout}"
);
}
#[test]
fn test_clojure_complexity() {
let output = pretender_cmd()
.arg("complexity")
.arg(source_fixture("clojure_sample.clj"))
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("simple: 1"),
"expected simple: 1 in stdout: {stdout}"
);
assert!(
stdout.contains("with-branch: 2"),
"expected with-branch: 2 in stdout: {stdout}"
);
assert!(
stdout.contains("complex-func: 5"),
"expected complex-func: 5 in stdout: {stdout}"
);
}
fn duration_setup() -> (PathBuf, PathBuf, PathBuf) {
let (dir, _guard) = write_temp_file(
"pretender.toml",
r#"
[thresholds.unit-test]
duration_max_ms = 50
[roles]
test = { paths = [] }
unit-test = { paths = [] }
integration-test = { paths = [] }
"#,
);
let test_file = dir.join("tests/unit/test_math.py");
std::fs::create_dir_all(test_file.parent().unwrap()).expect("create dirs");
std::fs::write(&test_file, "def test_addition(): pass").expect("write test file");
let junit_xml = format!(
r#"<?xml version="1.0"?>
<testsuite name="ts" tests="1">
<testcase classname="test_math" name="test_addition" file="{}" time="0.1"/>
</testsuite>"#,
test_file.display()
);
let (_, report_path) = write_temp_file("test-report.xml", &junit_xml);
(dir, test_file, report_path)
}
#[test]
fn test_duration_report_human_output() {
let (dir, _test_file, report_path) = duration_setup();
let output = pretender_cmd()
.arg("check")
.arg("--test-report")
.arg(&report_path)
.arg("--format")
.arg("human")
.env("NO_COLOR", "1")
.current_dir(&dir)
.output()
.expect("pretender check");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Test duration findings"),
"expected 'Test duration findings' in stdout: {stdout}"
);
assert!(
stdout.contains("test_addition"),
"expected test name in stdout: {stdout}"
);
assert!(
stdout.contains("100ms > 50ms"),
"expected duration violation in stdout: {stdout}"
);
}
#[test]
fn test_duration_report_json_contains_findings() {
let (dir, _test_file, report_path) = duration_setup();
let output = pretender_cmd()
.arg("check")
.arg("--test-report")
.arg(&report_path)
.arg("--format")
.arg("json")
.env("NO_COLOR", "1")
.current_dir(&dir)
.output()
.expect("pretender check");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("test_findings"),
"expected 'test_findings' in JSON output: {stdout}"
);
assert!(
stdout.contains("test_addition"),
"expected test name in JSON: {stdout}"
);
}
#[test]
fn test_duration_report_sarif_contains_findings() {
let (dir, _test_file, report_path) = duration_setup();
let output = pretender_cmd()
.arg("check")
.arg("--test-report")
.arg(&report_path)
.arg("--format")
.arg("sarif")
.env("NO_COLOR", "1")
.current_dir(&dir)
.output()
.expect("pretender check");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("pretender/test-duration"),
"expected 'pretender/test-duration' rule id in SARIF: {stdout}"
);
assert!(
stdout.contains("test_addition"),
"expected test name in SARIF: {stdout}"
);
}
#[test]
fn test_duration_no_threshold_no_findings() {
let (dir, _guard) = write_temp_file(
"pretender.toml",
r#"
[roles]
test = { paths = [] }
unit-test = { paths = [] }
integration-test = { paths = [] }
"#,
);
let test_file = dir.join("tests/unit/test_math.py");
std::fs::create_dir_all(test_file.parent().unwrap()).expect("create dirs");
std::fs::write(&test_file, "def test_addition(): pass").expect("write test file");
let junit_xml = format!(
r#"<?xml version="1.0"?>
<testsuite name="ts" tests="1">
<testcase classname="test_math" name="test_addition" file="{}" time="0.1"/>
</testsuite>"#,
test_file.display()
);
let (_, report_path) = write_temp_file("test-report.xml", &junit_xml);
let output = pretender_cmd()
.arg("check")
.arg("--test-report")
.arg(&report_path)
.arg("--format")
.arg("json")
.env("NO_COLOR", "1")
.current_dir(&dir)
.output()
.expect("pretender check");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("test_findings"),
"expected no test_findings when no threshold configured: {stdout}"
);
}
#[test]
fn test_duration_cache_persisted() {
let (dir, _test_file, report_path) = duration_setup();
let output = pretender_cmd()
.arg("check")
.arg("--test-report")
.arg(&report_path)
.arg("--format")
.arg("json")
.env("NO_COLOR", "1")
.current_dir(&dir)
.output()
.expect("pretender check");
assert!(output.status.success());
let cache_path = dir.join(".pretender/last-check.json");
assert!(cache_path.exists(), "cache file should exist");
let cache_content = std::fs::read_to_string(&cache_path).expect("read cache");
assert!(
cache_content.contains("test_findings"),
"expected test_findings in cache: {cache_content}"
);
}
#[test]
fn test_doctor_warns_when_global_hooks_path_redirects_hook() {
let dir = tempdir();
git_init(&dir);
std::fs::write(dir.join("pretender.toml"), "[pretender]\n").expect("write config");
write_pretender_hook(&dir);
let global_cfg = dir.join("redirect.gitconfig");
std::fs::write(
&global_cfg,
"[core]\n\thooksPath = /nonexistent-shim-hooks\n",
)
.expect("write redirect gitconfig");
let mut cmd = pretender_cmd();
cmd.arg("doctor")
.arg("--human")
.current_dir(&dir)
.env("NO_COLOR", "1")
.env("GIT_CONFIG_GLOBAL", &global_cfg);
let output = cmd.output().expect("run doctor");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Hook location"),
"expected a 'Hook location' doctor check; got: {stdout}"
);
assert!(
stdout.contains("core.hooksPath"),
"warning should mention core.hooksPath; got: {stdout}"
);
assert!(
stdout.contains("global"),
"warning should name the config scope; got: {stdout}"
);
}
#[test]
fn test_doctor_hook_location_passes_when_hooks_path_default() {
let dir = tempdir();
git_init(&dir);
std::fs::write(dir.join("pretender.toml"), "[pretender]\n").expect("write config");
write_pretender_hook(&dir);
let output = doctor_in(&dir, &["--human"]).output().expect("run doctor");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("✓ Hook location"),
"hook-location check should pass with default hooksPath; got: {stdout}"
);
}