Skip to main content

scute_core/
commit_message.rs

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/// Configuration for a commit-message check.
17///
18/// Both fields are optional. When omitted, defaults apply:
19/// standard Conventional Commits types and `{ fail: 0 }`.
20///
21/// ```
22/// use scute_core::commit_message::Definition;
23/// use scute_core::Thresholds;
24/// use scute_core::commit_message;
25///
26/// let def = Definition {
27///     types: Some(vec!["hotfix".into()]),
28///     thresholds: Some(Thresholds { warn: None, fail: Some(0) }),
29/// };
30///
31/// let evals = commit_message::check("hotfix: urgent patch", &def).unwrap();
32/// assert!(evals[0].is_pass());
33///
34/// let evals = commit_message::check("feat: add login", &def).unwrap();
35/// assert!(evals[0].is_fail());
36/// ```
37#[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
44/// Validate a commit message against the Conventional Commits spec.
45///
46/// Git comment lines (`#`-prefixed) are stripped before validation.
47/// Use `Definition::default()` for standard Conventional Commits types and `{ fail: 0 }`.
48///
49/// # Errors
50///
51/// Always returns `Ok`. Validation issues appear as evidence in the
52/// evaluation, not as errors.
53///
54/// ```
55/// use scute_core::commit_message;
56/// use scute_core::commit_message::Definition;
57///
58/// let evals = commit_message::check("feat(auth): add OAuth flow", &Definition::default()).unwrap();
59/// assert!(evals[0].is_pass());
60///
61/// let evals = commit_message::check("banana: ", &Definition::default()).unwrap();
62/// assert!(evals[0].is_fail());
63/// ```
64pub 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}