diff --git a/plugins/hookify/core/config_loader.py b/plugins/hookify/core/config_loader.py index fa2fc3e36f..d31f717c28 100644 --- a/plugins/hookify/core/config_loader.py +++ b/plugins/hookify/core/config_loader.py @@ -12,6 +12,76 @@ from dataclasses import dataclass, field +BOOLEAN_TRUE_VALUES = {'true', 'yes', 'on', '1'} +BOOLEAN_FALSE_VALUES = {'false', 'no', 'off', '0'} +BOOLEAN_FRONTMATTER_FIELDS = {'enabled'} + + +def _strip_inline_comment(value: str) -> str: + """Remove an unquoted YAML-style inline comment from a scalar value.""" + in_single_quote = False + in_double_quote = False + + for index, char in enumerate(value): + if char == "'" and not in_double_quote: + in_single_quote = not in_single_quote + elif char == '"' and not in_single_quote: + in_double_quote = not in_double_quote + elif ( + char == '#' + and not in_single_quote + and not in_double_quote + and (index == 0 or value[index - 1].isspace()) + ): + return value[:index].rstrip() + + return value + + +def _strip_quotes(value: str) -> str: + """Strip the simple wrapping quotes supported by the frontmatter parser.""" + return value.strip('"').strip("'") + + +def _parse_boolean(value: Any, field_name: str = 'enabled') -> bool: + """Parse a boolean frontmatter field using common YAML spellings.""" + if isinstance(value, bool): + return value + + if isinstance(value, int) and not isinstance(value, bool): + if value in (0, 1): + return bool(value) + + if isinstance(value, str): + normalized = _strip_quotes(_strip_inline_comment(value.strip())).strip().lower() + if normalized in BOOLEAN_TRUE_VALUES: + return True + if normalized in BOOLEAN_FALSE_VALUES: + return False + + raise ValueError( + f"{field_name} must be a boolean " + "(true/false, yes/no, on/off, or 1/0)" + ) + + +def _parse_frontmatter_scalar(key: str, value: str) -> Any: + """Parse a top-level frontmatter scalar value.""" + value = value.strip() + + if key in BOOLEAN_FRONTMATTER_FIELDS: + return _parse_boolean(value, key) + + value = _strip_quotes(value) + + # Preserve the parser's historical true/false handling for other fields. + if value.lower() == 'true': + return True + if value.lower() == 'false': + return False + return value + + @dataclass class Condition: """A single condition for matching.""" @@ -74,7 +144,7 @@ def from_dict(cls, frontmatter: Dict[str, Any], message: str) -> 'Rule': return cls( name=frontmatter.get('name', 'unnamed'), - enabled=frontmatter.get('enabled', True), + enabled=_parse_boolean(frontmatter.get('enabled', True)), event=frontmatter.get('event', 'all'), pattern=simple_pattern, conditions=conditions, @@ -144,12 +214,7 @@ def extract_frontmatter(content: str) -> tuple[Dict[str, Any], str]: current_list = [] else: # Simple key-value pair - value = value.strip('"').strip("'") - if value.lower() == 'true': - value = True - elif value.lower() == 'false': - value = False - frontmatter[key] = value + frontmatter[key] = _parse_frontmatter_scalar(key, value) # List item (starts with -) elif stripped.startswith('-') and in_list: diff --git a/plugins/hookify/tests/test_config_loader.py b/plugins/hookify/tests/test_config_loader.py new file mode 100644 index 0000000000..d3c363f43d --- /dev/null +++ b/plugins/hookify/tests/test_config_loader.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import os +import sys +import tempfile +import unittest +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from hookify.core.config_loader import Rule, extract_frontmatter, load_rules # noqa: E402 + + +def rule_for_enabled(value): + frontmatter, message = extract_frontmatter( + f"---\n" + f"name: r\n" + f"enabled: {value}\n" + f"event: bash\n" + f"pattern: rm -rf\n" + f"---\n" + f"msg\n" + ) + return frontmatter, Rule.from_dict(frontmatter, message) + + +class ConfigLoaderEnabledTest(unittest.TestCase): + def test_disabled_boolean_spellings_parse_false(self): + disabled_values = [ + "no", "No", "NO", + "false", "False", "FALSE", + "off", "Off", "OFF", + "0", + ] + + for value in disabled_values: + with self.subTest(value=value): + frontmatter, rule = rule_for_enabled(value) + + self.assertIs(frontmatter["enabled"], False) + self.assertIs(rule.enabled, False) + + def test_enabled_boolean_spellings_parse_true(self): + enabled_values = [ + "yes", "Yes", "YES", + "true", "True", "TRUE", + "on", "On", "ON", + "1", + ] + + for value in enabled_values: + with self.subTest(value=value): + frontmatter, rule = rule_for_enabled(value) + + self.assertIs(frontmatter["enabled"], True) + self.assertIs(rule.enabled, True) + + def test_inline_comment_is_ignored_for_enabled(self): + frontmatter, rule = rule_for_enabled("no # disabled for now") + + self.assertIs(frontmatter["enabled"], False) + self.assertIs(rule.enabled, False) + + def test_invalid_enabled_value_is_rejected(self): + with self.assertRaises(ValueError): + rule_for_enabled("maybe") + + def test_load_rules_omits_enabled_no_rule(self): + previous_cwd = os.getcwd() + with tempfile.TemporaryDirectory() as temp_dir: + rule_dir = Path(temp_dir) / ".claude" + rule_dir.mkdir() + (rule_dir / "hookify.disabled.local.md").write_text( + "---\n" + "name: disabled\n" + "enabled: no\n" + "event: bash\n" + "pattern: rm -rf\n" + "---\n" + "msg\n" + ) + + try: + os.chdir(temp_dir) + self.assertEqual(load_rules(event="bash"), []) + finally: + os.chdir(previous_cwd) + + +if __name__ == "__main__": + unittest.main()