use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use git2::Repository;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use serde::Serialize;
use super::diff::load_coverage_config;
use super::exit::{Classify, ExitKind};
use super::warn::{emit, warn, Diagnostic, GlobOrigin, Warning};
use crate::config::resolve_config_dir_at;
use crate::markers;
const REGULAR_FILE_MODES: [u32; 2] = [0o100_644, 0o100_755];
const FINDING_LEVEL: &str = "error";
const FINDING_KIND: &str = "marker-finding";
#[derive(Debug, Serialize, PartialEq, Eq)]
struct Finding<'a> {
level: &'static str,
kind: &'static str,
path: &'a str,
line: u32,
message: &'a str,
}
impl<'a> Finding<'a> {
fn new(error: &'a markers::MarkerError) -> Self {
Self {
level: FINDING_LEVEL,
kind: FINDING_KIND,
path: &error.path,
line: error.line,
message: &error.message,
}
}
}
impl Diagnostic for Finding<'_> {
fn level(&self) -> &str {
self.level
}
fn kind(&self) -> &str {
self.kind
}
}
#[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")
.classify(ExitKind::Git)?;
let root = repo
.workdir()
.context("coverage marker lint requires a Git working tree")
.classify(ExitKind::Git)?;
let tracked = self.paths.is_empty();
let paths = if tracked {
let (patterns, origin) = if self.include.is_empty() {
(load_config_include(root)?, GlobOrigin::ConfigInclude)
} else {
(self.include, GlobOrigin::IncludeFlag)
};
let include = compile_include(&patterns, origin.described())?;
let selected = select_tracked(
tracked_paths(&repo).classify(ExitKind::Git)?,
include.as_ref(),
);
if include.is_some() && selected.is_empty() {
warn(&Warning::GlobNoMatch {
globs: patterns,
origin,
});
}
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()))
.classify(ExitKind::Other)?;
let Ok(source) = String::from_utf8(bytes) else {
continue;
};
let display = path.display().to_string();
if let Err(error) = markers::scan(&display, &source) {
emit(error.to_string(), &Finding::new(&error));
failed = true;
}
}
if failed {
return Err(ExitKind::Marker.error("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}"))
.classify(ExitKind::Config)?;
builder.add(glob);
}
Ok(Some(
builder
.build()
.with_context(|| format!("could not compile the globs in {origin}"))
.classify(ExitKind::Config)?,
))
}
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::*;
use crate::cli::doc_fields::{
check_fields, example_keys, field_rows, json_keys, reference_section, table_fields,
};
#[test]
fn a_finding_is_the_marker_error_as_data() {
let error = markers::MarkerError {
path: "src/a:b.rs".to_string(),
line: 12,
message: "needs a \"reason\"".to_string(),
};
assert_eq!(
serde_json::to_string(&Finding::new(&error)).unwrap(),
r#"{"level":"error","kind":"marker-finding","path":"src/a:b.rs","line":12,"message":"needs a \"reason\""}"#
);
assert_eq!(error.to_string(), "src/a:b.rs:12: needs a \"reason\"");
}
#[test]
fn a_finding_names_its_own_level_and_kind() {
let error = markers::MarkerError {
path: "a.rs".to_string(),
line: 1,
message: "m".to_string(),
};
let finding = Finding::new(&error);
let object = serde_json::to_value(&finding).unwrap();
assert_eq!(object["level"], finding.level());
assert_eq!(object["kind"], finding.kind());
assert_eq!((finding.level(), finding.kind()), ("error", FINDING_KIND));
}
#[test]
fn a_finding_has_the_fields_the_docs_list() {
let error = markers::MarkerError {
path: "src/a.rs".to_string(),
line: 1,
message: "m".to_string(),
};
let line = serde_json::to_string(&Finding::new(&error)).unwrap();
let keys = json_keys(&line);
assert_eq!(keys, ["level", "kind", "path", "line", "message"]);
let section = reference_section("Findings and summary");
check_fields(
&field_rows(section, FINDING_KIND),
&serde_json::from_str(&line).unwrap(),
"a finding",
);
assert_eq!(table_fields(section, FINDING_KIND), keys, "the docs table");
assert_eq!(
example_keys(section, FINDING_KIND),
Some(keys),
"the docs example"
);
}
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"])
);
}
}