use alint_core::git::{
CommitRangeError, CommitRecord, commit_messages_in_range, head_commit_message,
};
use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Violation};
use regex::Regex;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
pattern: Option<String>,
#[serde(default)]
subject_max_length: Option<usize>,
#[serde(default)]
requires_body: bool,
#[serde(default)]
since: Option<String>,
#[serde(default)]
include_merges: bool,
}
#[derive(Debug)]
pub struct GitCommitMessageRule {
id: String,
level: Level,
policy_url: Option<String>,
message_override: Option<String>,
pattern: Option<Regex>,
subject_max_length: Option<usize>,
requires_body: bool,
since_raw: Option<String>,
include_merges: bool,
}
impl Rule for GitCommitMessageRule {
alint_core::rule_common_impl!();
fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let since = match &self.since_raw {
None => None,
Some(raw) => match expand_env(raw, env_lookup) {
Ok(resolved) => Some(resolved),
Err(missing) => {
return Err(Error::rule_config(
&self.id,
format!(
"`since:` references undefined env var `{missing}` \
and has no default. Either set the env var (for \
example, `ALINT_BASE_SHA` from \
`github.event.pull_request.base.sha` in a GitHub \
Actions workflow) or use the `${{VAR:-default}}` \
default-value syntax."
),
));
}
},
};
let commits = match &since {
None => match head_commit_message(ctx.root) {
Some(message) => vec![CommitRecord {
sha: "HEAD".to_string(),
message,
}],
None => return Ok(violations), },
Some(since) => {
match commit_messages_in_range(ctx.root, since, self.include_merges) {
Ok(None) => return Ok(violations), Ok(Some(records)) => records,
Err(CommitRangeError::BadRange { stderr }) => {
return Err(Error::rule_config(
&self.id,
format!(
"could not resolve commit range `{since}..HEAD`: {stderr}. \
Common cause: shallow clone. In a GitHub Actions PR \
workflow, use `actions/checkout@v4` with \
`fetch-depth: 0` so the base ref is reachable."
),
));
}
}
}
};
for commit in &commits {
self.check_one(commit, &mut violations);
}
Ok(violations)
}
}
impl GitCommitMessageRule {
fn check_one(&self, commit: &CommitRecord, violations: &mut Vec<Violation>) {
let (subject, body) = split_subject_body(&commit.message);
if let Some(re) = &self.pattern
&& !re.is_match(&commit.message)
{
violations.push(self.make_violation(format_msg(
commit,
subject,
&format!("commit message does not match pattern `{}`", re.as_str()),
)));
}
if let Some(max) = self.subject_max_length
&& subject.chars().count() > max
{
violations.push(self.make_violation(format_msg(
commit,
subject,
&format!(
"commit subject is {} chars; max allowed is {max}",
subject.chars().count(),
),
)));
}
if self.requires_body && body.trim().is_empty() {
violations.push(self.make_violation(format_msg(
commit,
subject,
"commit message has no body; this rule requires one",
)));
}
}
fn make_violation(&self, default_msg: String) -> Violation {
Violation::new(self.message_override.clone().unwrap_or(default_msg))
}
}
fn format_msg(commit: &CommitRecord, subject: &str, what: &str) -> String {
const SUBJECT_PREVIEW_MAX: usize = 60;
let preview: String = subject.chars().take(SUBJECT_PREVIEW_MAX).collect();
let ellipsis = if subject.chars().count() > SUBJECT_PREVIEW_MAX {
"…"
} else {
""
};
format!(
"commit {}: {what} (subject: \"{preview}{ellipsis}\")",
commit.sha
)
}
fn split_subject_body(message: &str) -> (&str, &str) {
let (subject, rest) = message.split_once('\n').unwrap_or((message, ""));
let body = rest.strip_prefix('\n').unwrap_or(rest);
(subject, body)
}
fn expand_env<F>(input: &str, lookup: F) -> std::result::Result<String, String>
where
F: Fn(&str) -> Option<String>,
{
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after_open = &rest[start + 2..];
let Some(end) = after_open.find('}') else {
out.push_str("${");
rest = after_open;
continue;
};
let inner = &after_open[..end];
let (name, default) = match inner.split_once(":-") {
Some((n, d)) => (n, Some(d)),
None => (inner, None),
};
match lookup(name) {
Some(v) if !v.is_empty() => out.push_str(&v),
_ => match default {
Some(d) => out.push_str(d),
None => return Err(name.to_string()),
},
}
rest = &after_open[end + 1..];
}
out.push_str(rest);
Ok(out)
}
fn env_lookup(name: &str) -> Option<String> {
std::env::var(name).ok()
}
pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
let opts: Options = spec
.deserialize_options()
.map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
if opts.pattern.is_none() && opts.subject_max_length.is_none() && !opts.requires_body {
return Err(Error::rule_config(
&spec.id,
"git_commit_message needs at least one of `pattern:`, `subject_max_length:`, \
or `requires_body: true`",
));
}
if spec.fix.is_some() {
return Err(Error::rule_config(
&spec.id,
"git_commit_message has no fix op",
));
}
if opts.include_merges && opts.since.is_none() {
return Err(Error::rule_config(
&spec.id,
"`include_merges: true` has no effect without `since:`. Either remove it \
or set `since:` to enable range mode.",
));
}
let pattern = opts
.pattern
.as_deref()
.map(|p| {
Regex::new(p).map_err(|e| {
Error::rule_config(&spec.id, format!("invalid `pattern:` regex `{p}`: {e}"))
})
})
.transpose()?;
Ok(Box::new(GitCommitMessageRule {
id: spec.id.clone(),
level: spec.level,
policy_url: spec.policy_url.clone(),
message_override: spec.message.clone(),
pattern,
subject_max_length: opts.subject_max_length,
requires_body: opts.requires_body,
since_raw: opts.since,
include_merges: opts.include_merges,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_one_line_message() {
let (subj, body) = split_subject_body("just a subject");
assert_eq!(subj, "just a subject");
assert_eq!(body, "");
}
#[test]
fn split_subject_body_with_canonical_blank_line() {
let (subj, body) = split_subject_body("Add feature\n\nLong description here.\nMore.");
assert_eq!(subj, "Add feature");
assert_eq!(body, "Long description here.\nMore.");
}
#[test]
fn split_subject_no_blank_separator() {
let (subj, body) = split_subject_body("subject\nrest of content");
assert_eq!(subj, "subject");
assert_eq!(body, "rest of content");
}
#[test]
fn pattern_rejects_unrelated_subject() {
let re = Regex::new(r"^(feat|fix|chore): ").unwrap();
assert!(!re.is_match("WIP changes"));
assert!(re.is_match("feat: add markdown formatter"));
}
#[test]
fn subject_length_uses_chars_not_bytes() {
let subj = "🚀".repeat(50);
assert_eq!(subj.chars().count(), 50);
assert_eq!(subj.len(), 50 * 4); }
#[test]
fn requires_body_detects_subject_only() {
let (_, body) = split_subject_body("just a subject");
assert!(body.trim().is_empty());
}
#[test]
fn requires_body_accepts_canonical_form() {
let (_, body) = split_subject_body("subject\n\nbody content");
assert!(!body.trim().is_empty());
}
#[test]
fn format_msg_renders_sha_and_subject() {
let commit = CommitRecord {
sha: "a1b2c3d".to_string(),
message: "fix: thing".to_string(),
};
let s = format_msg(&commit, "fix: thing", "subject too long");
assert!(s.contains("commit a1b2c3d"));
assert!(s.contains("fix: thing"));
assert!(s.contains("subject too long"));
}
#[test]
fn format_msg_truncates_long_subjects() {
let long_subject = "x".repeat(120);
let commit = CommitRecord {
sha: "abc1234".to_string(),
message: long_subject.clone(),
};
let s = format_msg(&commit, &long_subject, "too long");
assert!(s.contains(&"x".repeat(60)));
assert!(s.contains('…'));
assert!(!s.contains(&"x".repeat(61)));
}
fn fake_env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).to_string())
}
}
#[test]
fn expand_env_passthrough_for_bare_string() {
let env = fake_env(&[]);
assert_eq!(expand_env("origin/main", &env).unwrap(), "origin/main");
assert_eq!(expand_env("v0.9.20", &env).unwrap(), "v0.9.20");
assert_eq!(
expand_env("abc1234567890abcdef1234567890abcdef12345678", &env,).unwrap(),
"abc1234567890abcdef1234567890abcdef12345678"
);
}
#[test]
fn expand_env_substitutes_simple_var() {
let env = fake_env(&[("ALINT_BASE_SHA", "deadbeef")]);
assert_eq!(expand_env("${ALINT_BASE_SHA}", &env).unwrap(), "deadbeef");
}
#[test]
fn expand_env_default_used_when_var_unset() {
let env = fake_env(&[]);
assert_eq!(
expand_env("${MISSING:-origin/main}", &env).unwrap(),
"origin/main"
);
}
#[test]
fn expand_env_default_used_when_var_empty() {
let env = fake_env(&[("EMPTY", "")]);
assert_eq!(
expand_env("${EMPTY:-origin/main}", &env).unwrap(),
"origin/main"
);
}
#[test]
fn expand_env_errors_when_var_unset_and_no_default() {
let env = fake_env(&[]);
let err = expand_env("${NOPE}", &env).unwrap_err();
assert_eq!(err, "NOPE");
}
#[test]
fn expand_env_handles_multiple_references() {
let env = fake_env(&[("A", "foo"), ("B", "bar")]);
assert_eq!(expand_env("${A}-${B}", &env).unwrap(), "foo-bar");
}
#[test]
fn expand_env_handles_text_around_var() {
let env = fake_env(&[("SHA", "abc1234")]);
assert_eq!(
expand_env("refs/${SHA}/head", &env).unwrap(),
"refs/abc1234/head"
);
}
#[test]
fn expand_env_ignores_unclosed_brace() {
let env = fake_env(&[]);
assert_eq!(expand_env("foo${unclosed", &env).unwrap(), "foo${unclosed");
}
fn spec(toml: &str) -> RuleSpec {
let mut full =
String::from("id = \"test-rule\"\nkind = \"git_commit_message\"\nlevel = \"error\"\n");
full.push_str(toml);
toml::from_str(&full).unwrap()
}
#[test]
fn build_requires_at_least_one_assertion() {
let s = spec("");
let err = build(&s).unwrap_err();
assert!(err.to_string().contains("at least one of"));
}
#[test]
fn build_rejects_include_merges_without_since() {
let s = spec("requires_body = true\ninclude_merges = true\n");
let err = build(&s).unwrap_err();
assert!(
err.to_string().contains("include_merges"),
"expected include_merges hint, got: {err}"
);
}
#[test]
fn build_accepts_since_with_other_options() {
let s = spec("pattern = \"^feat: \"\nsince = \"origin/main\"\n");
assert!(build(&s).is_ok());
}
#[test]
fn build_accepts_since_with_include_merges() {
let s = spec("subject_max_length = 50\nsince = \"origin/main\"\ninclude_merges = true\n");
assert!(build(&s).is_ok());
}
}