diff --git a/end-to-end-tests/features/steps/then-violations.py b/end-to-end-tests/features/steps/then-violations.py index 5e116744..23266def 100644 --- a/end-to-end-tests/features/steps/then-violations.py +++ b/end-to-end-tests/features/steps/then-violations.py @@ -84,3 +84,12 @@ def assert_commit_title_too_long_violation(context): # Then assert_commits_linting_errors(result, [['CommitTitleTooLong']]) + + +@then('has a scope which is not lowercase violation is detected.') +def assert_non_lowercase_scope_violation(context): + # When/Then + result = assert_linting_fails(context) + + # Then + assert_commits_linting_errors(result, [['NonLowercaseScope']]) diff --git a/end-to-end-tests/features/steps/when.py b/end-to-end-tests/features/steps/when.py index 8d43479a..61224847 100644 --- a/end-to-end-tests/features/steps/when.py +++ b/end-to-end-tests/features/steps/when.py @@ -35,3 +35,8 @@ def set_output(context, output): @when('the argument --max-commit-title-length is set to "{max_length}".') def set_max_commit_title_length(context, max_length): context.arguments += f" --max-commit-title-length {max_length} " + + +@when('the argument --lowercase-scope is set.') +def set_lowercase_scope(context): + context.arguments += " --lowercase-scope " diff --git a/end-to-end-tests/features/violations/lowercase_scope.feature b/end-to-end-tests/features/violations/lowercase_scope.feature new file mode 100644 index 00000000..993f9abf --- /dev/null +++ b/end-to-end-tests/features/violations/lowercase_scope.feature @@ -0,0 +1,58 @@ +Feature: When the commit title has a scope which is not lowercase, it can be detected as a violation with the --lowercase-scope flag. + + + Scenario Outline: + Given the context and environment are reset. + When linting the "". + And the argument --output is set as "JSON". + And the argument --lowercase-scope is set. + Then has a scope which is not lowercase violation is detected. + + + Examples: + | commit_message | + | "feat(AUTH): add login" | + | "fix(API): handle errors" | + | "docs(README): update" | + | "feat(Auth): add login" | + | "fix(ApiClient): handle errors" | + | "docs(ReadMe): update" | + | "chore(Deps): update packages" | + | "feat(apiClient): add feature" | + | "fix(userService): bug fix" | + | "test(TestUtils): add test\n\nbody" | + + + Scenario Outline: + Given the context and environment are reset. + When linting the "". + And the argument --output is set as "JSON". + And the argument --lowercase-scope is set. + Then linting passes. + + + Examples: + | commit_message | + | "feat(auth): add login" | + | "fix(api): handle errors" | + | "docs(readme): update" | + | "chore(deps): update packages" | + | "test(user-service): add tests" | + | "feat(api-client): add retry logic" | + | "feat: add feature without scope" | + | "fix: bug fix without scope" | + | "feat(scope-with-hyphens): add feature\n\nwith body" | + + + Scenario Outline: + Given the context and environment are reset. + When linting the "". + And the argument --output is set as "JSON". + Then linting passes. + + + Examples: + | commit_message | + | "feat(AUTH): add login" | + | "fix(API): handle" | + | "docs(README): update" | diff --git a/src/cli.rs b/src/cli.rs index 64438843..4b6e4b95 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -37,6 +37,12 @@ pub(crate) struct Arguments { )] pub(crate) max_commit_title_length: usize, + #[arg( + long, + help = "Enforce that the scope (if present) in conventional commit messages must be lowercase, otherwise linting will fail." + )] + pub(crate) lowercase_scope: bool, + #[arg( help = "The Git reference from where to start taking the range of commits from till HEAD to lint. The range is inclusive of HEAD and exclusive of the provided reference. '-' indicates to read the standard input and lint the input as a Git commit message." )] diff --git a/src/commits/commit/conventional_commits_specification/lowercase_scope/mod.rs b/src/commits/commit/conventional_commits_specification/lowercase_scope/mod.rs new file mode 100644 index 00000000..e8fd81f1 --- /dev/null +++ b/src/commits/commit/conventional_commits_specification/lowercase_scope/mod.rs @@ -0,0 +1,27 @@ +use super::*; + +static SCOPE_EXTRACTION_REGEX: OnceLock = OnceLock::new(); + +pub(crate) fn lint(commit_message: &str) -> Result<(), LintingError> { + let regex = SCOPE_EXTRACTION_REGEX.get_or_init(|| { + Regex::new(&format!( + r"^{OPTIONAL_PRECEDING_WHITESPACE}{TYPE}{OPTIONAL_EXCLAMATION}\(([^\)]+)\){OPTIONAL_EXCLAMATION}:", + )) + .unwrap() + }); + + if let Some(captures) = regex.captures(commit_message) { + if let Some(scope) = captures.get(1) { + let scope_text = scope.as_str(); + // Check if the scope contains any uppercase letters + if scope_text.chars().any(|c| c.is_uppercase()) { + return Err(LintingError::NonLowercaseScope); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/commits/commit/conventional_commits_specification/lowercase_scope/tests.rs b/src/commits/commit/conventional_commits_specification/lowercase_scope/tests.rs new file mode 100644 index 00000000..a64c3fbf --- /dev/null +++ b/src/commits/commit/conventional_commits_specification/lowercase_scope/tests.rs @@ -0,0 +1,47 @@ +use rstest::rstest; + +use super::*; + +#[rstest( + commit_message, + // Lowercase scopes - should pass + case("feat(auth): add login"), + case("fix(api): handle errors"), + case("docs(readme): update"), + case("chore(deps): update packages"), + case("test(user-service): add tests"), + case("feat(api-client): add retry logic"), + // No scope - should pass + case("feat: add feature"), + case("fix: bug fix"), + case("docs: update docs"), +)] +fn test_lowercase_scope_passes(commit_message: &str) { + let result = lint(commit_message); + assert!(result.is_ok(), "Expected OK for: {}", commit_message); +} + +#[rstest( + commit_message, + // Uppercase scopes - should fail + case("feat(AUTH): add login"), + case("fix(API): handle errors"), + case("docs(README): update"), + // Mixed case scopes - should fail + case("feat(Auth): add login"), + case("fix(ApiClient): handle errors"), + case("docs(ReadMe): update"), + case("chore(Deps): update packages"), + // Scopes with uppercase letters in the middle + case("feat(apiClient): add feature"), + case("fix(userService): bug fix"), +)] +fn test_non_lowercase_scope_fails(commit_message: &str) { + let result = lint(commit_message); + assert_eq!( + result, + Err(LintingError::NonLowercaseScope), + "Expected NonLowercaseScope error for: {}", + commit_message + ); +} diff --git a/src/commits/commit/conventional_commits_specification/mod.rs b/src/commits/commit/conventional_commits_specification/mod.rs index 34a9285f..b9c9842f 100644 --- a/src/commits/commit/conventional_commits_specification/mod.rs +++ b/src/commits/commit/conventional_commits_specification/mod.rs @@ -2,6 +2,7 @@ use super::*; pub(super) mod empty_scope; pub(super) mod exclamation_mark_before_scope; +pub(crate) mod lowercase_scope; pub mod message_length; pub(super) mod no_description_after_type_and_scope; pub(super) mod no_space_after_colon_preceding_type_and_scope; diff --git a/src/commits/commit/mod.rs b/src/commits/commit/mod.rs index b2b6751e..77b434e5 100644 --- a/src/commits/commit/mod.rs +++ b/src/commits/commit/mod.rs @@ -55,6 +55,7 @@ impl Commit { &self, commit_type: &CommitType, max_commit_title_length: usize, + lowercase_scope: bool, ) -> Vec { info!("Linting the commit message {:?}.", self.message); let mut linting_errors = vec![]; @@ -115,6 +116,15 @@ impl Commit { } } + if lowercase_scope { + match conventional_commits_specification::lowercase_scope::lint(&self.message) { + Ok(()) => {} + Err(linting_error) => { + linting_errors.push(linting_error); + } + } + } + // Check message length regardless of conventional commits compliance let max_length = if max_commit_title_length == 0 { None diff --git a/src/commits/commit/tests/generated_tests/mod.rs b/src/commits/commit/tests/generated_tests/mod.rs index 2a288129..845ee7d7 100644 --- a/src/commits/commit/tests/generated_tests/mod.rs +++ b/src/commits/commit/tests/generated_tests/mod.rs @@ -5,6 +5,8 @@ const DEFAULT_COMMIT_TYPE: &CommitType = &CommitType::Any; const DEFAULT_COMMIT_TITLE_LENGTH: usize = 72; +const DEFAULT_LOWERCASE_SCOPE: bool = false; + mod generation; #[macro_use] mod macros; @@ -19,7 +21,7 @@ fn test_non_angular_type_commits_with_no_angular_type_only_assertion() { let commit = Commit::from_commit_message(&commit_message); // When - let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_linting_errors_eq!(expected_linting_errors, linting_errors, commit_message); @@ -35,7 +37,7 @@ fn test_angular_type_commits_with_no_angular_type_only_assertion() { let commit = Commit::from_commit_message(&commit_message); // When - let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_linting_errors_eq!(expected_linting_errors, linting_errors, commit_message); @@ -55,7 +57,7 @@ fn test_non_angular_type_commits_with_angular_type_only_assertion() { let commit = Commit::from_commit_message(&commit_message); // When - let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_linting_errors_eq!(expected_linting_errors, linting_errors, commit_message); @@ -71,7 +73,7 @@ fn test_angular_type_commits_with_angular_type_only_assertion() { let commit = Commit::from_commit_message(&commit_message); // When - let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_linting_errors_eq!(expected_linting_errors, linting_errors, commit_message); diff --git a/src/commits/commit/tests/mod.rs b/src/commits/commit/tests/mod.rs index 1ecdf0e9..e3d0a4ee 100644 --- a/src/commits/commit/tests/mod.rs +++ b/src/commits/commit/tests/mod.rs @@ -8,6 +8,8 @@ const DEFAULT_COMMIT_TYPE: &CommitType = &CommitType::Any; const DEFAULT_COMMIT_TITLE_LENGTH: usize = 72; +const DEFAULT_LOWERCASE_SCOPE: bool = false; + #[template] #[rstest( commit_message, @@ -57,7 +59,7 @@ fn test_angular_type_conventional_commits_and_only_angular_type(commit_message: let expected_linting_errors: Vec = vec![]; // When - let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_eq!( @@ -74,7 +76,7 @@ fn test_angular_type_conventional_commits(commit_message: &str) { let expected_linting_errors: Vec = vec![]; // When - let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_eq!( @@ -115,7 +117,7 @@ fn test_non_angular_type_conventional_commits_and_only_angular_type(commit_messa let expected_linting_errors = vec![LintingError::NonAngularType]; // When - let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(&CommitType::Angular, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_eq!( @@ -132,7 +134,7 @@ fn test_non_angular_type_conventional_commits(commit_message: &str) { let expected_linting_errors: Vec = vec![]; // When - let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_eq!( @@ -156,7 +158,7 @@ fn test_non_conventional_commits_fail_linting(commit_message: &str) { let commit = Commit::from_commit_message(commit_message.to_string()); // When - let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert!( @@ -202,7 +204,7 @@ fn test_commit_title_too_long(commit_message: &str) { let expected_linting_errors: Vec = vec![LintingError::CommitTitleTooLong]; // When - let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH); + let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, DEFAULT_COMMIT_TITLE_LENGTH, DEFAULT_LOWERCASE_SCOPE); // Then assert_eq!( @@ -218,7 +220,7 @@ fn test_max_commit_title_length_changeable(commit_message: &str) { let commit = Commit::from_commit_message(commit_message.to_string()); // When - let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, 120); + let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, 120, DEFAULT_LOWERCASE_SCOPE); // Then assert!( @@ -234,7 +236,7 @@ fn test_max_commit_title_length_disableable(commit_message: &str) { let commit = Commit::from_commit_message(commit_message.to_string()); // When - let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, 0); + let linting_errors = commit.lint(DEFAULT_COMMIT_TYPE, 0, DEFAULT_LOWERCASE_SCOPE); // Then assert!( diff --git a/src/commits/mod.rs b/src/commits/mod.rs index 2acdbbbb..74403962 100644 --- a/src/commits/mod.rs +++ b/src/commits/mod.rs @@ -52,11 +52,12 @@ impl Commits { self, commit_type: &CommitType, max_commit_title_length: usize, + lowercase_scope: bool, ) -> Option { let mut errors: HashMap> = HashMap::new(); for commit in self.commits.iter().cloned() { - let commit_errors = commit.lint(commit_type, max_commit_title_length); + let commit_errors = commit.lint(commit_type, max_commit_title_length, lowercase_scope); if !commit_errors.is_empty() { warn!( diff --git a/src/linting_error/mod.rs b/src/linting_error/mod.rs index a4cdae5f..3563ce4a 100644 --- a/src/linting_error/mod.rs +++ b/src/linting_error/mod.rs @@ -19,4 +19,6 @@ pub enum LintingError { NoDescriptionAfterTypeAndScope, /// Commit message subject line exceeds the recommended maximum length. CommitTitleTooLong, + /// Commit title has a scope which is not lowercase. + NonLowercaseScope, } diff --git a/src/main.rs b/src/main.rs index f3e97c66..f89c0dd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,9 +58,11 @@ fn run(arguments: Arguments) -> Result { Commits::from_git(&repository, arguments.from, arguments.history_mode) }?; - if let Some(linting_results) = - commits.lint(&arguments.commit_type, arguments.max_commit_title_length) - { + if let Some(linting_results) = commits.lint( + &arguments.commit_type, + arguments.max_commit_title_length, + arguments.lowercase_scope, + ) { match arguments.output { Output::Quiet => {} Output::Pretty => {