#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::path::Path;
use std::process::{Command, Output};
use git2::Repository;
use tempfile::TempDir;
fn repo() -> (TempDir, Repository) {
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init(dir.path()).unwrap();
(dir, repo)
}
fn write(repo: &Repository, path: &str, source: &str, track: bool) {
let root = repo.workdir().unwrap();
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, source).unwrap();
if track {
let mut index = repo.index().unwrap();
index.add_path(Path::new(path)).unwrap();
index.write().unwrap();
}
}
fn lint(root: &Path, paths: &[&str]) -> Output {
lint_with(root, &[], paths)
}
fn lint_with(root: &Path, flags: &[&str], paths: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_patchcov"))
.env_remove("PATCHCOV_CONFIG_DIR")
.env_remove("PATCHCOV_ERROR_FORMAT")
.args(["lint-markers", "-C"])
.arg(root)
.args(flags)
.args(paths)
.output()
.unwrap()
}
fn err_text(output: &Output) -> String {
String::from_utf8(output.stderr.clone()).unwrap()
}
fn bad() -> String {
marker("ignore reason=\"unclosed\"")
}
fn config_include(repo: &Repository, globs: &[&str]) {
let mut body = String::from("lint-markers:\n include:\n");
for glob in globs {
body.push_str(&format!(" - '{glob}'\n"));
}
write(repo, ".patchcov/config.yaml", &body, false);
}
fn marker(rest: &str) -> String {
format!("// {} {rest}\n", patchcov::markers::INTRODUCER)
}
#[test]
fn default_scans_tracked_worktree_files_only() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "src/clean.rs", "fn clean() {}\n", true);
write(
&repo,
"src/untracked.rs",
&marker("ignore reason=\"unclosed\""),
false,
);
assert!(lint(root, &[]).status.success());
write(
&repo,
"src/clean.rs",
&marker("ignore reason=\"unclosed\""),
false,
);
let output = lint(root, &[]);
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("src/clean.rs:1: unterminated"), "{stderr}");
assert!(!stderr.contains("src/untracked.rs"), "{stderr}");
}
#[test]
fn explicit_paths_select_files_and_report_unclosed_reason_quotes() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "src/clean.rs", "fn clean() {}\n", true);
write(
&repo,
"src/bad.rs",
&marker("ignore-line reason=\"unclosed"),
false,
);
assert!(lint(root, &["src/clean.rs"]).status.success());
let output = lint(root, &["src/bad.rs"]);
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("src/bad.rs:1: unterminated `reason="),
"{stderr}"
);
}
#[test]
fn reports_errors_in_more_than_one_file() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "a.rs", &marker("ignore reason=\"unclosed\""), true);
write(
&repo,
"b.rs",
&marker("ignore-line reason=\"unclosed"),
true,
);
let output = lint(root, &[]);
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("a.rs:1: unterminated"), "{stderr}");
assert!(stderr.contains("b.rs:1: unterminated"), "{stderr}");
}
#[test]
fn default_scans_non_rust_tracked_files() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "src/clean.rs", "fn clean() {}\n", true);
write(&repo, "scripts/tool.py", &bad().replace("//", "#"), true);
write(&repo, "notes.txt", &bad(), false);
let output = lint(root, &[]);
assert!(!output.status.success());
let stderr = err_text(&output);
assert!(
stderr.contains("scripts/tool.py:1: unterminated"),
"{stderr}"
);
assert!(!stderr.contains("notes.txt"), "{stderr}");
}
#[test]
fn binary_files_are_skipped_by_default_and_by_path() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "ok.txt", "plain\n", true);
let mut bytes = vec![0xff, 0xfe, 0x00, b'\n'];
bytes.extend_from_slice(bad().as_bytes());
let file = root.join("blob.bin");
std::fs::write(&file, &bytes).unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("blob.bin")).unwrap();
index.write().unwrap();
assert!(lint(root, &[]).status.success());
assert!(lint(root, &["blob.bin"]).status.success());
}
#[test]
fn a_missing_explicit_path_is_still_an_error() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "ok.txt", "plain\n", true);
let output = lint(root, &["absent.txt"]);
assert!(!output.status.success());
assert!(
err_text(&output).contains("could not read"),
"{}",
err_text(&output)
);
}
#[test]
fn gitlinks_and_symlinks_are_not_read() {
use git2::{IndexEntry, IndexTime, Oid};
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "ok.txt", "plain\n", true);
std::fs::create_dir_all(root.join("vendor/lib")).unwrap();
let mut index = repo.index().unwrap();
index
.add(&IndexEntry {
ctime: IndexTime::new(0, 0),
mtime: IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o160_000,
uid: 0,
gid: 0,
file_size: 0,
id: Oid::from_str("0123456789012345678901234567890123456789").unwrap(),
flags: 0,
flags_extended: 0,
path: b"vendor/lib".to_vec(),
})
.unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink("vendor", root.join("link")).unwrap();
index.add_path(Path::new("link")).unwrap();
}
index.write().unwrap();
let output = lint(root, &[]);
assert!(output.status.success(), "{}", err_text(&output));
}
#[test]
fn include_narrows_the_default_scan_and_repeats() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "src/a.py", &bad(), true);
write(&repo, "src/deep/b.py", &bad(), true);
write(&repo, "docs/c.md", &bad(), true);
write(&repo, "d.rs", &bad(), true);
let output = lint_with(root, &["--include", "src/**/*.py"], &[]);
assert!(!output.status.success());
let stderr = err_text(&output);
assert!(stderr.contains("src/a.py:1"), "{stderr}");
assert!(stderr.contains("src/deep/b.py:1"), "{stderr}");
assert!(!stderr.contains("docs/c.md"), "{stderr}");
assert!(!stderr.contains("d.rs"), "{stderr}");
let output = lint_with(root, &["--include", "*.rs", "--include", "docs/*.md"], &[]);
let stderr = err_text(&output);
assert!(stderr.contains("d.rs:1"), "{stderr}");
assert!(stderr.contains("docs/c.md:1"), "{stderr}");
assert!(!stderr.contains("src/"), "{stderr}");
assert!(lint_with(root, &["--include", "*.go"], &[])
.status
.success());
}
#[test]
fn include_does_not_restrict_explicit_paths() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "notes.txt", &bad(), true);
let output = lint_with(root, &["--include", "*.rs"], &["notes.txt"]);
assert!(!output.status.success());
assert!(
err_text(&output).contains("notes.txt:1"),
"{}",
err_text(&output)
);
}
#[test]
fn invalid_include_glob_is_an_error() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "a.rs", "fn a() {}\n", true);
let output = lint_with(root, &["--include", "src/["], &[]);
assert!(!output.status.success());
let stderr = err_text(&output);
assert!(stderr.contains("invalid glob `src/[`"), "{stderr}");
assert!(stderr.contains("--include"), "{stderr}");
}
#[test]
fn config_include_narrows_the_default_scan() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "a.rs", &bad(), true);
write(&repo, "docs/b.md", &bad(), true);
config_include(&repo, &["**/*.rs"]);
let output = lint(root, &[]);
assert!(!output.status.success());
let stderr = err_text(&output);
assert!(stderr.contains("a.rs:1"), "{stderr}");
assert!(!stderr.contains("docs/b.md"), "{stderr}");
config_include(&repo, &["**/*.go"]);
let output = lint(root, &[]);
assert!(output.status.success());
assert!(
err_text(&output).contains("lint-markers.include in config.yaml"),
"{}",
err_text(&output)
);
}
#[test]
fn include_flag_replaces_the_config_list() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "a.rs", &bad(), true);
write(&repo, "docs/b.md", &bad(), true);
config_include(&repo, &["**/*.rs"]);
let output = lint_with(root, &["--include", "docs/*.md"], &[]);
let stderr = err_text(&output);
assert!(stderr.contains("docs/b.md:1"), "{stderr}");
assert!(!stderr.contains("a.rs"), "{stderr}");
}
#[test]
fn malformed_config_and_invalid_config_glob_are_errors() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "a.rs", "fn a() {}\n", true);
write(
&repo,
".patchcov/config.yaml",
"lint-markers: [oops\n",
false,
);
let output = lint(root, &[]);
assert!(!output.status.success());
assert!(
err_text(&output).contains("could not parse coverage config"),
"{}",
err_text(&output)
);
config_include(&repo, &["src/["]);
let output = lint(root, &[]);
assert!(!output.status.success());
assert!(
err_text(&output).contains("lint-markers.include in config.yaml"),
"{}",
err_text(&output)
);
assert!(lint(root, &["a.rs"]).status.success());
}
fn json_lines(output: &Output) -> Vec<serde_json::Value> {
let text = err_text(output);
text.lines()
.map(|line| serde_json::from_str(line).unwrap_or_else(|err| panic!("{err}: {text}")))
.collect()
}
#[test]
fn json_findings_carry_path_and_line_and_the_failure_comes_last() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "a.rs", &marker("ignore reason=\"unclosed\""), true);
write(
&repo,
"src/b.rs",
&format!("fn b() {{}}\n{}", marker("ignore-line reason=\"unclosed")),
true,
);
let output = lint_with(root, &["--error-format", "json"], &[]);
assert_eq!(output.status.code(), Some(4), "{}", err_text(&output));
let lines = json_lines(&output);
assert_eq!(lines.len(), 3, "{lines:?}");
assert!(
lines.iter().all(|line| line["level"] == "error"),
"{lines:?}"
);
let finding = |path: &str, line: u32, start: &str| {
let found = lines
.iter()
.find(|l| l["kind"] == "marker-finding" && l["path"] == path)
.unwrap_or_else(|| panic!("no finding for {path}: {lines:?}"));
assert_eq!(found["line"], line, "{found}");
let message = found["message"].as_str().unwrap();
assert!(message.starts_with(start), "{message}");
assert!(
!message.contains(path),
"the location is not repeated: {message}"
);
assert!(found.get("code").is_none(), "only the failure has a code");
};
finding("a.rs", 1, "unterminated `");
finding("src/b.rs", 2, "unterminated `reason=");
let failure = lines.last().unwrap();
assert_eq!(failure["kind"], "marker", "{failure}");
assert_eq!(failure["code"], 4, "{failure}");
}
#[test]
fn text_findings_are_unchanged() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "a.rs", &marker("ignore reason=\"unclosed\""), true);
let output = lint(root, &[]);
let text = err_text(&output);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].starts_with("a.rs:1: unterminated `"), "{text}");
assert_eq!(lines[1], "Error: coverage marker lint failed");
}
#[test]
fn a_clean_json_run_prints_nothing() {
let (_dir, repo) = repo();
let root = repo.workdir().unwrap();
write(&repo, "a.rs", "fn a() {}\n", true);
let output = lint_with(root, &["--error-format", "json"], &[]);
assert!(output.status.success());
assert_eq!(err_text(&output), "");
}