surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| 1 | """Tests for rule loading and filtering from .local.md files. |
| 2 | |
| 3 | Tests cover: |
| 4 | - Loading multiple rule files |
| 5 | - Event-based filtering |
| 6 | - YAML frontmatter parsing |
| 7 | - Legacy pattern to conditions conversion |
| 8 | """ |
| 9 | |
| 10 | import pytest |
| 11 | from pathlib import Path |
| 12 | |
| 13 | from hookify.core.config_loader import ( |
| 14 | Rule, Condition, load_rules, load_rule_file, extract_frontmatter |
| 15 | ) |
| 16 | |
| 17 | |
| 18 | class TestExtractFrontmatter: |
| 19 | """Tests for YAML frontmatter extraction.""" |
| 20 | |
| 21 | def test_simple_frontmatter(self): |
| 22 | """Test parsing simple key-value pairs.""" |
| 23 | content = """--- |
| 24 | name: test-rule |
| 25 | enabled: true |
| 26 | event: bash |
| 27 | action: warn |
| 28 | --- |
| 29 | |
| 30 | Rule message here. |
| 31 | """ |
| 32 | frontmatter, message = extract_frontmatter(content) |
| 33 | |
| 34 | assert frontmatter["name"] == "test-rule" |
| 35 | assert frontmatter["enabled"] is True |
| 36 | assert frontmatter["event"] == "bash" |
| 37 | assert frontmatter["action"] == "warn" |
| 38 | assert message == "Rule message here." |
| 39 | |
| 40 | def test_boolean_values(self): |
| 41 | """Test boolean value parsing (true/false).""" |
| 42 | content = """--- |
| 43 | enabled: true |
| 44 | disabled: false |
| 45 | --- |
| 46 | msg |
| 47 | """ |
| 48 | frontmatter, _ = extract_frontmatter(content) |
| 49 | |
| 50 | assert frontmatter["enabled"] is True |
| 51 | assert frontmatter["disabled"] is False |
| 52 | |
| 53 | def test_quoted_strings(self): |
| 54 | """Test quoted string parsing.""" |
| 55 | content = """--- |
| 56 | pattern: "rm -rf" |
| 57 | name: 'test-name' |
| 58 | --- |
| 59 | msg |
| 60 | """ |
| 61 | frontmatter, _ = extract_frontmatter(content) |
| 62 | |
| 63 | assert frontmatter["pattern"] == "rm -rf" |
| 64 | assert frontmatter["name"] == "test-name" |
| 65 | |
| 66 | def test_conditions_list(self): |
| 67 | """Test parsing conditions as list of dicts.""" |
| 68 | content = """--- |
| 69 | name: test |
| 70 | conditions: |
| 71 | - field: command |
| 72 | operator: contains |
| 73 | pattern: test |
| 74 | - field: file_path |
| 75 | operator: ends_with |
| 76 | pattern: .py |
| 77 | --- |
| 78 | msg |
| 79 | """ |
| 80 | frontmatter, _ = extract_frontmatter(content) |
| 81 | |
| 82 | assert "conditions" in frontmatter |
| 83 | assert len(frontmatter["conditions"]) == 2 |
| 84 | assert frontmatter["conditions"][0]["field"] == "command" |
| 85 | assert frontmatter["conditions"][0]["operator"] == "contains" |
| 86 | assert frontmatter["conditions"][1]["pattern"] == ".py" |
| 87 | |
| 88 | def test_inline_dict_conditions(self): |
| 89 | """Test parsing inline comma-separated dict items.""" |
| 90 | content = """--- |
| 91 | name: test |
| 92 | conditions: |
| 93 | - field: command, operator: regex_match, pattern: test |
| 94 | --- |
| 95 | msg |
| 96 | """ |
| 97 | frontmatter, _ = extract_frontmatter(content) |
| 98 | |
| 99 | assert len(frontmatter["conditions"]) == 1 |
| 100 | assert frontmatter["conditions"][0]["field"] == "command" |
| 101 | assert frontmatter["conditions"][0]["operator"] == "regex_match" |
| 102 | |
| 103 | def test_no_frontmatter(self): |
| 104 | """Test handling content without frontmatter.""" |
| 105 | content = "Just plain text without frontmatter" |
| 106 | frontmatter, message = extract_frontmatter(content) |
| 107 | |
| 108 | assert frontmatter == {} |
| 109 | assert message == content |
| 110 | |
| 111 | def test_incomplete_frontmatter(self): |
| 112 | """Test handling incomplete frontmatter markers.""" |
| 113 | content = """--- |
| 114 | name: test |
| 115 | No closing marker |
| 116 | """ |
| 117 | frontmatter, _ = extract_frontmatter(content) |
| 118 | assert frontmatter == {} |
| 119 | |
| 120 | |
| 121 | class TestLoadRuleFile: |
| 122 | """Tests for loading individual rule files.""" |
| 123 | |
| 124 | def test_load_valid_rule(self, create_rule_file): |
| 125 | """Test loading a valid rule file.""" |
| 126 | content = """--- |
| 127 | name: valid-rule |
| 128 | enabled: true |
| 129 | event: bash |
| 130 | action: block |
| 131 | conditions: |
| 132 | - field: command |
| 133 | operator: contains |
| 134 | pattern: danger |
| 135 | --- |
| 136 | |
| 137 | This is a dangerous command! |
| 138 | """ |
| 139 | rule_file = create_rule_file("valid-rule", content) |
| 140 | rule = load_rule_file(str(rule_file)) |
| 141 | |
| 142 | assert rule is not None |
| 143 | assert rule.name == "valid-rule" |
| 144 | assert rule.enabled is True |
| 145 | assert rule.event == "bash" |
| 146 | assert rule.action == "block" |
| 147 | assert len(rule.conditions) == 1 |
| 148 | assert rule.conditions[0].field == "command" |
| 149 | assert "dangerous command" in rule.message |
| 150 | |
| 151 | def test_load_legacy_pattern_rule(self, create_rule_file): |
| 152 | """Test loading rule with legacy pattern (converts to condition).""" |
| 153 | content = """--- |
| 154 | name: legacy-rule |
| 155 | enabled: true |
| 156 | event: bash |
| 157 | pattern: "rm -rf" |
| 158 | --- |
| 159 | |
| 160 | Old style rule. |
| 161 | """ |
| 162 | rule_file = create_rule_file("legacy-rule", content) |
| 163 | rule = load_rule_file(str(rule_file)) |
| 164 | |
| 165 | assert rule is not None |
| 166 | assert len(rule.conditions) == 1 |
| 167 | assert rule.conditions[0].field == "command" # Inferred from bash event |
| 168 | assert rule.conditions[0].operator == "regex_match" |
| 169 | assert rule.conditions[0].pattern == "rm -rf" |
| 170 | |
| 171 | def test_load_file_event_legacy_pattern(self, create_rule_file): |
| 172 | """Test legacy pattern with file event infers correct field.""" |
| 173 | content = """--- |
| 174 | name: file-legacy |
| 175 | enabled: true |
| 176 | event: file |
| 177 | pattern: "TODO" |
| 178 | --- |
| 179 | |
| 180 | Found TODO. |
| 181 | """ |
| 182 | rule_file = create_rule_file("file-legacy", content) |
| 183 | rule = load_rule_file(str(rule_file)) |
| 184 | |
| 185 | assert rule.conditions[0].field == "new_text" |
| 186 | |
| 187 | def test_load_missing_frontmatter(self, create_rule_file): |
| 188 | """Test loading file without frontmatter returns None.""" |
| 189 | content = "No frontmatter here" |
| 190 | rule_file = create_rule_file("no-frontmatter", content) |
| 191 | rule = load_rule_file(str(rule_file)) |
| 192 | |
| 193 | assert rule is None |
| 194 | |
| 195 | def test_load_nonexistent_file(self): |
| 196 | """Test loading nonexistent file returns None.""" |
| 197 | rule = load_rule_file("/nonexistent/path/hookify.test.local.md") |
| 198 | assert rule is None |
| 199 | |
| 200 | |
| 201 | class TestLoadRules: |
| 202 | """Tests for loading multiple rules with filtering.""" |
| 203 | |
| 204 | def test_load_multiple_rules(self, temp_project_dir, create_rule_file): |
| 205 | """Test loading multiple rule files.""" |
| 206 | create_rule_file("rule1", """--- |
| 207 | name: rule-one |
| 208 | enabled: true |
| 209 | event: bash |
| 210 | conditions: |
| 211 | - field: command |
| 212 | operator: contains |
| 213 | pattern: test1 |
| 214 | --- |
| 215 | Rule 1 |
| 216 | """) |
| 217 | create_rule_file("rule2", """--- |
| 218 | name: rule-two |
| 219 | enabled: true |
| 220 | event: bash |
| 221 | conditions: |
| 222 | - field: command |
| 223 | operator: contains |
| 224 | pattern: test2 |
| 225 | --- |
| 226 | Rule 2 |
| 227 | """) |
| 228 | |
| 229 | rules = load_rules() |
| 230 | |
| 231 | assert len(rules) == 2 |
| 232 | names = {r.name for r in rules} |
| 233 | assert "rule-one" in names |
| 234 | assert "rule-two" in names |
| 235 | |
| 236 | def test_filter_by_event(self, temp_project_dir, create_rule_file): |
| 237 | """Test filtering rules by event type.""" |
| 238 | create_rule_file("bash-rule", """--- |
| 239 | name: bash-rule |
| 240 | enabled: true |
| 241 | event: bash |
| 242 | conditions: |
| 243 | - field: command |
| 244 | operator: contains |
| 245 | pattern: test |
| 246 | --- |
| 247 | Bash rule |
| 248 | """) |
| 249 | create_rule_file("file-rule", """--- |
| 250 | name: file-rule |
| 251 | enabled: true |
| 252 | event: file |
| 253 | conditions: |
| 254 | - field: content |
| 255 | operator: contains |
| 256 | pattern: test |
| 257 | --- |
| 258 | File rule |
| 259 | """) |
| 260 | create_rule_file("all-rule", """--- |
| 261 | name: all-rule |
| 262 | enabled: true |
| 263 | event: all |
| 264 | conditions: |
| 265 | - field: content |
| 266 | operator: contains |
| 267 | pattern: test |
| 268 | --- |
| 269 | All events rule |
| 270 | """) |
| 271 | |
| 272 | # Filter for bash events |
| 273 | bash_rules = load_rules(event="bash") |
| 274 | bash_names = {r.name for r in bash_rules} |
| 275 | assert "bash-rule" in bash_names |
| 276 | assert "all-rule" in bash_names # 'all' matches any event |
| 277 | assert "file-rule" not in bash_names |
| 278 | |
| 279 | # Filter for file events |
| 280 | file_rules = load_rules(event="file") |
| 281 | file_names = {r.name for r in file_rules} |
| 282 | assert "file-rule" in file_names |
| 283 | assert "all-rule" in file_names |
| 284 | assert "bash-rule" not in file_names |
| 285 | |
| 286 | def test_filter_excludes_disabled(self, temp_project_dir, create_rule_file): |
| 287 | """Test that disabled rules are excluded.""" |
| 288 | create_rule_file("enabled-rule", """--- |
| 289 | name: enabled-rule |
| 290 | enabled: true |
| 291 | event: bash |
| 292 | conditions: |
| 293 | - field: command |
| 294 | operator: contains |
| 295 | pattern: test |
| 296 | --- |
| 297 | Enabled |
| 298 | """) |
| 299 | create_rule_file("disabled-rule", """--- |
| 300 | name: disabled-rule |
| 301 | enabled: false |
| 302 | event: bash |
| 303 | conditions: |
| 304 | - field: command |
| 305 | operator: contains |
| 306 | pattern: test |
| 307 | --- |
| 308 | Disabled |
| 309 | """) |
| 310 | |
| 311 | rules = load_rules() |
| 312 | |
| 313 | assert len(rules) == 1 |
| 314 | assert rules[0].name == "enabled-rule" |
| 315 | |
| 316 | def test_load_rules_handles_invalid_file(self, temp_project_dir, create_rule_file): |
| 317 | """Test that invalid files are skipped without crashing.""" |
| 318 | # Valid rule |
| 319 | create_rule_file("valid", """--- |
| 320 | name: valid |
| 321 | enabled: true |
| 322 | event: bash |
| 323 | conditions: |
| 324 | - field: command |
| 325 | operator: contains |
| 326 | pattern: test |
| 327 | --- |
| 328 | Valid rule |
| 329 | """) |
| 330 | # Invalid rule (no frontmatter) |
| 331 | create_rule_file("invalid", "No frontmatter") |
| 332 | |
| 333 | rules = load_rules() |
| 334 | |
| 335 | # Should only load the valid rule |
| 336 | assert len(rules) == 1 |
| 337 | assert rules[0].name == "valid" |
| 338 | |
| 339 | def test_load_with_no_rules(self, temp_project_dir): |
| 340 | """Test loading when no rule files exist.""" |
| 341 | rules = load_rules() |
| 342 | assert rules == [] |
| 343 | |
| 344 | |
| 345 | class TestRuleFromDict: |
| 346 | """Tests for Rule.from_dict construction.""" |
| 347 | |
| 348 | def test_defaults(self): |
| 349 | """Test default values for optional fields.""" |
| 350 | frontmatter = { |
| 351 | "name": "test", |
| 352 | "event": "bash", |
| 353 | } |
| 354 | rule = Rule.from_dict(frontmatter, "message") |
| 355 | |
| 356 | assert rule.name == "test" |
| 357 | assert rule.enabled is True # Default |
| 358 | assert rule.action == "warn" # Default |
| 359 | assert rule.message == "message" |
| 360 | |
| 361 | def test_explicit_values(self): |
| 362 | """Test explicit values override defaults.""" |
| 363 | frontmatter = { |
| 364 | "name": "test", |
| 365 | "enabled": False, |
| 366 | "event": "file", |
| 367 | "action": "block", |
| 368 | "tool_matcher": "Write|Edit", |
| 369 | } |
| 370 | rule = Rule.from_dict(frontmatter, "message") |
| 371 | |
| 372 | assert rule.enabled is False |
| 373 | assert rule.event == "file" |
| 374 | assert rule.action == "block" |
| 375 | assert rule.tool_matcher == "Write|Edit" |
| 376 | |
| 377 | |
| 378 | class TestConditionFromDict: |
| 379 | """Tests for Condition.from_dict construction.""" |
| 380 | |
| 381 | def test_all_fields(self): |
| 382 | """Test creating condition with all fields.""" |
| 383 | data = { |
| 384 | "field": "command", |
| 385 | "operator": "regex_match", |
| 386 | "pattern": r"rm\s+-rf" |
| 387 | } |
| 388 | condition = Condition.from_dict(data) |
| 389 | |
| 390 | assert condition.field == "command" |
| 391 | assert condition.operator == "regex_match" |
| 392 | assert condition.pattern == r"rm\s+-rf" |
| 393 | |
| 394 | def test_default_operator(self): |
| 395 | """Test default operator is regex_match.""" |
| 396 | data = { |
| 397 | "field": "command", |
| 398 | "pattern": "test" |
| 399 | } |
| 400 | condition = Condition.from_dict(data) |
| 401 | |
| 402 | assert condition.operator == "regex_match" |
| 403 | |
| 404 | def test_missing_fields(self): |
| 405 | """Test missing fields default to empty strings.""" |
| 406 | data = {} |
| 407 | condition = Condition.from_dict(data) |
| 408 | |
| 409 | assert condition.field == "" |
| 410 | assert condition.pattern == "" |