use crate::registry::Ctx;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
PreCommit,
PrePush,
}
impl Stage {
pub fn as_str(self) -> &'static str {
match self {
Stage::PreCommit => "pre-commit",
Stage::PrePush => "pre-push",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitState {
Merge,
Rebase,
CherryPick,
Revert,
Bisect,
}
impl GitState {
pub fn markers(self) -> &'static [&'static str] {
match self {
GitState::Merge => &["MERGE_HEAD"],
GitState::Rebase => &["REBASE_HEAD", "rebase-merge", "rebase-apply"],
GitState::CherryPick => &["CHERRY_PICK_HEAD"],
GitState::Revert => &["REVERT_HEAD"],
GitState::Bisect => &["BISECT_LOG"],
}
}
pub fn as_str(self) -> &'static str {
match self {
GitState::Merge => "a merge",
GitState::Rebase => "a rebase",
GitState::CherryPick => "a cherry-pick",
GitState::Revert => "a revert",
GitState::Bisect => "a bisect",
}
}
pub const ALL: [GitState; 5] = [
GitState::Merge,
GitState::Rebase,
GitState::CherryPick,
GitState::Revert,
GitState::Bisect,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
pub files: &'static [&'static str],
pub opt_in: &'static [&'static str],
pub not_during: &'static [GitState],
}
impl Scope {
pub const ALWAYS: Scope = Scope {
files: &[],
opt_in: &[],
not_during: &[],
};
pub const fn files(files: &'static [&'static str]) -> Scope {
Scope {
files,
opt_in: &[],
not_during: &[],
}
}
pub const fn new(files: &'static [&'static str], opt_in: &'static [&'static str]) -> Scope {
Scope {
files,
opt_in,
not_during: &[],
}
}
pub const fn not_during(self, states: &'static [GitState]) -> Scope {
Scope {
files: self.files,
opt_in: self.opt_in,
not_during: states,
}
}
pub fn matches(&self, paths: &[String]) -> bool {
let by_ext = self.files.is_empty()
|| paths
.iter()
.any(|path| self.files.iter().any(|ext| path.ends_with(ext)));
let opted_in = self.opt_in.is_empty()
|| paths.iter().any(|p| {
let name = p.rsplit('/').next().unwrap_or(p);
self.opt_in.iter().any(|c| {
match c.split_once('*') {
Some((pre, suf)) => name.starts_with(pre) && name.ends_with(suf),
None => name == *c,
}
})
});
by_ext && opted_in
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Passed,
Failed,
Warned,
Fixed,
Unavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Proceed,
Block,
}
impl Verdict {
pub fn exit_code(self) -> i32 {
match self {
Verdict::Proceed => 0,
Verdict::Block => 1,
}
}
pub fn blocking(blocked: bool) -> Verdict {
if blocked {
Verdict::Block
} else {
Verdict::Proceed
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Block,
Warn,
}
impl Severity {
pub fn parse(value: &str) -> Option<Severity> {
match value {
"warn" => Some(Severity::Warn),
"block" => Some(Severity::Block),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Severity::Block => "block",
Severity::Warn => "warn",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fix {
None,
Rewrite,
}
impl Fix {
pub fn as_str(self) -> &'static str {
match self {
Fix::None => "none",
Fix::Rewrite => "rewrite",
}
}
}
pub trait Check: Sync {
fn name(&self) -> &str;
fn stage(&self) -> Stage;
fn scope(&self) -> Scope;
fn severity(&self) -> Severity;
fn fix(&self) -> Fix {
Fix::None
}
fn run(&self, ctx: &Ctx) -> Outcome;
}
pub struct Builtin {
pub name: &'static str,
pub stage: Stage,
pub scope: Scope,
pub severity: Severity,
pub run: fn(&Ctx) -> Outcome,
pub fix: Fix,
}
impl Check for Builtin {
fn name(&self) -> &str {
self.name
}
fn stage(&self) -> Stage {
self.stage
}
fn scope(&self) -> Scope {
self.scope
}
fn severity(&self) -> Severity {
self.severity
}
fn fix(&self) -> Fix {
self.fix
}
fn run(&self, ctx: &Ctx) -> Outcome {
(self.run)(ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_parses_exactly_the_two_words_it_documents() {
for s in [Severity::Block, Severity::Warn] {
assert_eq!(Severity::parse(s.as_str()), Some(s));
}
for bad in ["", "Warn", "WARN", "advisory", "true", "1", " warn"] {
assert_eq!(Severity::parse(bad), None, "{bad:?} must not parse");
}
}
#[test]
fn fix_says_how_it_is_written_in_json() {
assert_eq!(Fix::None.as_str(), "none");
assert_eq!(Fix::Rewrite.as_str(), "rewrite");
}
#[test]
fn always_matches_anything() {
assert!(Scope::ALWAYS.matches(&[]));
assert!(Scope::ALWAYS.matches(&["README.md".into()]));
}
#[test]
fn extensions_gate_on_the_file_type() {
let s = Scope::files(&[".rs"]);
assert!(s.matches(&["src/main.rs".into()]));
assert!(!s.matches(&["README.md".into()]));
}
#[test]
fn files_and_opt_in_are_a_conjunction() {
let ruff = Scope::new(&[".py"], &["ruff.toml", "pyproject.toml"]);
assert!(
!ruff.matches(&["a.py".into()]),
"python alone is not enough — the repo must opt in"
);
assert!(
!ruff.matches(&["pyproject.toml".into()]),
"and a config alone is not enough without python"
);
assert!(ruff.matches(&["a.py".into(), "pyproject.toml".into()]));
}
#[test]
fn a_trailing_star_is_a_prefix_match() {
let s = Scope::new(&[".yaml"], &[".kube-linter*.yaml"]);
assert!(s.matches(&["k8s/x.yaml".into(), ".kube-linter-prod.yaml".into()]));
assert!(!s.matches(&["k8s/x.yaml".into(), ".kube-lint.yaml".into()]));
}
#[test]
fn opt_in_matches_a_nested_manifest() {
let cargo = Scope::new(&[".rs"], &["Cargo.toml"]);
assert!(cargo.matches(&["crates/a/src/lib.rs".into(), "crates/a/Cargo.toml".into()]));
}
}