#![cfg_attr(coverage_nightly, coverage(off))]
use anyhow::Result;
use serde::Serialize;
use std::path::{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)]
enum StageResult {
Ran {
ok: bool,
violations: Vec<Violation>,
detail: Option<String>,
},
NotApplicable(String),
}
impl StageResult {
fn pass() -> Self {
Self::Ran {
ok: true,
violations: Vec::new(),
detail: None,
}
}
fn ran(ok: bool, violations: Vec<Violation>, detail: Option<String>) -> Self {
Self::Ran {
ok,
violations,
detail,
}
}
fn not_applicable(reason: impl Into<String>) -> Self {
Self::NotApplicable(reason.into())
}
}
#[derive(Debug, Serialize)]
struct StageReport {
name: &'static str,
ok: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
skipped: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
not_applicable: Option<String>,
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,
stages_measured: usize,
#[serde(skip_serializing_if = "Option::is_none")]
not_measured: Option<String>,
duration_ms: u64,
stages: Vec<StageReport>,
}
pub async fn handle_verify(args: VerifyArgs) -> Result<()> {
let overall = Instant::now();
let selected = select_stages(args.stage.as_deref(), &args.skip)?;
let mut stages = Vec::new();
let mut failed = false;
let mut measured = 0usize;
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 duration_ms = |start: Instant| start.elapsed().as_millis() as u64;
match run_stage(name, &args) {
StageResult::Ran {
ok,
violations,
detail,
} => {
measured += 1;
failed |= !ok;
stages.push(StageReport {
name,
ok: Some(ok),
skipped: None,
not_applicable: None,
duration_ms: duration_ms(start),
violations,
detail: if ok { None } else { detail },
});
}
StageResult::NotApplicable(reason) => stages.push(StageReport {
name,
ok: None,
skipped: None,
not_applicable: Some(reason),
duration_ms: duration_ms(start),
violations: Vec::new(),
detail: None,
}),
}
}
let not_measured = (measured == 0).then(|| {
"no selected stage could measure anything here, so verify has no verdict to give"
.to_string()
});
let report = VerifyReport {
ok: !failed && measured > 0,
stages_measured: measured,
not_measured,
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 !report.ok {
std::process::exit(1);
}
Ok(())
}
fn select_stages(stage: Option<&str>, skip: &[String]) -> Result<Vec<&'static str>> {
match stage {
Some(s) => match STAGES.iter().find(|k| **k == s) {
Some(&known) => Ok(vec![known]),
None => anyhow::bail!("unknown stage `{s}`; valid stages: {}", STAGES.join(",")),
},
None => Ok(STAGES
.iter()
.copied()
.filter(|s| !skip.iter().any(|k| k == s))
.collect()),
}
}
fn skipped(name: &'static str, why: &'static str) -> StageReport {
StageReport {
name,
ok: None,
skipped: Some(why),
not_applicable: None,
duration_ms: 0,
violations: Vec::new(),
detail: None,
}
}
fn run_stage(name: &str, args: &VerifyArgs) -> StageResult {
match name {
"format" => stage_format(args),
"complexity" => stage_complexity(),
"satd" => stage_satd(),
"clippy" => stage_clippy(args),
"tests" => stage_tests(),
other => StageResult::ran(
false,
Vec::new(),
Some(format!("no such verify stage: `{other}`")),
),
}
}
fn in_cargo_project(dir: &Path) -> bool {
dir.canonicalize()
.as_deref()
.unwrap_or(dir)
.ancestors()
.any(|a| a.join("Cargo.toml").is_file())
}
const SOURCE_EXTENSIONS: &[&str] = &[
"rs", "py", "ts", "tsx", "js", "jsx", "go", "c", "h", "cc", "cpp", "hpp", "java", "kt", "rb",
"php", "swift", "sh", "bash", "lua", "sql", "scala",
];
fn any_source_file(dir: &Path, wanted: &[&str]) -> bool {
ignore::WalkBuilder::new(dir)
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build()
.filter_map(std::result::Result::ok)
.any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| wanted.contains(&x))
})
}
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")))
}
pub(crate) fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
match chars.next() {
Some('[') => {
for f in chars.by_ref() {
if ('\x40'..='\x7e').contains(&f) {
break;
}
}
}
Some(']') => {
while let Some(f) = chars.next() {
if f == '\x07' {
break;
}
if f == '\x1b' {
let _ = chars.next();
break;
}
}
}
Some(_) | None => {}
}
}
out
}
fn run(cmd: &mut Command) -> (bool, String) {
cmd.env("CARGO_TERM_COLOR", "never");
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(), strip_ansi(&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) -> StageResult {
if !in_cargo_project(Path::new(".")) {
return StageResult::not_applicable(
"cargo fmt needs a Cargo project; no Cargo.toml in this directory or any parent",
);
}
if args.fix {
let _ = run(cargo().args(["fmt", "--all"]));
return StageResult::pass();
}
let (ok, out) = run(cargo().args(["fmt", "--all", "--", "--check"]));
StageResult::ran(ok, Vec::new(), format_detail(&out))
}
fn format_detail(output: &str) -> Option<String> {
first_error(output).or_else(|| tail(output, 20))
}
fn stage_complexity() -> StageResult {
let files = changed_rust_files();
if files.is_empty() {
return if any_source_file(Path::new("."), &["rs"]) {
StageResult::not_applicable("no Rust files changed vs HEAD, so nothing was measured")
} else {
StageResult::not_applicable("no Rust source files here, so nothing was measured")
};
}
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);
StageResult::ran(ok, Vec::new(), tail(&out, 25))
}
fn stage_satd() -> StageResult {
if !any_source_file(Path::new("."), SOURCE_EXTENSIONS) {
return StageResult::not_applicable("no source files here, so nothing was measured");
}
let (_, out) = run(pmat_self().args([
"analyze",
"satd",
"--strict",
"--format",
"json",
"--fail-on-violation",
]));
let (ok, detail) = satd_verdict(&out);
StageResult::ran(ok, Vec::new(), detail)
}
fn satd_verdict(output: &str) -> (bool, Option<String>) {
match parse_satd_violation_count(output) {
Some(0) => (true, None),
Some(n) => (
false,
Some(format!(
"{n} strict-mode SATD violation(s) (TODO/FIXME/HACK/BUG)\n{}",
tail(output, 25).unwrap_or_default()
)),
),
None => (
false,
first_error(output)
.or_else(|| tail(output, 25))
.or_else(|| Some("analyze satd produced no violation count".to_string())),
),
}
}
fn parse_satd_violation_count(output: &str) -> Option<u64> {
const KEY: &str = "\"total_violations\"";
let after = output.lines().find_map(|l| l.split_once(KEY))?.1;
let digits: String = after
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(char::is_ascii_digit)
.collect();
digits.parse().ok()
}
fn stage_tests() -> StageResult {
if !in_cargo_project(Path::new(".")) {
return StageResult::not_applicable(
"cargo test needs a Cargo project; no Cargo.toml in this directory or any parent",
);
}
let (ok, out) = run(cargo()
.args(["test", "--lib"])
.env("RUST_MIN_STACK", "8388608"));
StageResult::ran(ok, Vec::new(), tail(&out, 30))
}
const CLIPPY_TARGETS: &str = "--all-targets";
const CLIPPY_LINTS: &[&str] = &["-D", "warnings", "-A", "unused-variables"];
fn stage_clippy(args: &VerifyArgs) -> StageResult {
if !in_cargo_project(Path::new(".")) {
return StageResult::not_applicable(
"cargo clippy needs a Cargo project; no Cargo.toml in this directory or any parent",
);
}
if args.fix {
let mut fix = cargo();
fix.args([
"clippy",
CLIPPY_TARGETS,
"--fix",
"--allow-dirty",
"--allow-staged",
"--",
])
.args(CLIPPY_LINTS);
let _ = run(&mut fix);
}
let mut check = cargo();
check
.args(["clippy", CLIPPY_TARGETS, "--message-format=json", "--"])
.args(CLIPPY_LINTS);
let (ok, out) = run(&mut check);
let violations = parse_clippy_violations(&out);
let detail = if violations.is_empty() {
first_error(&out).or_else(|| tail(&out, 10))
} else {
None
};
StageResult::ran(ok, violations, detail)
}
fn first_error(output: &str) -> Option<String> {
output
.lines()
.map(str::trim)
.find(|l| l.starts_with("error"))
.map(str::to_string)
}
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();
let level = msg
.get("level")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let text = msg
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let is_abort_summary = text.starts_with("aborting due to");
let keep = rule.starts_with("clippy::") || (level == "error" && !is_abort_summary);
if !keep {
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: if rule.is_empty() {
"rustc".to_string()
} else {
rule.to_string()
},
message: text.to_string(),
});
}
out
}
fn changed_rust_files() -> Vec<String> {
changed_rust_files_in(Path::new("."))
}
fn changed_rust_files_in(dir: &Path) -> Vec<String> {
const SOURCES: [&[&str]; 3] = [
&["diff", "--name-only", "HEAD"],
&["diff", "--name-only", "--cached"],
&["ls-files", "--others", "--exclude-standard"],
];
let mut files: Vec<String> = Vec::new();
for args in SOURCES {
let (ok, out) = run(Command::new("git").args(args).current_dir(dir));
if !ok {
continue;
}
for line in out.lines().filter(|l| l.ends_with(".rs")) {
if !files.iter().any(|f| f == line) {
files.push(line.to_string());
}
}
}
files
}
fn print_text(report: &VerifyReport) {
use crate::cli::colors as c;
let on = c::colors_enabled();
let sgr = |s: c::Sgr| if on { s.raw() } else { "" };
let (green, red, dim, reset) = (sgr(c::GREEN), sgr(c::RED), sgr(c::DIM), sgr(c::RESET));
for s in &report.stages {
let status = match (s.ok, s.not_applicable.is_some()) {
(Some(true), _) => format!("{green}✓ pass{reset}"),
(Some(false), _) => format!("{red}✗ FAIL{reset}"),
(None, true) => format!("{dim}~ n/a {reset}"),
(None, false) => format!("{dim}- skip{reset}"),
};
println!(" {status} {:<11} {}ms", s.name, s.duration_ms);
if let Some(reason) = &s.not_applicable {
println!(" {dim}{reason}{reset}");
}
for v in &s.violations {
println!(
" {red}{}{reset} {}:{} {}",
v.rule, v.file, v.line, v.message
);
}
if let Some(d) = &s.detail {
for l in d.lines() {
println!(" {dim}{l}{reset}");
}
}
}
if let Some(reason) = &report.not_measured {
println!(
"\n{red}✗ verify measured nothing{reset} ({}ms) — {reason}",
report.duration_ms
);
} else if report.ok {
println!(
"\n{green}✓ verify passed{reset} ({}ms) — safe to commit",
report.duration_ms
);
} else {
println!(
"\n{red}✗ verify failed{reset} ({}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_parse_clippy_violations_keeps_rustc_errors() {
let stream = r#"{"reason":"compiler-message","message":{"level":"error","code":{"code":"E0063"},"message":"missing field `tdg_measured` in initializer of `EvidenceSummary`","spans":[{"file_name":"src/services/evidence.rs","line_start":412,"is_primary":true}]}}
{"reason":"compiler-message","message":{"level":"error","code":null,"message":"aborting due to 1 previous error","spans":[]}}
{"reason":"compiler-message","message":{"level":"error","code":null,"message":"could not compile `cc` (lib) due to 11 previous errors","spans":[]}}
{"reason":"build-finished","success":false}"#;
let v = parse_clippy_violations(stream);
assert_eq!(v.len(), 2, "got {v:?}");
assert_eq!(v[0].rule, "E0063");
assert_eq!(v[0].file, "src/services/evidence.rs");
assert_eq!(v[0].line, 412);
assert!(v[0].message.contains("tdg_measured"));
assert_eq!(v[1].rule, "rustc");
assert!(v[1].message.contains("could not compile `cc`"));
assert!(!v.iter().any(|x| x.message.starts_with("aborting due to")));
}
#[test]
fn test_parse_clippy_violations_drops_rustc_warnings() {
let stream = r#"{"reason":"compiler-message","message":{"level":"warning","code":{"code":"unused_imports"},"message":"unused import: `std::fmt`","spans":[{"file_name":"src/a.rs","line_start":3,"is_primary":true}]}}"#;
assert!(parse_clippy_violations(stream).is_empty());
}
#[test]
fn test_tail() {
assert_eq!(tail("a\n\nb\nc", 2).as_deref(), Some("b\nc"));
assert_eq!(tail("", 5), None);
}
#[test]
fn test_first_error_prefers_the_cause_over_progress_noise() {
let out = " Compiling pmat v3.29.1\n\
error: no such command: `clippy`\n\
\n\
Did you mean `check`?\n\
Compiling serde v1.0.0\n\
warning: build failed, waiting for other jobs to finish";
assert_eq!(
first_error(out).as_deref(),
Some("error: no such command: `clippy`")
);
assert!(tail(out, 10).unwrap().contains("waiting for other jobs"));
}
#[test]
fn test_format_detail_keeps_the_cargo_error_over_the_usage_screen() {
let out = "error: could not find `Cargo.toml` in `/tmp/empty` or any parent directory\n\
Usage: cargo fmt [OPTIONS] [-- <rustfmt_options>...]\n\
Arguments:\n\
\x20 [rustfmt_options]... Options passed to rustfmt\n\
Options:\n\
\x20 -q, --quiet\n\
\x20 -h, --help\n\
\x20 Print help";
let detail = format_detail(out).expect("a failing format stage must say why");
assert!(
detail.contains("could not find `Cargo.toml`"),
"the cause was dropped: {detail}"
);
assert!(
!detail.contains("Print help"),
"the usage screen is not a failure detail: {detail}"
);
}
#[test]
fn test_format_detail_falls_back_to_the_diff_tail() {
let out = "Diff in /x/src/lib.rs at line 3:\n-fn a(){}\n+fn a() {}\n";
let detail = format_detail(out).expect("detail");
assert!(detail.contains("Diff in /x/src/lib.rs"), "{detail}");
}
#[test]
fn test_first_error_none_when_output_has_no_error_line() {
assert_eq!(first_error(" Compiling pmat v3.29.1\n Finished"), None);
assert_eq!(first_error(""), None);
}
#[test]
fn test_select_stages_rejects_an_unknown_stage_name() {
let err = select_stages(Some("nonexistent"), &[])
.unwrap_err()
.to_string();
assert!(err.contains("nonexistent"), "{err}");
for s in STAGES {
assert!(err.contains(s), "{err} is missing {s}");
}
}
#[test]
fn test_select_stages_known_name_and_skip_list() {
assert_eq!(select_stages(Some("satd"), &[]).unwrap(), vec!["satd"]);
let skip = vec!["format".to_string(), "tests".to_string()];
assert_eq!(
select_stages(None, &skip).unwrap(),
vec!["complexity", "satd", "clippy"]
);
}
#[test]
fn test_run_stage_unknown_name_is_not_a_pass() {
let args = VerifyArgs {
format: VerifyFormat::Json,
fix: false,
no_fail_fast: false,
skip: Vec::new(),
stage: None,
};
match run_stage("nonexistent", &args) {
StageResult::Ran { ok, detail, .. } => {
assert!(!ok);
assert!(detail.unwrap_or_default().contains("nonexistent"));
}
other => panic!("a name that is not a stage must not be not-applicable: {other:?}"),
}
}
#[test]
fn test_satd_verdict_fails_on_reported_violations() {
let report = r#"{
"total_files": 1,
"total_violations": 3,
"summary": "Found 3 SATD violations in 1 files"
}"#;
let (ok, detail) = satd_verdict(report);
assert!(!ok, "3 reported violations must fail the stage");
assert!(detail.unwrap_or_default().contains('3'));
assert_eq!(parse_satd_violation_count(report), Some(3));
}
#[test]
fn test_satd_verdict_passes_only_on_a_measured_zero() {
let (ok, _) = satd_verdict("{\n \"total_violations\": 0\n}");
assert!(ok);
let (ok, detail) = satd_verdict("error: no such subcommand: `satd`");
assert!(!ok);
assert!(detail.unwrap_or_default().contains("no such subcommand"));
assert_eq!(parse_satd_violation_count(""), None);
}
#[test]
fn test_changed_rust_files_sees_untracked_files() {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
let git = |args: &[&str]| {
let ok = Command::new("git")
.args(args)
.current_dir(p)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
assert!(ok, "git {args:?} failed");
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "t"]);
std::fs::write(p.join("tracked.rs"), "pub fn a() {}\n").expect("write");
git(&["add", "tracked.rs"]);
git(&["commit", "-qm", "init"]);
std::fs::write(p.join("untracked.rs"), "pub fn b() {}\n").expect("write");
std::fs::write(p.join("notes.md"), "hi\n").expect("write");
std::fs::write(p.join(".gitignore"), "ignored.rs\n").expect("write");
std::fs::write(p.join("ignored.rs"), "pub fn c() {}\n").expect("write");
let files = changed_rust_files_in(p);
assert!(
files.iter().any(|f| f == "untracked.rs"),
"untracked .rs must be gated: {files:?}"
);
assert!(!files.iter().any(|f| f == "ignored.rs"), "{files:?}");
assert!(!files.iter().any(|f| f.ends_with(".md")), "{files:?}");
std::fs::write(p.join("tracked.rs"), "pub fn a() -> u8 { 1 }\n").expect("write");
git(&["add", "untracked.rs", "tracked.rs"]);
let files = changed_rust_files_in(p);
assert_eq!(
files.iter().filter(|f| *f == "untracked.rs").count(),
1,
"{files:?}"
);
assert!(files.iter().any(|f| f == "tracked.rs"), "{files:?}");
}
}
#[cfg(test)]
mod ansi_tests {
use super::*;
#[test]
fn strip_ansi_removes_csi_sequences() {
let coloured = "Diff in \u{1b}[1msrc/lib.rs\u{1b}[0m at line \u{1b}[31m3\u{1b}[0m:";
let plain = strip_ansi(coloured);
assert!(!plain.contains('\u{1b}'), "got: {plain:?}");
assert_eq!(plain, "Diff in src/lib.rs at line 3:");
}
#[test]
fn strip_ansi_removes_osc_and_leaves_plain_text_alone() {
assert_eq!(strip_ansi("\u{1b}]0;title\u{7}body"), "body");
assert_eq!(strip_ansi("no escapes here"), "no escapes here");
assert_eq!(strip_ansi("tail\u{1b}"), "tail");
}
#[test]
fn detail_helpers_carry_no_escapes_once_stripped() {
let raw = "\u{1b}[1mDiff in a.rs\u{1b}[0m\n\u{1b}[31merror: something broke\u{1b}[0m\n";
let cleaned = strip_ansi(raw);
assert_eq!(
first_error(&cleaned).as_deref(),
Some("error: something broke")
);
assert!(!tail(&cleaned, 5).unwrap().contains('\u{1b}'));
}
#[test]
fn cargo_stages_are_not_applicable_outside_a_cargo_project() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(
!in_cargo_project(dir.path()),
"a bare tempdir has no Cargo.toml in any ancestor"
);
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("write");
assert!(in_cargo_project(dir.path()));
assert!(
in_cargo_project(&dir.path().join("src")),
"a subdirectory of a Cargo project is still in it"
);
}
#[test]
fn source_file_detection_distinguishes_empty_from_populated() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(!any_source_file(dir.path(), SOURCE_EXTENSIONS));
assert!(!any_source_file(dir.path(), &["rs"]));
std::fs::write(dir.path().join("main.go"), "package main\n").expect("write");
assert!(
any_source_file(dir.path(), SOURCE_EXTENSIONS),
"a Go file is source the pmat-native stages can read"
);
assert!(
!any_source_file(dir.path(), &["rs"]),
"...but it is not Rust, so the complexity stage still has nothing"
);
std::fs::write(dir.path().join("lib.rs"), "pub fn a() {}\n").expect("write");
assert!(any_source_file(dir.path(), &["rs"]));
}
#[test]
fn a_run_that_measured_nothing_is_not_a_pass() {
let stage = |name: &'static str, ok: Option<bool>, na: Option<&str>| StageReport {
name,
ok,
skipped: None,
not_applicable: na.map(str::to_string),
duration_ms: 0,
violations: Vec::new(),
detail: None,
};
let measured = |stages: Vec<StageReport>| -> (bool, usize) {
let n = stages.iter().filter(|s| s.ok.is_some()).count();
let failed = stages.iter().any(|s| s.ok == Some(false));
(!failed && n > 0, n)
};
let (ok, n) = measured(vec![
stage("complexity", None, Some("no Rust source files here")),
stage("satd", None, Some("no source files here")),
]);
assert_eq!(n, 0);
assert!(!ok, "zero measured stages must not report a pass");
let (ok, n) = measured(vec![
stage("format", Some(true), None),
stage("complexity", None, Some("no Rust files changed vs HEAD")),
]);
assert_eq!(n, 1);
assert!(
ok,
"one real pass alongside a not-applicable stage is green"
);
let (ok, _) = measured(vec![
stage("format", Some(false), None),
stage("complexity", None, Some("no Rust files changed vs HEAD")),
]);
assert!(!ok);
}
}