alint_rules/
git_commit_message.rs1use alint_core::git::head_commit_message;
21use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Violation};
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() && opts.subject_max_length.is_none() && !opts.requires_body {
136 return Err(Error::rule_config(
137 &spec.id,
138 "git_commit_message needs at least one of `pattern:`, `subject_max_length:`, \
139 or `requires_body: true`",
140 ));
141 }
142 if spec.fix.is_some() {
143 return Err(Error::rule_config(
144 &spec.id,
145 "git_commit_message has no fix op",
146 ));
147 }
148
149 let pattern = opts
150 .pattern
151 .as_deref()
152 .map(|p| {
153 Regex::new(p).map_err(|e| {
154 Error::rule_config(&spec.id, format!("invalid `pattern:` regex `{p}`: {e}"))
155 })
156 })
157 .transpose()?;
158
159 Ok(Box::new(GitCommitMessageRule {
160 id: spec.id.clone(),
161 level: spec.level,
162 policy_url: spec.policy_url.clone(),
163 message_override: spec.message.clone(),
164 pattern,
165 subject_max_length: opts.subject_max_length,
166 requires_body: opts.requires_body,
167 }))
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn split_one_line_message() {
176 let (subj, body) = split_subject_body("just a subject");
177 assert_eq!(subj, "just a subject");
178 assert_eq!(body, "");
179 }
180
181 #[test]
182 fn split_subject_body_with_canonical_blank_line() {
183 let (subj, body) = split_subject_body("Add feature\n\nLong description here.\nMore.");
184 assert_eq!(subj, "Add feature");
185 assert_eq!(body, "Long description here.\nMore.");
186 }
187
188 #[test]
189 fn split_subject_no_blank_separator() {
190 let (subj, body) = split_subject_body("subject\nrest of content");
195 assert_eq!(subj, "subject");
196 assert_eq!(body, "rest of content");
197 }
198
199 #[test]
200 fn pattern_rejects_unrelated_subject() {
201 let re = Regex::new(r"^(feat|fix|chore): ").unwrap();
202 assert!(!re.is_match("WIP changes"));
203 assert!(re.is_match("feat: add markdown formatter"));
204 }
205
206 #[test]
207 fn subject_length_uses_chars_not_bytes() {
208 let subj = "🚀".repeat(50);
212 assert_eq!(subj.chars().count(), 50);
213 assert_eq!(subj.len(), 50 * 4); }
215
216 #[test]
217 fn requires_body_detects_subject_only() {
218 let (_, body) = split_subject_body("just a subject");
219 assert!(body.trim().is_empty());
220 }
221
222 #[test]
223 fn requires_body_accepts_canonical_form() {
224 let (_, body) = split_subject_body("subject\n\nbody content");
225 assert!(!body.trim().is_empty());
226 }
227}