use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Parser;
use git2::Repository;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use super::diff::load_coverage_config;
use crate::config::resolve_config_dir_at;
use crate::markers;
const REGULAR_FILE_MODES: [u32; 2] = [0o100_644, 0o100_755];
#[derive(Parser)]
pub struct LintMarkersCommand {
#[arg(value_name = "PATH")]
pub paths: Vec<PathBuf>,
#[arg(long = "include", value_name = "GLOB")]
pub include: Vec<String>,
}
impl LintMarkersCommand {
pub fn execute(self, repo_path: Option<&Path>) -> Result<()> {
let repo = Repository::discover(repo_path.unwrap_or_else(|| Path::new(".")))
.context("could not find a Git repository for coverage marker lint")?;
let root = repo
.workdir()
.context("coverage marker lint requires a Git working tree")?;
let tracked = self.paths.is_empty();
let paths = if tracked {
let (patterns, origin) = if self.include.is_empty() {
(
load_config_include(root)?,
"lint-markers.include in config.yaml",
)
} else {
(self.include, "--include")
};
let include = compile_include(&patterns, origin)?;
let selected = select_tracked(tracked_paths(&repo)?, include.as_ref());
if include.is_some() && selected.is_empty() {
eprintln!(
"warning: no tracked file matches the globs in {origin}; \
no coverage markers were checked"
);
}
selected
} else {
self.paths
};
let mut failed = false;
for path in paths {
let file = root.join(&path);
if tracked
&& matches!(
std::fs::symlink_metadata(&file),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
)
{
continue;
}
let bytes = std::fs::read(&file)
.with_context(|| format!("could not read {}", file.display()))?;
let Ok(source) = String::from_utf8(bytes) else {
continue;
};
let display = path.display().to_string();
if let Err(error) = markers::scan(&display, &source) {
eprintln!("{error:#}");
failed = true;
}
}
if failed {
bail!("coverage marker lint failed");
}
Ok(())
}
}
fn load_config_include(repo_root: &Path) -> Result<Vec<String>> {
let config_dir = resolve_config_dir_at(None, repo_root);
Ok(load_coverage_config(&config_dir)?.lint_markers.include)
}
fn compile_include(patterns: &[String], origin: &str) -> Result<Option<GlobSet>> {
if patterns.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.with_context(|| format!("invalid glob `{pattern}` in {origin}"))?;
builder.add(glob);
}
Ok(Some(builder.build().with_context(|| {
format!("could not compile the globs in {origin}")
})?))
}
fn select_tracked(paths: Vec<PathBuf>, include: Option<&GlobSet>) -> Vec<PathBuf> {
match include {
Some(set) => paths.into_iter().filter(|p| set.is_match(p)).collect(),
None => paths,
}
}
fn tracked_paths(repo: &Repository) -> Result<Vec<PathBuf>> {
let index = repo.index().context("could not read Git index")?;
let mut paths: Vec<PathBuf> = index
.iter()
.filter(|entry| REGULAR_FILE_MODES.contains(&entry.mode))
.filter_map(|entry| std::str::from_utf8(&entry.path).ok().map(PathBuf::from))
.collect();
paths.dedup();
Ok(paths)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn globs(patterns: &[&str]) -> GlobSet {
let patterns: Vec<String> = patterns.iter().map(ToString::to_string).collect();
compile_include(&patterns, "test").unwrap().unwrap()
}
fn paths(names: &[&str]) -> Vec<PathBuf> {
names.iter().map(PathBuf::from).collect()
}
#[test]
fn no_patterns_means_no_restriction() {
assert!(compile_include(&[], "test").unwrap().is_none());
let all = paths(&["a.rs", "docs/b.md"]);
assert_eq!(select_tracked(all.clone(), None), all);
}
#[test]
fn star_stays_in_one_component_and_double_star_crosses() {
let set = globs(&["src/**/*.py", "*.toml"]);
let kept = select_tracked(
paths(&[
"src/a.py",
"src/x/y/b.py",
"src/c.rs",
"Cargo.toml",
"sub/Cargo.toml",
]),
Some(&set),
);
assert_eq!(kept, paths(&["src/a.py", "src/x/y/b.py", "Cargo.toml"]));
}
#[test]
fn patterns_union() {
let set = globs(&["**/*.rs", "**/*.py"]);
let kept = select_tracked(paths(&["a.rs", "d/b.py", "c.md"]), Some(&set));
assert_eq!(kept, paths(&["a.rs", "d/b.py"]));
}
#[test]
fn invalid_glob_names_the_pattern_and_its_source() {
let error = compile_include(&["src/[".to_string()], "--include").unwrap_err();
let message = format!("{error:#}");
assert!(message.contains("src/["), "{message}");
assert!(message.contains("--include"), "{message}");
}
fn entry(path: &str, mode: u32, stage: u16, id: git2::Oid) -> git2::IndexEntry {
git2::IndexEntry {
ctime: git2::IndexTime::new(0, 0),
mtime: git2::IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode,
uid: 0,
gid: 0,
file_size: 0,
id,
flags: stage << 12,
flags_extended: 0,
path: path.as_bytes().to_vec(),
}
}
#[test]
fn tracked_paths_keeps_regular_files_once_each() {
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init(dir.path()).unwrap();
let blob = repo.blob(b"x").unwrap();
let commit = git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap();
let mut index = repo.index().unwrap();
index.add(&entry("a.txt", 0o100_644, 0, blob)).unwrap();
index.add(&entry("run.sh", 0o100_755, 0, blob)).unwrap();
index.add(&entry("link", 0o120_000, 0, blob)).unwrap();
index
.add(&entry("vendor/lib", 0o160_000, 0, commit))
.unwrap();
for stage in 1..=3 {
index
.add(&entry("conflict.md", 0o100_644, stage, blob))
.unwrap();
}
assert_eq!(
tracked_paths(&repo).unwrap(),
paths(&["a.txt", "conflict.md", "run.sh"])
);
}
}