use crate::finding::{Finding, FindingKind, Tier};
use anyhow::{Context, Result};
use std::path::Path;
use std::process::{Command, Output};
const TOOL_BIN: &str = "cargo-semver-checks";
pub fn run(root: &Path, since: &str, enabled: bool) -> Result<Vec<Finding>> {
if !enabled {
return Ok(Vec::new());
}
if !is_installed() {
eprintln!(
"cargo-impact: --semver-checks requested but `{TOOL_BIN}` not found on PATH. \
Install it with `cargo install cargo-semver-checks`; skipping."
);
return Ok(Vec::new());
}
let output = match invoke(root, since) {
Ok(o) => o,
Err(e) => {
eprintln!("cargo-impact: cargo-semver-checks failed to start: {e:#}");
return Ok(Vec::new());
}
};
Ok(parse_output(output.status.success(), &combined(&output)))
}
pub fn parse_output(success: bool, combined: &str) -> Vec<Finding> {
if success {
return Vec::new();
}
let details = combined.trim().to_string();
let evidence = first_failing_lint(&details).map_or_else(
|| "cargo-semver-checks reports breaking public-API changes".to_string(),
|lint| format!("cargo-semver-checks: {lint}"),
);
let kind = FindingKind::SemverCheck {
level: "breaking".to_string(),
details,
};
vec![
Finding::new("", Tier::Likely, 0.95, kind, evidence).with_suggested_action(
"cargo semver-checks check-release # for full detail".to_string(),
),
]
}
pub fn is_installed() -> bool {
which(TOOL_BIN).is_some()
}
fn which(name: &str) -> Option<std::path::PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
if let Some(pathext) = std::env::var_os("PATHEXT") {
for ext in std::env::split_paths(&pathext) {
let with_ext =
candidate.with_extension(ext.to_string_lossy().trim_start_matches('.'));
if with_ext.is_file() {
return Some(with_ext);
}
}
}
}
None
}
fn invoke(root: &Path, since: &str) -> Result<Output> {
Command::new("cargo")
.arg("semver-checks")
.arg("check-release")
.arg("--baseline-rev")
.arg(since)
.current_dir(root)
.output()
.context("spawning cargo semver-checks")
}
fn combined(output: &Output) -> String {
let mut s = String::new();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.is_empty() {
s.push_str(&stdout);
}
if !stdout.is_empty() && !stderr.is_empty() {
s.push('\n');
}
if !stderr.is_empty() {
s.push_str(&stderr);
}
s
}
fn first_failing_lint(combined: &str) -> Option<String> {
for line in combined.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("FAIL ") {
return Some(rest.trim().to_string());
}
if let Some(rest) = trimmed.strip_prefix("--- failure ") {
return Some(rest.trim_end_matches(": ---").trim().to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::SeverityClass;
#[test]
fn success_produces_no_findings() {
let findings = parse_output(true, "whatever was on stdout");
assert!(findings.is_empty());
}
#[test]
fn failure_yields_single_high_finding() {
let findings = parse_output(false, "some scary looking cargo output");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, SeverityClass::High);
assert_eq!(findings[0].tier, Tier::Likely);
assert_eq!(findings[0].confidence, 0.95);
match &findings[0].kind {
FindingKind::SemverCheck { level, details } => {
assert_eq!(level, "breaking");
assert!(details.contains("cargo output"));
}
other => panic!("expected SemverCheck, got {other:?}"),
}
}
#[test]
fn first_failing_lint_extracted_as_evidence_when_present() {
let output = "\
Building baseline … done
FAIL function_parameter_count_changed: `foo` now takes 3 params (was 2)
at src/lib.rs:42
Other status noise
";
let findings = parse_output(false, output);
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.evidence
.contains("function_parameter_count_changed"),
"evidence should surface the first failing lint; got {:?}",
findings[0].evidence
);
}
#[test]
fn handles_alternative_failure_prefix_format() {
let output = "--- failure enum_variant_added: ---\n body details";
let findings = parse_output(false, output);
assert_eq!(findings.len(), 1);
assert!(findings[0].evidence.contains("enum_variant_added"));
}
#[test]
fn suggested_action_points_users_at_raw_tool() {
let findings = parse_output(false, "FAIL anything");
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.suggested_action
.as_deref()
.is_some_and(|s| s.contains("cargo semver-checks"))
);
}
#[test]
fn run_returns_empty_when_disabled_regardless_of_tool_presence() {
let findings = run(Path::new("."), "HEAD", false).unwrap();
assert!(findings.is_empty());
}
#[test]
fn which_finds_a_ubiquitous_binary() {
assert!(which("git").is_some());
assert!(which("this-binary-does-not-exist-i-promise-xyz").is_none());
}
}