spacehub

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

claude-code / plugins / hookify / tests / test_rule_loading.py
410 lines · 10.5 KBRaw
1"""Tests for rule loading and filtering from .local.md files.
2
3Tests cover:
4- Loading multiple rule files
5- Event-based filtering
6- YAML frontmatter parsing
7- Legacy pattern to conditions conversion
8"""
9
10import pytest
11from pathlib import Path
12
13from hookify.core.config_loader import (
14 Rule, Condition, load_rules, load_rule_file, extract_frontmatter
15)
16
17
18class TestExtractFrontmatter:
19 """Tests for YAML frontmatter extraction."""
20
21 def test_simple_frontmatter(self):
22 """Test parsing simple key-value pairs."""
23 content = """---
24name: test-rule
25enabled: true
26event: bash
27action: warn
28---
29
30Rule 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 = """---
43enabled: true
44disabled: false
45---
46msg
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 = """---
56pattern: "rm -rf"
57name: 'test-name'
58---
59msg
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 = """---
69name: test
70conditions:
71 - field: command
72 operator: contains
73 pattern: test
74 - field: file_path
75 operator: ends_with
76 pattern: .py
77---
78msg
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 = """---
91name: test
92conditions:
93 - field: command, operator: regex_match, pattern: test
94---
95msg
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 = """---
114name: test
115No closing marker
116"""
117 frontmatter, _ = extract_frontmatter(content)
118 assert frontmatter == {}
119
120
121class 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 = """---
127name: valid-rule
128enabled: true
129event: bash
130action: block
131conditions:
132 - field: command
133 operator: contains
134 pattern: danger
135---
136
137This 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 = """---
154name: legacy-rule
155enabled: true
156event: bash
157pattern: "rm -rf"
158---
159
160Old 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 = """---
174name: file-legacy
175enabled: true
176event: file
177pattern: "TODO"
178---
179
180Found 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
201class 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", """---
207name: rule-one
208enabled: true
209event: bash
210conditions:
211 - field: command
212 operator: contains
213 pattern: test1
214---
215Rule 1
216""")
217 create_rule_file("rule2", """---
218name: rule-two
219enabled: true
220event: bash
221conditions:
222 - field: command
223 operator: contains
224 pattern: test2
225---
226Rule 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", """---
239name: bash-rule
240enabled: true
241event: bash
242conditions:
243 - field: command
244 operator: contains
245 pattern: test
246---
247Bash rule
248""")
249 create_rule_file("file-rule", """---
250name: file-rule
251enabled: true
252event: file
253conditions:
254 - field: content
255 operator: contains
256 pattern: test
257---
258File rule
259""")
260 create_rule_file("all-rule", """---
261name: all-rule
262enabled: true
263event: all
264conditions:
265 - field: content
266 operator: contains
267 pattern: test
268---
269All 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", """---
289name: enabled-rule
290enabled: true
291event: bash
292conditions:
293 - field: command
294 operator: contains
295 pattern: test
296---
297Enabled
298""")
299 create_rule_file("disabled-rule", """---
300name: disabled-rule
301enabled: false
302event: bash
303conditions:
304 - field: command
305 operator: contains
306 pattern: test
307---
308Disabled
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", """---
320name: valid
321enabled: true
322event: bash
323conditions:
324 - field: command
325 operator: contains
326 pattern: test
327---
328Valid 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
345class 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
378class 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 == ""