#![cfg_attr(coverage_nightly, coverage(off))]
use anyhow::Result;
use serde::Serialize;
use std::path::PathBuf;
use std::process::Command;
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum VerifyFormat {
Text,
Json,
}
#[derive(Debug, clap::Args)]
pub struct VerifyArgs {
#[arg(long, value_enum, default_value = "text")]
pub format: VerifyFormat,
#[arg(long)]
pub fix: bool,
#[arg(long)]
pub no_fail_fast: bool,
#[arg(long, value_delimiter = ',')]
pub skip: Vec<String>,
#[arg(long)]
pub stage: Option<String>,
}
const STAGES: &[&str] = &["format", "complexity", "satd", "clippy", "tests"];
#[derive(Debug, Serialize)]
struct Violation {
file: String,
line: u64,
rule: String,
message: String,
}
#[derive(Debug, Serialize)]
struct StageReport {
name: &'static str,
ok: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
skipped: Option<&'static str>,
duration_ms: u64,
#[serde(skip_serializing_if = "Vec::is_empty")]
violations: Vec<Violation>,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
#[derive(Debug, Serialize)]
struct VerifyReport {
ok: bool,
duration_ms: u64,
stages: Vec<StageReport>,
}
pub async fn handle_verify(args: VerifyArgs) -> Result<()> {
let overall = Instant::now();
let selected: Vec<&str> = match args.stage.as_deref() {
Some(s) => vec![s],
None => STAGES
.iter()
.copied()
.filter(|s| !args.skip.iter().any(|k| k == s))
.collect(),
};
let mut stages = Vec::new();
let mut failed = false;
for &name in STAGES {
if !selected.contains(&name) {
stages.push(skipped(name, "not-selected"));
continue;
}
if failed && !args.no_fail_fast {
stages.push(skipped(name, "fail-fast"));
continue;
}
let start = Instant::now();
let (ok, violations, detail) = run_stage(name, &args);
failed |= !ok;
stages.push(StageReport {
name,
ok: Some(ok),
skipped: None,
duration_ms: start.elapsed().as_millis() as u64,
violations,
detail: if ok { None } else { detail },
});
}
let report = VerifyReport {
ok: !failed,
duration_ms: overall.elapsed().as_millis() as u64,
stages,
};
match args.format {
VerifyFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?),
VerifyFormat::Text => print_text(&report),
}
if failed {
std::process::exit(1);
}
Ok(())
}
fn skipped(name: &'static str, why: &'static str) -> StageReport {
StageReport {
name,
ok: None,
skipped: Some(why),
duration_ms: 0,
violations: Vec::new(),
detail: None,
}
}
fn run_stage(name: &str, args: &VerifyArgs) -> (bool, Vec<Violation>, Option<String>) {
match name {
"format" => stage_format(args),
"complexity" => stage_complexity(),
"satd" => stage_satd(),
"clippy" => stage_clippy(args),
"tests" => stage_tests(),
_ => (true, Vec::new(), None),
}
}
fn cargo() -> Command {
Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
}
fn pmat_self() -> Command {
Command::new(std::env::current_exe().unwrap_or_else(|_| PathBuf::from("pmat")))
}
fn run(cmd: &mut Command) -> (bool, String) {
match cmd.output() {
Ok(out) => {
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), s)
}
Err(e) => (false, format!("failed to spawn command: {e}")),
}
}
fn tail(output: &str, n: usize) -> Option<String> {
let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.is_empty() {
return None;
}
let start = lines.len().saturating_sub(n);
Some(lines[start..].join("\n"))
}
fn stage_format(args: &VerifyArgs) -> (bool, Vec<Violation>, Option<String>) {
if args.fix {
let _ = run(cargo().args(["fmt", "--all"]));
return (true, Vec::new(), None);
}
let (ok, out) = run(cargo().args(["fmt", "--all", "--", "--check"]));
(ok, Vec::new(), tail(&out, 20))
}
fn stage_complexity() -> (bool, Vec<Violation>, Option<String>) {
let files = changed_rust_files();
if files.is_empty() {
return (true, Vec::new(), None);
}
let mut cmd = pmat_self();
cmd.args([
"analyze",
"complexity",
"--max-cyclomatic",
"30",
"--max-cognitive",
"25",
"--fail-on-violation",
"--files",
]);
cmd.arg(files.join(","));
let (ok, out) = run(&mut cmd);
(ok, Vec::new(), tail(&out, 25))
}
fn stage_satd() -> (bool, Vec<Violation>, Option<String>) {
let (ok, out) = run(pmat_self().args(["analyze", "satd", "--strict"]));
(ok, Vec::new(), tail(&out, 25))
}
fn stage_tests() -> (bool, Vec<Violation>, Option<String>) {
let (ok, out) = run(cargo()
.args(["test", "--lib"])
.env("RUST_MIN_STACK", "8388608"));
(ok, Vec::new(), tail(&out, 30))
}
fn stage_clippy(args: &VerifyArgs) -> (bool, Vec<Violation>, Option<String>) {
if args.fix {
let _ = run(cargo().args([
"clippy",
"--lib",
"--bins",
"--fix",
"--allow-dirty",
"--allow-staged",
"--",
"-D",
"warnings",
]));
}
let (ok, out) = run(cargo().args([
"clippy",
"--lib",
"--bins",
"--message-format=json",
"--",
"-D",
"warnings",
]));
let violations = parse_clippy_violations(&out);
let detail = if violations.is_empty() {
tail(&out, 10)
} else {
None
};
(ok, violations, detail)
}
fn parse_clippy_violations(json_stream: &str) -> Vec<Violation> {
let mut out = Vec::new();
for line in json_stream.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("reason").and_then(serde_json::Value::as_str) != Some("compiler-message") {
continue;
}
let msg = &v["message"];
let rule = msg
.get("code")
.and_then(|c| c.get("code"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !rule.starts_with("clippy::") {
continue;
}
let span = msg
.get("spans")
.and_then(serde_json::Value::as_array)
.and_then(|a| {
a.iter()
.find(|s| {
s.get("is_primary")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.or_else(|| a.first())
});
let (file, line) = span
.map(|s| {
(
s.get("file_name")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
s.get("line_start")
.and_then(serde_json::Value::as_u64)
.unwrap_or_default(),
)
})
.unwrap_or_default();
out.push(Violation {
file,
line,
rule: rule.to_string(),
message: msg
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
});
}
out
}
fn changed_rust_files() -> Vec<String> {
let (ok, out) = run(Command::new("git").args(["diff", "--name-only", "HEAD"]));
if !ok {
return Vec::new();
}
out.lines()
.filter(|l| l.ends_with(".rs"))
.map(str::to_string)
.collect()
}
fn print_text(report: &VerifyReport) {
for s in &report.stages {
let status = match s.ok {
Some(true) => "\x1b[32m✓ pass\x1b[0m",
Some(false) => "\x1b[31m✗ FAIL\x1b[0m",
None => "\x1b[2m- skip\x1b[0m",
};
println!(" {status} {:<11} {}ms", s.name, s.duration_ms);
for v in &s.violations {
println!(
" \x1b[31m{}\x1b[0m {}:{} {}",
v.rule, v.file, v.line, v.message
);
}
if let Some(d) = &s.detail {
for l in d.lines() {
println!(" \x1b[2m{l}\x1b[0m");
}
}
}
if report.ok {
println!(
"\n\x1b[32m✓ verify passed\x1b[0m ({}ms) — safe to commit",
report.duration_ms
);
} else {
println!(
"\n\x1b[31m✗ verify failed\x1b[0m ({}ms) — fix before committing",
report.duration_ms
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_clippy_violations() {
let stream = r#"{"reason":"compiler-message","message":{"level":"error","code":{"code":"clippy::nonminimal_bool"},"message":"this boolean expression can be simplified","spans":[{"file_name":"src/x.rs","line_start":230,"is_primary":true}]}}
{"reason":"compiler-artifact","package_id":"x"}
{"reason":"compiler-message","message":{"level":"warning","code":{"code":"dead_code"},"message":"never used","spans":[{"file_name":"src/y.rs","line_start":5,"is_primary":true}]}}"#;
let v = parse_clippy_violations(stream);
assert_eq!(v.len(), 1);
assert_eq!(v[0].rule, "clippy::nonminimal_bool");
assert_eq!(v[0].file, "src/x.rs");
assert_eq!(v[0].line, 230);
}
#[test]
fn test_tail() {
assert_eq!(tail("a\n\nb\nc", 2).as_deref(), Some("b\nc"));
assert_eq!(tail("", 5), None);
}
}