use serde::{Deserialize, Serialize};
pub const CATCH_ALL: &str = "all";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum PatternSet {
One(String),
Many(Vec<String>),
}
impl PatternSet {
fn patterns(&self) -> &[String] {
match self {
PatternSet::One(p) => std::slice::from_ref(p),
PatternSet::Many(p) => p,
}
}
fn is_catch_all(&self) -> bool {
self.patterns()
.iter()
.any(|p| p.eq_ignore_ascii_case(CATCH_ALL))
}
pub fn matches(&self, subject: &str, case_sensitive: bool) -> bool {
if self.is_catch_all() {
return true;
}
if case_sensitive {
return self.patterns().iter().any(|p| wildcard_match(p, subject));
}
let haystack = subject.to_lowercase();
self.patterns()
.iter()
.any(|p| wildcard_match(&p.to_lowercase(), &haystack))
}
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star, mut backtrack) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
backtrack = ti;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
backtrack += 1;
ti = backtrack;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
impl From<Vec<&str>> for PatternSet {
fn from(v: Vec<&str>) -> Self {
PatternSet::Many(v.into_iter().map(String::from).collect())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct CommitFormats {
#[serde(default = "default_major")]
pub major: PatternSet,
#[serde(default = "default_minor")]
pub minor: PatternSet,
#[serde(default = "default_patch")]
pub patch: PatternSet,
#[serde(default = "default_case_sensitive", alias = "caseSensitive")]
pub case_sensitive: bool,
}
fn default_case_sensitive() -> bool {
true
}
fn default_major() -> PatternSet {
PatternSet::Many(Vec::new())
}
fn default_minor() -> PatternSet {
vec![
"feat:*",
"feat(?*):*",
"Feat:*",
"Feat(?*):*",
"Feat/*",
"feat/*",
"feature:*",
"feature(?*):*",
"Feature/*",
"feature/*",
]
.into()
}
fn default_patch() -> PatternSet {
vec![
"fix:*",
"fix(?*):*",
"Fix:*",
"Fix(?*):*",
"Fix/*",
"fix/*",
"perf:*",
"perf(?*):*",
"Perf:*",
"Perf(?*):*",
"refactor:*",
"refactor(?*):*",
"Refactor:*",
"Refactor(?*):*",
"Refactor/*",
"refactor/*",
]
.into()
}
impl Default for CommitFormats {
fn default() -> Self {
Self {
major: default_major(),
minor: default_minor(),
patch: default_patch(),
case_sensitive: default_case_sensitive(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn defaults() -> CommitFormats {
CommitFormats::default()
}
#[test]
fn single_string_pattern_is_accepted() {
let p: PatternSet = serde_json::from_str(r#""feat:*""#).unwrap();
assert!(p.matches("feat: add login", true));
assert!(!p.matches("fix: crash", true));
}
#[test]
fn list_of_patterns_is_accepted() {
let p: PatternSet = serde_json::from_str(r#"["feat:*", "Feat/*"]"#).unwrap();
assert!(p.matches("feat: x", true));
assert!(p.matches("Feat/add-login", true));
assert!(!p.matches("chore: x", true));
}
#[test]
fn catch_all_matches_anything() {
let p: PatternSet = serde_json::from_str(r#""all""#).unwrap();
assert!(p.matches("literally anything", true));
assert!(p.matches("", true));
}
#[test]
fn catch_all_is_a_keyword_not_a_pattern_so_casing_is_irrelevant() {
for raw in [r#""All""#, r#""ALL""#, r#""all""#] {
let p: PatternSet = serde_json::from_str(raw).unwrap();
assert!(p.matches("chore: whatever", true), "{raw} should catch all");
assert!(
p.matches("chore: whatever", false),
"{raw} should catch all"
);
}
}
#[test]
fn catch_all_inside_a_list_still_catches_all() {
let p: PatternSet = serde_json::from_str(r#"["feat:*", "all"]"#).unwrap();
assert!(p.matches("chore: whatever", true));
}
#[test]
fn case_sensitive_by_default_rejects_wrong_casing() {
let p: PatternSet = serde_json::from_str(r#""feat:*""#).unwrap();
assert!(!p.matches("FEAT: shouting", true));
assert!(p.matches("FEAT: shouting", false));
}
#[test]
fn case_insensitive_lowercases_both_sides() {
let p: PatternSet = serde_json::from_str(r#""Feat/*""#).unwrap();
assert!(p.matches("FEAT/add-login", false));
assert!(p.matches("feat/add-login", false));
}
#[test]
fn defaults_cover_the_branch_style_prefixes_the_issue_asked_for() {
let f = defaults();
for subject in [
"Feat/add-login",
"Fix/resolve-crash",
"Refactor/cleanup",
"Feat: add login",
"Fix: resolve crash",
"feature: add login",
] {
let hit = f.major.matches(subject, f.case_sensitive)
|| f.minor.matches(subject, f.case_sensitive)
|| f.patch.matches(subject, f.case_sensitive);
assert!(hit, "default patterns should match {subject:?}");
}
}
#[test]
fn defaults_still_ignore_non_release_conventional_types() {
let f = defaults();
for subject in ["chore: deps", "docs: typo", "ci: cache", "test: add case"] {
let hit = f.major.matches(subject, f.case_sensitive)
|| f.minor.matches(subject, f.case_sensitive)
|| f.patch.matches(subject, f.case_sensitive);
assert!(!hit, "{subject:?} must not trigger a release");
}
}
#[test]
fn partial_config_falls_back_to_defaults_per_level() {
let f: CommitFormats = serde_json::from_str(r#"{"minor": "all"}"#).unwrap();
assert!(f.minor.matches("anything", true));
assert!(f.patch.matches("fix: x", true), "patch keeps its default");
assert!(f.case_sensitive, "case_sensitive defaults to true");
}
#[test]
fn case_sensitive_accepts_both_snake_and_camel_spelling() {
let a: CommitFormats = serde_json::from_str(r#"{"case_sensitive": false}"#).unwrap();
let b: CommitFormats = serde_json::from_str(r#"{"caseSensitive": false}"#).unwrap();
assert!(!a.case_sensitive);
assert!(!b.case_sensitive);
}
}