diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py index 8fcc63fac..ab2bc5638 100644 --- a/commitizen/cz/customize/customize.py +++ b/commitizen/cz/customize/customize.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Iterable, Mapping from jinja2 import Template @@ -23,6 +23,13 @@ __all__ = ["CustomizeCommitsCz"] +_REQUIRED_ANSWER_MSG = "This answer is required." + + +def _required_validator(val: str) -> bool | str: + """Return True when *val* is non-blank, otherwise an error message.""" + return True if val.strip() else _REQUIRED_ANSWER_MSG + class CustomizeCommitsCz(BaseCommitizen): bump_pattern = defaults.BUMP_PATTERN @@ -50,7 +57,16 @@ def __init__(self, config: BaseConfig) -> None: setattr(self, attr_name, value) def questions(self) -> list[CzQuestion]: - return self.custom_settings.get("questions", [{}]) # type: ignore[return-value] + raw_questions: Iterable[CzQuestion] = self.custom_settings.get( + "questions", [{}] + ) # type: ignore[assignment] + result: list[CzQuestion] = [] + for raw in raw_questions: + q: dict[str, Any] = dict(raw) + if q.get("type") == "input" and q.pop("required", False): + q["validate"] = _required_validator + result.append(q) # type: ignore[arg-type] + return result def message(self, answers: Mapping[str, Any]) -> str: message_template = Template(self.custom_settings.get("message_template", "")) diff --git a/commitizen/question.py b/commitizen/question.py index 043b8f3ba..d397d9856 100644 --- a/commitizen/question.py +++ b/commitizen/question.py @@ -21,6 +21,8 @@ class InputQuestion(TypedDict, total=False): name: str message: str filter: Callable[[str], str] + validate: Callable[[str], bool | str] + required: bool class ConfirmQuestion(TypedDict): diff --git a/docs/customization/config_file.md b/docs/customization/config_file.md index 50185a758..e0a6b14b0 100644 --- a/docs/customization/config_file.md +++ b/docs/customization/config_file.md @@ -45,6 +45,7 @@ Example: type = "input" name = "message" message = "Body." + required = true [[tool.commitizen.customize.questions]] type = "confirm" @@ -181,6 +182,7 @@ Example: | `default` | `Any` | `None` | (OPTIONAL) The default value for this question. | | `filter` | `str` | `None` | (OPTIONAL) Validator for user's answer. **(Work in Progress)** | | `multiline` | `bool` | `False` | (OPTIONAL) Enable multiline support when `type = input`. | +| `required` | `bool` | `False` | (OPTIONAL) When `true` and `type = input`, the user cannot submit an empty answer. An error message ("This answer is required.") is displayed until a non-blank value is entered. | | `use_search_filter` | `bool` | `False` | (OPTIONAL) Enable search/filter functionality for list/select type questions. This allows users to type and filter through the choices. | | `use_jk_keys` | `bool` | `True` | (OPTIONAL) Enable/disable j/k keys for navigation in list/select type questions. Set to false if you prefer arrow keys only. | diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 311eea19a..e6ff5d1ef 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -609,3 +609,105 @@ def test_change_type_map(config): def test_change_type_map_unicode(config_with_unicode): cz = CustomizeCommitsCz(config_with_unicode) assert cz.change_type_map == {"✨ feature": "Feat", "🐛 bug fix": "Fix"} + + +# --------------------------------------------------------------------------- +# Tests for `required` field on input questions +# --------------------------------------------------------------------------- + +TOML_WITH_REQUIRED = r""" + [tool.commitizen.customize] + message_template = "{{message}}" + + [[tool.commitizen.customize.questions]] + type = "input" + name = "message" + message = "Body." + required = true + + [[tool.commitizen.customize.questions]] + type = "input" + name = "optional_note" + message = "Optional note." +""" + +TOML_WITHOUT_REQUIRED = r""" + [tool.commitizen.customize] + message_template = "{{message}}" + + [[tool.commitizen.customize.questions]] + type = "input" + name = "message" + message = "Body." +""" + + +def test_required_question_has_validate_callable(): + """A question with required=true should expose a validate callable.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + questions = cz.questions() + + required_q = questions[0] + assert "validate" in required_q + assert callable(required_q["validate"]) + + +def test_required_field_is_removed_from_question_dict(): + """The `required` key must not be forwarded to questionary.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + for q in cz.questions(): + assert "required" not in q + + +def test_required_validator_rejects_empty_input(): + """Validator must reject empty and whitespace-only strings.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + validate = cz.questions()[0]["validate"] + + assert validate("") is not True + assert validate(" ") is not True + assert isinstance(validate(""), str) # error message + + +def test_required_validator_accepts_nonempty_input(): + """Validator must accept any non-whitespace-only input.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + validate = cz.questions()[0]["validate"] + + assert validate("hello") is True + assert validate(" hi ") is True + + +def test_optional_question_has_no_validate(): + """Questions without required=true must not get a validate callable.""" + config = TomlConfig(data=TOML_WITHOUT_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + assert "validate" not in cz.questions()[0] + + +def test_optional_question_in_mixed_config(): + """Only questions with required=true receive a validator; others are unaffected.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + questions = cz.questions() + + required_q = questions[0] + optional_q = questions[1] + + assert "validate" in required_q + assert "validate" not in optional_q + + +def test_required_does_not_mutate_config_settings(): + """Processing questions must not mutate the underlying config data.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + # Call questions() twice; the second call must still work correctly. + cz.questions() + questions = cz.questions() + assert "required" not in questions[0] + assert "validate" in questions[0]