Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions plugins/hookify/core/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions plugins/hookify/tests/test_config_loader.py
Original file line number Diff line number Diff line change
@@ -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()