1use serde::Deserialize;
2
3use crate::{Evaluation, Evidence, ExecutionError, Expected, Outcome, Thresholds};
4
5pub const CHECK_NAME: &str = "commit-message";
6
7const DEFAULT_THRESHOLDS: Thresholds = Thresholds {
8 warn: None,
9 fail: Some(0),
10};
11
12const DEFAULT_TYPES: &[&str] = &[
13 "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert",
14];
15
16#[derive(Debug, Default, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Definition {
40 pub types: Option<Vec<String>>,
41 pub thresholds: Option<Thresholds>,
42}
43
44pub fn check(message: &str, definition: &Definition) -> Result<Vec<Evaluation>, ExecutionError> {
65 let clean = strip_comments(message);
66 let subject = clean.lines().next().unwrap_or("");
67
68 Ok(vec![Evaluation {
69 target: subject.into(),
70 outcome: evaluate(&clean, definition),
71 }])
72}
73
74fn strip_comments(message: &str) -> String {
75 message
76 .lines()
77 .filter(|l| !l.starts_with('#'))
78 .collect::<Vec<_>>()
79 .join("\n")
80}
81
82fn evaluate(message: &str, definition: &Definition) -> Outcome {
83 let subject = message.lines().next().unwrap_or("");
84 let types = definition
85 .types
86 .clone()
87 .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|&s| s.into()).collect());
88 let mut evidence = validate_subject(subject, &types);
89 evidence.extend(validate_structure(message));
90 let observed = u64::from(!evidence.is_empty());
91 let thresholds = definition.thresholds.clone().unwrap_or(DEFAULT_THRESHOLDS);
92
93 Outcome::completed(observed, thresholds, evidence)
94}
95
96fn validate_subject(subject: &str, types: &[String]) -> Vec<Evidence> {
97 let Some((prefix, description)) = subject.split_once(": ") else {
98 return vec![Evidence::with_expected(
99 "subject-format",
100 subject,
101 Expected::Text("type(scope): description".into()),
102 )];
103 };
104
105 let prefix_clean = prefix.trim_end_matches('!');
106 let type_str = prefix_clean.split('(').next().unwrap_or(prefix_clean);
107 let mut evidence = Vec::new();
108
109 let type_known = types.iter().any(|t| t.eq_ignore_ascii_case(type_str));
110 if !type_known {
111 evidence.push(Evidence::with_expected(
112 "unknown-type",
113 type_str,
114 Expected::List(types.to_vec()),
115 ));
116 }
117
118 if prefix.contains("()") {
119 evidence.push(Evidence::new("empty-scope", "()"));
120 }
121
122 if description.trim().is_empty() {
123 evidence.push(Evidence::new("empty-description", description));
124 }
125
126 evidence
127}
128
129fn validate_structure(message: &str) -> Vec<Evidence> {
130 let mut lines = message.lines();
131 let _subject = lines.next();
132 let second_line = lines.next();
133
134 if let Some(line) = second_line
135 && !line.is_empty()
136 {
137 return vec![Evidence::new("body-separator", line)];
138 }
139
140 let paragraphs: Vec<&str> = message.split("\n\n").collect();
141 if paragraphs.len() >= 2 {
142 return validate_footers(paragraphs.last().unwrap());
143 }
144
145 vec![]
146}
147
148fn validate_footers(paragraph: &str) -> Vec<Evidence> {
149 let lines: Vec<&str> = paragraph.lines().collect();
150 if !lines.iter().any(|l| is_footer_line(l)) {
151 return vec![];
152 }
153
154 let mut evidence = Vec::new();
155 for line in &lines {
156 match footer_token(line) {
157 Some(token)
158 if is_breaking_change(token)
159 && token != "BREAKING CHANGE"
160 && token != "BREAKING-CHANGE" =>
161 {
162 evidence.push(Evidence::with_expected(
163 "breaking-change-case",
164 token,
165 Expected::List(vec!["BREAKING CHANGE".into(), "BREAKING-CHANGE".into()]),
166 ));
167 }
168 None => {
169 evidence.push(Evidence::with_expected(
170 "footer-format",
171 line,
172 Expected::Text("token: value | token #value".into()),
173 ));
174 }
175 _ => {}
176 }
177 }
178 evidence
179}
180
181fn is_footer_line(line: &str) -> bool {
182 footer_token(line).is_some()
183}
184
185fn footer_token(line: &str) -> Option<&str> {
186 if let Some((token, _)) = line.split_once(": ")
187 && is_footer_token(token)
188 {
189 return Some(token);
190 }
191 if let Some((token, _)) = line.split_once(" #")
192 && is_footer_token(token)
193 {
194 return Some(token);
195 }
196 None
197}
198
199fn is_footer_token(token: &str) -> bool {
200 is_breaking_change(token)
201 || (!token.is_empty() && token.chars().all(|c| c.is_alphanumeric() || c == '-'))
202}
203
204fn is_breaking_change(token: &str) -> bool {
205 token.eq_ignore_ascii_case("BREAKING CHANGE") || token.eq_ignore_ascii_case("BREAKING-CHANGE")
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::Status;
212 use googletest::prelude::*;
213 use test_case::test_case;
214
215 struct Completed {
216 status: Status,
217 observed: u64,
218 thresholds: Thresholds,
219 evidence: Vec<Evidence>,
220 }
221
222 fn unwrap_completed(outcome: Outcome) -> Completed {
223 match outcome {
224 Outcome::Completed {
225 status,
226 observed,
227 thresholds,
228 evidence,
229 } => Completed {
230 status,
231 observed,
232 thresholds,
233 evidence,
234 },
235 other @ Outcome::Errored(_) => panic!("expected Completed, got {other:?}"),
236 }
237 }
238
239 #[test]
240 fn message_without_colon_space_separator_fails() {
241 let c = unwrap_completed(evaluate("no separator here", &Definition::default()));
242
243 assert_eq!(c.status, Status::Fail);
244 assert_eq!(c.observed, 1);
245 assert_that!(c.evidence[0].rule, some(eq("subject-format")));
246 assert_eq!(c.evidence[0].found, "no separator here");
247 }
248
249 #[test]
250 fn rejects_unknown_type() {
251 let c = unwrap_completed(evaluate("banana: do something", &Definition::default()));
252
253 assert_eq!(c.status, Status::Fail);
254 assert_that!(c.evidence[0].rule, some(eq("unknown-type")));
255 assert_eq!(c.evidence[0].found, "banana");
256 }
257
258 #[test]
259 fn unknown_type_expected_lists_valid_types() {
260 let c = unwrap_completed(evaluate("banana: do something", &Definition::default()));
261
262 assert!(matches!(c.evidence[0].expected, Some(Expected::List(_))));
263 }
264
265 #[test_case("feat: ", "empty-description" ; "rejects empty description")]
266 #[test_case("feat: \t ", "empty-description" ; "rejects whitespace only description")]
267 #[test_case("feat(): add login", "empty-scope" ; "rejects empty scope")]
268 fn rejects_with_rule(message: &str, expected_rule: &str) {
269 let c = unwrap_completed(evaluate(message, &Definition::default()));
270
271 assert_eq!(c.status, Status::Fail);
272 assert_that!(c.evidence[0].rule, some(eq(expected_rule)));
273 }
274
275 #[test_case("Feat: add login" ; "accepts type regardless of case")]
276 #[test_case("feat(auth): add login" ; "accepts scope in parentheses")]
277 #[test_case("feat!: breaking change" ; "accepts breaking change indicator")]
278 #[test_case("feat(api)!: remove endpoint" ; "accepts scope with breaking change")]
279 fn accepts_valid_subject(message: &str) {
280 let c = unwrap_completed(evaluate(message, &Definition::default()));
281
282 assert_eq!(c.status, Status::Pass);
283 assert!(c.evidence.is_empty());
284 }
285
286 #[test]
287 fn multiple_violations_produce_multiple_evidence_entries() {
288 let c = unwrap_completed(evaluate("banana: ", &Definition::default()));
289
290 assert_eq!(c.status, Status::Fail);
291 assert_eq!(c.observed, 1);
292 assert_eq!(c.evidence.len(), 2);
293 assert_that!(c.evidence[0].rule, some(eq("unknown-type")));
294 assert_that!(c.evidence[1].rule, some(eq("empty-description")));
295 }
296
297 #[test]
298 fn rejects_body_not_separated_by_blank_line() {
299 let c = unwrap_completed(evaluate(
300 "feat: add login\nThis is not separated.",
301 &Definition::default(),
302 ));
303
304 assert_eq!(c.status, Status::Fail);
305 assert_that!(c.evidence[0].rule, some(eq("body-separator")));
306 assert_eq!(c.evidence[0].found, "This is not separated.");
307 assert_eq!(c.evidence[0].expected, None);
308 }
309
310 #[test_case("feat: add login\n\nThis adds the login flow." ; "valid message with body passes")]
311 #[test_case("feat: add login\n\nSome body text.\n\nReviewed-by: Alice" ; "valid message with footer passes")]
312 #[test_case("fix: resolve bug\n\nFixes #123" ; "accepts footer with hash value format")]
313 fn accepts_valid_multiline(message: &str) {
314 let c = unwrap_completed(evaluate(message, &Definition::default()));
315
316 assert_eq!(c.status, Status::Pass);
317 assert!(c.evidence.is_empty());
318 }
319
320 #[test]
321 fn rejects_malformed_footer() {
322 let c = unwrap_completed(evaluate(
323 "feat: add login\n\nSome body.\n\nReviewed-by: Alice\nnot a valid footer",
324 &Definition::default(),
325 ));
326
327 assert_eq!(c.status, Status::Fail);
328 assert_that!(c.evidence[0].rule, some(eq("footer-format")));
329 assert_eq!(c.evidence[0].found, "not a valid footer");
330 }
331
332 #[test]
333 fn rejects_lowercase_breaking_change_footer() {
334 let c = unwrap_completed(evaluate(
335 "feat!: drop API\n\nbreaking change: removed endpoint",
336 &Definition::default(),
337 ));
338
339 assert_eq!(c.status, Status::Fail);
340 assert_that!(c.evidence[0].rule, some(eq("breaking-change-case")));
341 assert_eq!(c.evidence[0].found, "breaking change");
342 }
343
344 #[test]
345 fn strips_git_comment_lines() {
346 let evals = check(
347 "feat: add login\n# This is a git comment\n\nBody here.",
348 &Definition::default(),
349 )
350 .unwrap();
351
352 assert!(evals[0].is_pass());
353 }
354
355 #[test]
356 fn rejects_empty_commit_message() {
357 let c = unwrap_completed(evaluate("", &Definition::default()));
358
359 assert_eq!(c.status, Status::Fail);
360 assert_that!(c.evidence[0].rule, some(eq("subject-format")));
361 }
362
363 #[test]
364 fn rejects_whitespace_only_commit_message() {
365 let c = unwrap_completed(evaluate(" \n \n ", &Definition::default()));
366
367 assert_eq!(c.status, Status::Fail);
368 }
369
370 #[test]
371 fn valid_message_returns_pass_with_all_fields() {
372 let c = unwrap_completed(evaluate("feat: add login", &Definition::default()));
373
374 assert_eq!(c.status, Status::Pass);
375 assert_eq!(c.observed, 0);
376 assert_eq!(
377 c.thresholds,
378 Thresholds {
379 warn: None,
380 fail: Some(0)
381 }
382 );
383 assert!(c.evidence.is_empty());
384 }
385
386 #[test]
387 fn check_sets_target_to_subject_line() {
388 let evals = check("feat: add login", &Definition::default()).unwrap();
389
390 assert_eq!(evals[0].target, "feat: add login");
391 }
392
393 #[test]
394 fn evaluation_thresholds_match_definition() {
395 let definition = Definition {
396 thresholds: Some(Thresholds {
397 warn: Some(1),
398 fail: Some(3),
399 }),
400 ..Definition::default()
401 };
402
403 let c = unwrap_completed(evaluate("feat: add login", &definition));
404
405 assert_eq!(
406 c.thresholds,
407 Thresholds {
408 warn: Some(1),
409 fail: Some(3),
410 }
411 );
412 }
413
414 #[test]
415 fn subject_format_expected_describes_format() {
416 let c = unwrap_completed(evaluate("no separator here", &Definition::default()));
417
418 assert_eq!(
419 c.evidence[0].expected,
420 Some(Expected::Text("type(scope): description".into()))
421 );
422 }
423
424 #[test]
425 fn unknown_type_expected_reflects_config_types() {
426 let definition = Definition {
427 types: Some(vec!["hotfix".into(), "deploy".into()]),
428 ..Definition::default()
429 };
430
431 let c = unwrap_completed(evaluate("feat: add login", &definition));
432
433 assert_eq!(
434 c.evidence[0].expected,
435 Some(Expected::List(vec!["hotfix".into(), "deploy".into()]))
436 );
437 }
438
439 #[test]
440 fn footer_format_expected_describes_format() {
441 let c = unwrap_completed(evaluate(
442 "feat: add login\n\nSome body.\n\nReviewed-by: Alice\nnot a valid footer",
443 &Definition::default(),
444 ));
445
446 assert_eq!(
447 c.evidence[0].expected,
448 Some(Expected::Text("token: value | token #value".into()))
449 );
450 }
451
452 #[test]
453 fn breaking_change_case_expected_shows_valid_casings() {
454 let c = unwrap_completed(evaluate(
455 "feat!: drop API\n\nbreaking change: removed endpoint",
456 &Definition::default(),
457 ));
458
459 assert_eq!(
460 c.evidence[0].expected,
461 Some(Expected::List(vec![
462 "BREAKING CHANGE".into(),
463 "BREAKING-CHANGE".into(),
464 ]))
465 );
466 }
467
468 #[test]
469 fn custom_types_override_defaults() {
470 let definition = Definition {
471 types: Some(vec!["hotfix".into()]),
472 ..Definition::default()
473 };
474
475 let c = unwrap_completed(evaluate("hotfix: urgent patch", &definition));
476
477 assert_eq!(c.status, Status::Pass);
478 assert!(c.evidence.is_empty());
479 }
480}