# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
"""Tests for the commit_parsing subpackage.
All tests are pure — no I/O, no mocks, no async.
"""
from __future__ import annotations
import pytest
from releasekit.commit_parsing import (
BUMP_PRECEDENCE,
BumpType,
CommitParser,
ConventionalCommit,
ConventionalCommitParser,
ParsedCommit,
max_bump,
parse_conventional_commit,
)
# ---------------------------------------------------------------------------
# BumpType
# ---------------------------------------------------------------------------
class TestBumpType:
"""Tests for BumpType enum."""
def test_values(self) -> None:
"""Test values."""
assert BumpType.MAJOR.value == 'major'
assert BumpType.MINOR.value == 'minor'
assert BumpType.PATCH.value == 'patch'
assert BumpType.PRERELEASE.value == 'prerelease'
assert BumpType.NONE.value == 'none'
def test_precedence_order(self) -> None:
"""Test precedence order."""
assert BUMP_PRECEDENCE == [
BumpType.MAJOR,
BumpType.MINOR,
BumpType.PATCH,
BumpType.PRERELEASE,
BumpType.NONE,
]
# ---------------------------------------------------------------------------
# max_bump
# ---------------------------------------------------------------------------
class TestMaxBump:
"""Tests for the max_bump pure function."""
def test_same_type(self) -> None:
"""Test same type."""
for bt in BumpType:
assert max_bump(bt, bt) == bt
def test_major_wins_over_all(self) -> None:
"""Test major wins over all."""
for bt in BumpType:
assert max_bump(BumpType.MAJOR, bt) == BumpType.MAJOR
assert max_bump(bt, BumpType.MAJOR) == BumpType.MAJOR
def test_minor_over_patch(self) -> None:
"""Test minor over patch."""
assert max_bump(BumpType.MINOR, BumpType.PATCH) == BumpType.MINOR
assert max_bump(BumpType.PATCH, BumpType.MINOR) == BumpType.MINOR
def test_patch_over_none(self) -> None:
"""Test patch over none."""
assert max_bump(BumpType.PATCH, BumpType.NONE) == BumpType.PATCH
assert max_bump(BumpType.NONE, BumpType.PATCH) == BumpType.PATCH
def test_prerelease_over_none(self) -> None:
"""Test prerelease over none."""
assert max_bump(BumpType.PRERELEASE, BumpType.NONE) == BumpType.PRERELEASE
def test_patch_over_prerelease(self) -> None:
"""Test patch over prerelease."""
assert max_bump(BumpType.PATCH, BumpType.PRERELEASE) == BumpType.PATCH
def test_none_none(self) -> None:
"""Test none none."""
assert max_bump(BumpType.NONE, BumpType.NONE) == BumpType.NONE
def test_commutative(self) -> None:
"""max_bump(a, b) == max_bump(b, a) for all pairs."""
for a in BumpType:
for b in BumpType:
assert max_bump(a, b) == max_bump(b, a)
# ---------------------------------------------------------------------------
# ParsedCommit / ConventionalCommit alias
# ---------------------------------------------------------------------------
class TestParsedCommit:
"""Tests for the ParsedCommit dataclass."""
def test_frozen(self) -> None:
"""Test frozen."""
cc = ParsedCommit(sha='abc', type='feat', description='x')
with pytest.raises(AttributeError):
cc.sha = 'def' # type: ignore[misc]
def test_defaults(self) -> None:
"""Test defaults."""
cc = ParsedCommit(sha='', type='feat', description='x')
assert cc.scope == ''
assert cc.breaking is False
assert cc.bump == BumpType.NONE
assert cc.raw == ''
assert cc.is_revert is False
assert cc.reverted_bump == BumpType.NONE
def test_alias(self) -> None:
"""ConventionalCommit is an alias for ParsedCommit."""
assert ConventionalCommit is ParsedCommit
def test_equality(self) -> None:
"""Test equality."""
a = ParsedCommit(sha='a', type='feat', description='x', bump=BumpType.MINOR)
b = ParsedCommit(sha='a', type='feat', description='x', bump=BumpType.MINOR)
assert a == b
def test_inequality(self) -> None:
"""Test inequality."""
a = ParsedCommit(sha='a', type='feat', description='x')
b = ParsedCommit(sha='b', type='feat', description='x')
assert a != b
# ---------------------------------------------------------------------------
# CommitParser protocol
# ---------------------------------------------------------------------------
class TestCommitParserProtocol:
"""Tests for the CommitParser protocol."""
def test_conventional_parser_satisfies_protocol(self) -> None:
"""Test conventional parser satisfies protocol."""
parser = ConventionalCommitParser()
assert isinstance(parser, CommitParser)
def test_custom_parser_satisfies_protocol(self) -> None:
"""Test custom parser satisfies protocol."""
class MyParser:
"""MyParser."""
def parse(self, message: str, sha: str = '') -> ParsedCommit | None:
"""Parse."""
if message.startswith('['):
return ParsedCommit(sha=sha, type='fix', description=message)
return None
parser = MyParser()
assert isinstance(parser, CommitParser)
def test_non_parser_fails_protocol(self) -> None:
"""Test non parser fails protocol."""
class NotAParser:
"""NotAParser."""
def analyze(self, msg: str) -> str:
"""Analyze."""
return msg
assert not isinstance(NotAParser(), CommitParser)
# ---------------------------------------------------------------------------
# ConventionalCommitParser
# ---------------------------------------------------------------------------
class TestConventionalCommitParser:
"""Comprehensive tests for ConventionalCommitParser.parse()."""
@pytest.fixture()
def parser(self) -> ConventionalCommitParser:
"""Parser."""
return ConventionalCommitParser()
# --- Basic types ---
def test_feat(self, parser: ConventionalCommitParser) -> None:
"""Test feat."""
cc = parser.parse('feat: add streaming support')
assert cc is not None
assert cc.type == 'feat'
assert cc.description == 'add streaming support'
assert cc.bump == BumpType.MINOR
assert cc.breaking is False
def test_fix(self, parser: ConventionalCommitParser) -> None:
"""Test fix."""
cc = parser.parse('fix: resolve null pointer')
assert cc is not None
assert cc.type == 'fix'
assert cc.bump == BumpType.PATCH
def test_perf(self, parser: ConventionalCommitParser) -> None:
"""Test perf."""
cc = parser.parse('perf: optimize hot loop')
assert cc is not None
assert cc.type == 'perf'
assert cc.bump == BumpType.PATCH
def test_chore(self, parser: ConventionalCommitParser) -> None:
"""Test chore."""
cc = parser.parse('chore: update deps')
assert cc is not None
assert cc.type == 'chore'
assert cc.bump == BumpType.NONE
def test_docs(self, parser: ConventionalCommitParser) -> None:
"""Test docs."""
cc = parser.parse('docs: update README')
assert cc is not None
assert cc.type == 'docs'
assert cc.bump == BumpType.NONE
def test_ci(self, parser: ConventionalCommitParser) -> None:
"""Test ci."""
cc = parser.parse('ci: add coverage')
assert cc is not None
assert cc.type == 'ci'
assert cc.bump == BumpType.NONE
def test_build(self, parser: ConventionalCommitParser) -> None:
"""Test build."""
cc = parser.parse('build: update Makefile')
assert cc is not None
assert cc.type == 'build'
assert cc.bump == BumpType.NONE
def test_style(self, parser: ConventionalCommitParser) -> None:
"""Test style."""
cc = parser.parse('style: fix formatting')
assert cc is not None
assert cc.type == 'style'
assert cc.bump == BumpType.NONE
def test_refactor(self, parser: ConventionalCommitParser) -> None:
"""Test refactor."""
cc = parser.parse('refactor: extract helper')
assert cc is not None
assert cc.type == 'refactor'
assert cc.bump == BumpType.NONE
def test_test(self, parser: ConventionalCommitParser) -> None:
"""Test test."""
cc = parser.parse('test: add unit tests')
assert cc is not None
assert cc.type == 'test'
assert cc.bump == BumpType.NONE
# --- Scope ---
def test_scope(self, parser: ConventionalCommitParser) -> None:
"""Test scope."""
cc = parser.parse('feat(auth): add OAuth2')
assert cc is not None
assert cc.type == 'feat'
assert cc.scope == 'auth'
assert cc.description == 'add OAuth2'
def test_empty_scope(self, parser: ConventionalCommitParser) -> None:
"""Test empty scope."""
cc = parser.parse('feat(): add thing')
assert cc is not None
assert cc.scope == ''
def test_scope_with_dashes(self, parser: ConventionalCommitParser) -> None:
"""Test scope with dashes."""
cc = parser.parse('fix(my-scope): thing')
assert cc is not None
assert cc.scope == 'my-scope'
# --- Breaking changes ---
def test_breaking_bang(self, parser: ConventionalCommitParser) -> None:
"""Test breaking bang."""
cc = parser.parse('fix!: remove deprecated API')
assert cc is not None
assert cc.breaking is True
assert cc.bump == BumpType.MAJOR
def test_breaking_scope_bang(self, parser: ConventionalCommitParser) -> None:
"""Test breaking scope bang."""
cc = parser.parse('feat(api)!: redesign endpoints')
assert cc is not None
assert cc.type == 'feat'
assert cc.scope == 'api'
assert cc.breaking is True
assert cc.bump == BumpType.MAJOR
def test_breaking_change_footer(self, parser: ConventionalCommitParser) -> None:
"""Test breaking change footer."""
cc = parser.parse('feat: new API BREAKING CHANGE')
assert cc is not None
assert cc.breaking is True
assert cc.bump == BumpType.MAJOR
def test_breaking_change_hyphen(self, parser: ConventionalCommitParser) -> None:
"""Test breaking change hyphen."""
cc = parser.parse('feat: new API BREAKING-CHANGE')
assert cc is not None
assert cc.breaking is True
assert cc.bump == BumpType.MAJOR
def test_chore_breaking(self, parser: ConventionalCommitParser) -> None:
"""Even chore with ! is MAJOR."""
cc = parser.parse('chore!: drop Python 3.9 support')
assert cc is not None
assert cc.breaking is True
assert cc.bump == BumpType.MAJOR
# --- Revert commits ---
def test_revert_github_format(self, parser: ConventionalCommitParser) -> None:
"""Test revert github format."""
cc = parser.parse('Revert "feat: add streaming"')
assert cc is not None
assert cc.type == 'revert'
assert cc.is_revert is True
assert cc.reverted_bump == BumpType.MINOR
assert cc.bump == BumpType.NONE
assert cc.description == 'feat: add streaming'
def test_revert_github_lowercase(self, parser: ConventionalCommitParser) -> None:
"""Test revert github lowercase."""
cc = parser.parse('revert "fix: null pointer"')
assert cc is not None
assert cc.is_revert is True
assert cc.reverted_bump == BumpType.PATCH
def test_revert_conventional_format(self, parser: ConventionalCommitParser) -> None:
"""Test revert conventional format."""
cc = parser.parse('revert: feat: add streaming')
assert cc is not None
assert cc.type == 'revert'
assert cc.is_revert is True
assert cc.reverted_bump == BumpType.MINOR
def test_revert_conventional_with_scope(self, parser: ConventionalCommitParser) -> None:
"""Test revert conventional with scope."""
cc = parser.parse('revert: feat(auth): add OAuth2')
assert cc is not None
assert cc.is_revert is True
assert cc.scope == 'auth'
assert cc.reverted_bump == BumpType.MINOR
def test_revert_non_conventional_inner(self, parser: ConventionalCommitParser) -> None:
"""Revert of a non-conventional message still parses as revert."""
cc = parser.parse('Revert "Update README.md"')
assert cc is not None
assert cc.is_revert is True
assert cc.reverted_bump == BumpType.NONE
def test_revert_breaking_inner(self, parser: ConventionalCommitParser) -> None:
"""Test revert breaking inner."""
cc = parser.parse('Revert "feat!: breaking change"')
assert cc is not None
assert cc.is_revert is True
assert cc.reverted_bump == BumpType.MAJOR
# --- Non-conventional messages ---
def test_non_conventional_plain(self, parser: ConventionalCommitParser) -> None:
"""Test non conventional plain."""
assert parser.parse('Update README.md') is None
def test_non_conventional_merge(self, parser: ConventionalCommitParser) -> None:
"""Test non conventional merge."""
assert parser.parse('Merge pull request #42') is None
def test_non_conventional_wip(self, parser: ConventionalCommitParser) -> None:
"""Test non conventional wip."""
assert parser.parse('WIP') is None
def test_non_conventional_empty(self, parser: ConventionalCommitParser) -> None:
"""Test non conventional empty."""
assert parser.parse('') is None
def test_non_conventional_whitespace(self, parser: ConventionalCommitParser) -> None:
"""Test non conventional whitespace."""
assert parser.parse(' ') is None
# --- Edge cases ---
def test_empty_description(self, parser: ConventionalCommitParser) -> None:
"""Test empty description."""
assert parser.parse('feat:') is None
assert parser.parse('feat: ') is None
def test_sha_preserved(self, parser: ConventionalCommitParser) -> None:
"""Test sha preserved."""
cc = parser.parse('fix: bug', sha='abc123')
assert cc is not None
assert cc.sha == 'abc123'
def test_raw_preserved(self, parser: ConventionalCommitParser) -> None:
"""Test raw preserved."""
msg = 'feat(auth): add OAuth2'
cc = parser.parse(msg)
assert cc is not None
assert cc.raw == msg
def test_leading_trailing_whitespace(self, parser: ConventionalCommitParser) -> None:
"""Test leading trailing whitespace."""
cc = parser.parse(' feat: add thing ')
assert cc is not None
assert cc.type == 'feat'
assert cc.description == 'add thing'
def test_colon_in_description(self, parser: ConventionalCommitParser) -> None:
"""Test colon in description."""
cc = parser.parse('fix: handle edge case: null input')
assert cc is not None
assert cc.description == 'handle edge case: null input'
def test_uppercase_type_rejected(self, parser: ConventionalCommitParser) -> None:
"""Types must be lowercase per spec."""
assert parser.parse('FEAT: add thing') is None
assert parser.parse('Fix: bug') is None
# ---------------------------------------------------------------------------
# parse_conventional_commit convenience function
# ---------------------------------------------------------------------------
class TestParseConventionalCommitFunction:
"""Tests for the module-level convenience function."""
def test_delegates_to_parser(self) -> None:
"""Test delegates to parser."""
cc = parse_conventional_commit('feat: add thing')
assert cc is not None
assert cc.type == 'feat'
assert cc.bump == BumpType.MINOR
def test_returns_none_for_non_conventional(self) -> None:
"""Test returns none for non conventional."""
assert parse_conventional_commit('Update README') is None
def test_sha_forwarded(self) -> None:
"""Test sha forwarded."""
cc = parse_conventional_commit('fix: bug', sha='deadbeef')
assert cc is not None
assert cc.sha == 'deadbeef'
# ---------------------------------------------------------------------------
# Custom parser integration
# ---------------------------------------------------------------------------
class TestCustomParser:
"""Test that a custom parser can be used as a drop-in replacement."""
def test_custom_parser_produces_parsed_commit(self) -> None:
"""Test custom parser produces parsed commit."""
class PrefixParser:
"""Parses '[TYPE] description' format."""
def parse(self, message: str, sha: str = '') -> ParsedCommit | None:
"""Parse."""
if not message.startswith('['):
return None
end = message.index(']')
commit_type = message[1:end].lower()
description = message[end + 1 :].strip()
bump = BumpType.MINOR if commit_type == 'feature' else BumpType.PATCH
return ParsedCommit(
sha=sha,
type=commit_type,
description=description,
bump=bump,
raw=message,
)
parser = PrefixParser()
assert isinstance(parser, CommitParser)
cc = parser.parse('[FEATURE] add streaming', sha='abc')
assert cc is not None
assert cc.type == 'feature'
assert cc.bump == BumpType.MINOR
assert cc.description == 'add streaming'
cc2 = parser.parse('[BUGFIX] null pointer', sha='def')
assert cc2 is not None
assert cc2.type == 'bugfix'
assert cc2.bump == BumpType.PATCH
assert parser.parse('not a bracket message') is None