alint_rules/
git_commit_message.rs1use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Violation};
21use alint_core::git::head_commit_message;
22use regex::Regex;
23use serde::Deserialize;
24
25#[derive(Debug, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct Options {
28 #[serde(default)]
33 pattern: Option<String>,
34 #[serde(default)]
39 subject_max_length: Option<usize>,
40 #[serde(default)]
45 requires_body: bool,
46}
47
48#[derive(Debug)]
49pub struct GitCommitMessageRule {
50 id: String,
51 level: Level,
52 policy_url: Option<String>,
53 message_override: Option<String>,
54 pattern: Option<Regex>,
55 subject_max_length: Option<usize>,
56 requires_body: bool,
57}
58
59impl Rule for GitCommitMessageRule {
60 fn id(&self) -> &str {
61 &self.id
62 }
63 fn level(&self) -> Level {
64 self.level
65 }
66 fn policy_url(&self) -> Option<&str> {
67 self.policy_url.as_deref()
68 }
69
70 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
71 let mut violations = Vec::new();
72 let Some(message) = head_commit_message(ctx.root) else {
73 return Ok(violations);
77 };
78
79 let (subject, body) = split_subject_body(&message);
80
81 if let Some(re) = &self.pattern
82 && !re.is_match(&message)
83 {
84 violations.push(self.make_violation(format!(
85 "HEAD commit message does not match pattern `{}`",
86 re.as_str(),
87 )));
88 }
89
90 if let Some(max) = self.subject_max_length
91 && subject.chars().count() > max
92 {
93 violations.push(self.make_violation(format!(
94 "HEAD commit subject is {} chars; max allowed is {max}",
95 subject.chars().count(),
96 )));
97 }
98
99 if self.requires_body && body.trim().is_empty() {
100 violations.push(self.make_violation(
101 "HEAD commit message has no body; this rule requires one".to_string(),
102 ));
103 }
104
105 Ok(violations)
106 }
107}
108
109impl GitCommitMessageRule {
110 fn make_violation(&self, default_msg: String) -> Violation {
111 Violation::new(self.message_override.clone().unwrap_or(default_msg))
112 }
113}
114
115fn split_subject_body(message: &str) -> (&str, &str) {
121 let (subject, rest) = message.split_once('\n').unwrap_or((message, ""));
122 let body = rest.strip_prefix('\n').unwrap_or(rest);
127 (subject, body)
128}
129
130pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
131 let opts: Options = spec
132 .deserialize_options()
133 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
134
135 if opts.pattern.is_none()
136 && opts.subject_max_length.is_none()
137 && !opts.requires_body
138 {
139 return Err(Error::rule_config(
140 &spec.id,
141 "git_commit_message needs at least one of `pattern:`, `subject_max_length:`, \
142 or `requires_body: true`",
143 ));
144 }
145 if spec.fix.is_some() {
146 return Err(Error::rule_config(
147 &spec.id,
148 "git_commit_message has no fix op",
149 ));
150 }
151
152 let pattern = opts
153 .pattern
154 .as_deref()
155 .map(|p| {
156 Regex::new(p).map_err(|e| {
157 Error::rule_config(&spec.id, format!("invalid `pattern:` regex `{p}`: {e}"))
158 })
159 })
160 .transpose()?;
161
162 Ok(Box::new(GitCommitMessageRule {
163 id: spec.id.clone(),
164 level: spec.level,
165 policy_url: spec.policy_url.clone(),
166 message_override: spec.message.clone(),
167 pattern,
168 subject_max_length: opts.subject_max_length,
169 requires_body: opts.requires_body,
170 }))
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn split_one_line_message() {
179 let (subj, body) = split_subject_body("just a subject");
180 assert_eq!(subj, "just a subject");
181 assert_eq!(body, "");
182 }
183
184 #[test]
185 fn split_subject_body_with_canonical_blank_line() {
186 let (subj, body) = split_subject_body("Add feature\n\nLong description here.\nMore.");
187 assert_eq!(subj, "Add feature");
188 assert_eq!(body, "Long description here.\nMore.");
189 }
190
191 #[test]
192 fn split_subject_no_blank_separator() {
193 let (subj, body) = split_subject_body("subject\nrest of content");
198 assert_eq!(subj, "subject");
199 assert_eq!(body, "rest of content");
200 }
201
202 #[test]
203 fn pattern_rejects_unrelated_subject() {
204 let re = Regex::new(r"^(feat|fix|chore): ").unwrap();
205 assert!(!re.is_match("WIP changes"));
206 assert!(re.is_match("feat: add markdown formatter"));
207 }
208
209 #[test]
210 fn subject_length_uses_chars_not_bytes() {
211 let subj = "🚀".repeat(50);
215 assert_eq!(subj.chars().count(), 50);
216 assert_eq!(subj.len(), 50 * 4); }
218
219 #[test]
220 fn requires_body_detects_subject_only() {
221 let (_, body) = split_subject_body("just a subject");
222 assert!(body.trim().is_empty());
223 }
224
225 #[test]
226 fn requires_body_accepts_canonical_form() {
227 let (_, body) = split_subject_body("subject\n\nbody content");
228 assert!(!body.trim().is_empty());
229 }
230}