use serde_json::Value;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_cargo-impact")
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
fn seed_repo(initial: &[(&str, &str)], modifications: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "t@t"]);
git(root, &["config", "user.name", "t"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "core.autocrlf", "false"]);
git(root, &["checkout", "-q", "-B", "main"]);
for (rel, body) in initial {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, body).unwrap();
}
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", "init"]);
for (rel, body) in modifications {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, body).unwrap();
}
dir
}
fn run_impact(root: &Path, extra_args: &[&str]) -> (String, i32) {
let mut cmd = Command::new(binary());
cmd.arg("--manifest-dir").arg(root);
for a in extra_args {
cmd.arg(a);
}
let output = cmd.output().expect("spawn cargo-impact");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output.status.code().unwrap_or(-1);
if code != 0 && code != 1 {
eprintln!(
"cargo-impact exited with code {code}\n\
args: --manifest-dir <tmp> {}\n\
stderr:\n{stderr}",
extra_args.join(" ")
);
}
(stdout, code)
}
fn manifest() -> &'static str {
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\npath = \"src/lib.rs\"\n"
}
#[test]
fn clean_workspace_reports_no_findings_and_exits_zero() {
let body = "pub fn untouched() {}\n";
let dir = seed_repo(
&[("Cargo.toml", manifest()), ("src/lib.rs", body)],
&[],
);
let (stdout, code) = run_impact(dir.path(), &["--format", "json"]);
assert_eq!(code, 0, "clean workspace exit code; stdout:\n{stdout}");
let report: Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(report["summary"]["total"], 0);
assert!(report["findings"].as_array().unwrap().is_empty());
}
#[test]
fn clean_workspace_context_mode_emits_empty_file_list() {
let body = "pub fn untouched() {}\n";
let dir = seed_repo(
&[("Cargo.toml", manifest()), ("src/lib.rs", body)],
&[],
);
let (stdout, code) = run_impact(dir.path(), &["--context"]);
assert_eq!(code, 0, "clean --context exit code; stdout:\n{stdout}");
assert_eq!(
stdout, "",
"--context must be a pure file-list stream; clean trees should not emit prose"
);
}
#[test]
fn trait_signature_change_emits_high_severity_findings() {
let dir = seed_repo(
&[
("Cargo.toml", manifest()),
(
"src/lib.rs",
"pub trait Greeter { fn hi(&self) -> u32; }\n\
pub struct Friend;\n\
impl Greeter for Friend { fn hi(&self) -> u32 { 1 } }\n\
\n\
#[cfg(test)]\n\
mod tests {\n\
use super::*;\n\
#[test] fn greets() { let _ = Friend.hi(); }\n\
}\n",
),
],
&[(
"src/lib.rs",
"pub trait Greeter { fn hi(&self) -> String; }\n\
pub struct Friend;\n\
impl Greeter for Friend { fn hi(&self) -> String { String::new() } }\n\
\n\
#[cfg(test)]\n\
mod tests {\n\
use super::*;\n\
#[test] fn greets() { let _ = Friend.hi(); }\n\
}\n",
)],
);
let (stdout, code) = run_impact(dir.path(), &["--format", "json"]);
assert_eq!(code, 0, "no --fail-on; stdout:\n{stdout}");
let report: Value = serde_json::from_str(&stdout).expect("parse JSON");
let findings = report["findings"].as_array().expect("findings array");
assert!(
!findings.is_empty(),
"expected findings for a trait signature change; got none"
);
let kinds: Vec<&str> = findings.iter().filter_map(|f| f["kind"].as_str()).collect();
assert!(
kinds.contains(&"trait_definition_change"),
"expected trait_definition_change finding; kinds = {kinds:?}"
);
assert!(
kinds.contains(&"trait_impl"),
"expected trait_impl finding; kinds = {kinds:?}"
);
assert!(report["summary"]["by_severity"]["high"].as_u64().unwrap() >= 1);
}
#[test]
fn fail_on_high_exits_nonzero_when_high_severity_finding_present() {
let dir = seed_repo(
&[
("Cargo.toml", manifest()),
(
"src/lib.rs",
"pub trait Greeter { fn hi(&self); }\n\
pub struct F;\n\
impl Greeter for F { fn hi(&self) {} }\n",
),
],
&[(
"src/lib.rs",
"pub trait Greeter { fn hi(&self, n: u32); }\n\
pub struct F;\n\
impl Greeter for F { fn hi(&self, n: u32) { let _ = n; } }\n",
)],
);
let (_stdout, code) = run_impact(dir.path(), &["--format", "json", "--fail-on", "high"]);
assert_eq!(code, 1, "--fail-on high should trip on trait sig change");
}
#[test]
fn derive_of_changed_trait_is_flagged() {
let dir = seed_repo(
&[
("Cargo.toml", manifest()),
(
"src/lib.rs",
"pub trait Bundle {}\n\
pub struct User;\n",
),
],
&[(
"src/lib.rs",
"pub trait Bundle { fn count(&self) -> u32; }\n\
#[derive(Bundle)]\n\
pub struct User;\n",
)],
);
let (stdout, _code) = run_impact(dir.path(), &["--format", "json"]);
let report: Value = serde_json::from_str(&stdout).expect("parse JSON");
let findings = report["findings"].as_array().unwrap();
let kinds: Vec<&str> = findings.iter().filter_map(|f| f["kind"].as_str()).collect();
assert!(
kinds.contains(&"derived_trait_impl"),
"expected derived_trait_impl finding; kinds = {kinds:?}"
);
}
#[test]
fn test_flag_emits_nextest_filter_expression() {
let dir = seed_repo(
&[
("Cargo.toml", manifest()),
(
"src/lib.rs",
"pub fn engine() -> u32 { 0 }\n\
#[cfg(test)] mod tests {\n\
use super::*;\n\
#[test] fn uses_engine() { let _ = engine(); }\n\
}\n",
),
],
&[(
"src/lib.rs",
"pub fn engine() -> u32 { 1 }\n\
#[cfg(test)] mod tests {\n\
use super::*;\n\
#[test] fn uses_engine() { let _ = engine(); }\n\
}\n",
)],
);
let (stdout, code) = run_impact(dir.path(), &["--test"]);
assert_eq!(code, 0);
assert!(
stdout.contains("test(uses_engine)"),
"expected nextest filter to include the affected test; got {stdout:?}"
);
}
#[test]
fn mcp_version_tool_responds_to_tools_call_over_stdio() {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(binary())
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn mcp server");
let stdin = child.stdin.as_mut().expect("stdin handle");
let req = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"impact_version","arguments":{}}}"#;
writeln!(stdin, "{req}").expect("write");
drop(child.stdin.take());
let out = child.wait_with_output().expect("wait");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"mcp server exited non-zero ({:?})\nstdout:\n{stdout}\nstderr:\n{stderr}",
out.status.code()
);
let line = stdout
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or_else(|| {
panic!("no response on mcp stdout\nstdout:\n{stdout}\nstderr:\n{stderr}")
});
let resp: Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("parse response `{line}`: {e}\nstderr:\n{stderr}"));
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], 1);
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
!text.is_empty() && text.chars().any(|c| c.is_ascii_digit()),
"expected a version string, got {text:?}"
);
}
#[cfg(unix)]
#[test]
fn macro_expand_flag_is_graceful_when_tool_is_unavailable() {
let dir = seed_repo(
&[
("Cargo.toml", manifest()),
("src/lib.rs", "pub trait T { fn hi(&self); }\n"),
],
&[("src/lib.rs", "pub trait T { fn hi(&self) -> String; }\n")],
);
let slim_path = "/usr/bin:/bin";
let mut cmd = Command::new(binary());
cmd.arg("--manifest-dir")
.arg(dir.path())
.arg("--macro-expand")
.arg("--format")
.arg("json")
.env("PATH", slim_path);
let out = cmd.output().expect("spawn cargo-impact");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"--macro-expand caused non-zero exit unexpectedly. stdout:\n{stdout}\nstderr:\n{stderr}"
);
let _: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"invalid JSON output with --macro-expand. stdout:\n{stdout}\nstderr:\n{stderr}\nerr: {e}"
)
});
assert!(
stderr.contains("cargo-expand") || stderr.contains("macro-expand"),
"expected stderr to mention cargo-expand when the tool is unavailable. stderr:\n{stderr}"
);
}
#[test]
fn feature_powerset_surfaces_findings_hidden_under_default_features() {
let cargo_toml = "[package]\nname=\"fixture\"\nversion=\"0.1.0\"\nedition=\"2021\"\n\
[features]\ndefault = []\nextra = []\n\
[lib]\npath=\"src/lib.rs\"\n";
let initial_src = "pub trait Greeter { fn hi(&self) -> u32; }\n\
pub struct Always;\n\
impl Greeter for Always { fn hi(&self) -> u32 { 1 } }\n\
#[cfg(feature = \"extra\")]\n\
pub struct Gated;\n\
#[cfg(feature = \"extra\")]\n\
impl Greeter for Gated { fn hi(&self) -> u32 { 2 } }\n";
let changed_src = "pub trait Greeter { fn hi(&self) -> String; }\n\
pub struct Always;\n\
impl Greeter for Always { fn hi(&self) -> String { String::new() } }\n\
#[cfg(feature = \"extra\")]\n\
pub struct Gated;\n\
#[cfg(feature = \"extra\")]\n\
impl Greeter for Gated { fn hi(&self) -> String { String::new() } }\n";
let dir = seed_repo(
&[("Cargo.toml", cargo_toml), ("src/lib.rs", initial_src)],
&[("src/lib.rs", changed_src)],
);
let (baseline_out, baseline_code) = run_impact(dir.path(), &["--format", "json"]);
assert_eq!(baseline_code, 0, "baseline should exit 0: {baseline_out}");
let baseline: Value = serde_json::from_str(&baseline_out).expect("parse baseline json");
let baseline_evidence: Vec<String> = baseline["findings"]
.as_array()
.unwrap()
.iter()
.filter_map(|f| f["evidence"].as_str().map(str::to_string))
.collect();
assert!(
!baseline_evidence.iter().any(|e| e.contains("Gated")),
"baseline view must NOT mention `Gated` โ it's behind a feature gate. Got: {baseline_evidence:?}"
);
let (powerset_out, powerset_code) =
run_impact(dir.path(), &["--format", "json", "--feature-powerset"]);
assert_eq!(powerset_code, 0, "powerset should exit 0: {powerset_out}");
let powerset: Value = serde_json::from_str(&powerset_out).expect("parse powerset json");
let powerset_evidence: Vec<String> = powerset["findings"]
.as_array()
.unwrap()
.iter()
.filter_map(|f| f["evidence"].as_str().map(str::to_string))
.collect();
let gated_hit = powerset_evidence
.iter()
.find(|e| e.contains("Gated"))
.unwrap_or_else(|| {
panic!(
"powerset view must surface the `Gated` impl. Got evidence: {powerset_evidence:?}"
)
});
assert!(
gated_hit.contains("--all-features"),
"feature-revealed finding must be annotated with the set that revealed it. Got: {gated_hit}"
);
}
#[test]
fn mcp_impact_analyze_streams_progress_notifications_before_result() {
use std::io::Write;
use std::process::Stdio;
let dir = seed_repo(
&[
("Cargo.toml", manifest()),
("src/lib.rs", "pub trait T { fn hi(&self); }\n"),
],
&[("src/lib.rs", "pub trait T { fn hi(&self) -> String; }\n")],
);
let mut child = Command::new(binary())
.arg("mcp")
.current_dir(dir.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn mcp server");
let stdin = child.stdin.as_mut().expect("stdin");
let manifest_dir = dir.path().to_string_lossy().replace('\\', "/");
let req = format!(
r#"{{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{{"name":"impact_analyze","arguments":{{"manifest_dir":"{manifest_dir}"}}}}}}"#
);
writeln!(stdin, "{req}").expect("write");
drop(child.stdin.take());
let out = child.wait_with_output().expect("wait");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"mcp server exited non-zero ({:?})\nstdout:\n{stdout}\nstderr:\n{stderr}",
out.status.code()
);
let mut notifications = Vec::new();
let mut result: Option<Value> = None;
for line in stdout.lines().filter(|l| !l.trim().is_empty()) {
let v: Value = serde_json::from_str(line).unwrap_or_else(|e| {
panic!("parse line `{line}`: {e}\nfull stdout:\n{stdout}\nstderr:\n{stderr}")
});
if v["method"] == "notifications/message" {
notifications.push(v);
} else if v["id"] == 7 {
result = Some(v);
}
}
assert!(
!notifications.is_empty(),
"expected at least one progress notification; full stdout:\n{stdout}"
);
let first = ¬ifications[0];
assert_eq!(first["params"]["level"], "info");
assert!(
first["params"]["data"]["stage"].is_string(),
"notification data must carry a stage string; got {first}"
);
let has_done = notifications
.iter()
.any(|n| n["params"]["data"]["stage"] == "done");
assert!(
has_done,
"expected a `done` progress event; stages seen: {:?}",
notifications
.iter()
.map(|n| n["params"]["data"]["stage"].as_str().unwrap_or(""))
.collect::<Vec<_>>()
);
let result = result.expect("no result envelope on stdout");
assert!(result["result"]["content"][0]["text"].is_string());
}
#[test]
fn json_output_schema_is_stable() {
let dir = seed_repo(
&[
("Cargo.toml", manifest()),
("src/lib.rs", "pub fn a() {}\n"),
],
&[("src/lib.rs", "pub fn a() { let _ = 1; }\n")],
);
let (stdout, _code) = run_impact(dir.path(), &["--format", "json"]);
let report: Value = serde_json::from_str(&stdout).expect("parse JSON");
for field in [
"version",
"changed_files",
"candidate_symbols",
"findings",
"summary",
] {
assert!(
!report[field].is_null(),
"JSON envelope missing required field `{field}`; got:\n{stdout}"
);
}
for field in ["total", "by_severity", "by_tier"] {
assert!(
!report["summary"][field].is_null(),
"summary missing `{field}`"
);
}
}
#[test]
fn output_is_byte_identical_across_two_runs_for_every_format() {
let dir = seed_repo(
&[
("Cargo.toml", manifest()),
(
"src/lib.rs",
"pub trait Greeter { fn hi(&self); }\n\
pub struct F;\n\
impl Greeter for F { fn hi(&self) {} }\n\
#[cfg(test)] mod tests {\n\
use super::*;\n\
#[test] fn smoke() { F.hi(); }\n\
}\n",
),
],
&[(
"src/lib.rs",
"pub trait Greeter { fn hi(&self, n: u32); }\n\
pub struct F;\n\
impl Greeter for F { fn hi(&self, n: u32) { let _ = n; } }\n\
#[cfg(test)] mod tests {\n\
use super::*;\n\
#[test] fn smoke() { F.hi(1); }\n\
}\n",
)],
);
for args in [
vec!["--format", "text"],
vec!["--format", "markdown"],
vec!["--format", "json"],
vec!["--format", "sarif"],
vec!["--format", "pr-comment"],
vec!["--test"],
] {
let (first, code_a) = run_impact(dir.path(), &args);
let (second, code_b) = run_impact(dir.path(), &args);
assert_eq!(code_a, code_b, "exit codes diverged for {args:?}");
assert_eq!(
first, second,
"stdout diverged for {args:?}\n--- first ---\n{first}\n--- second ---\n{second}"
);
}
}