Skip to main content

agnix_rules/
lib.rs

1//! Validation rules for agnix - agent configuration linter.
2//!
3//! This crate provides the rule definitions used by agnix to validate
4//! agent configurations including Skills, Hooks, MCP servers, Memory files,
5//! and Plugins.
6//!
7//! # Usage
8//!
9//! ```
10//! use agnix_rules::{RULES_DATA, VALID_TOOLS, TOOL_RULE_PREFIXES};
11//!
12//! // RULES_DATA is a static array of (rule_id, rule_name) tuples
13//! for (id, name) in RULES_DATA {
14//!     println!("{}: {}", id, name);
15//! }
16//!
17//! // VALID_TOOLS contains all tool names from rules.json
18//! for tool in VALID_TOOLS {
19//!     println!("Tool: {}", tool);
20//! }
21//!
22//! // TOOL_RULE_PREFIXES maps rule prefixes to their tools
23//! for (prefix, tool) in TOOL_RULE_PREFIXES {
24//!     println!("Prefix {} -> Tool {}", prefix, tool);
25//! }
26//! ```
27//!
28//! # Rule Categories
29//!
30//! - **AS-xxx**: Agent Skills
31//! - **CC-xxx**: Claude Code (Hooks, Skills, Memory, etc.)
32//! - **MCP-xxx**: Model Context Protocol
33//! - **COP-xxx**: GitHub Copilot
34//! - **CUR-xxx**: Cursor
35//! - **XML-xxx**: XML/XSLT based configs
36//! - **XP-xxx**: Cross-platform rules
37
38// Include the auto-generated rules data from build.rs
39include!(concat!(env!("OUT_DIR"), "/rules_data.rs"));
40
41/// Returns the total number of rules.
42pub fn rule_count() -> usize {
43    RULES_DATA.len()
44}
45
46/// Looks up a rule by ID, returning the name if found.
47pub fn get_rule_name(id: &str) -> Option<&'static str> {
48    RULES_DATA
49        .iter()
50        .find(|(rule_id, _)| *rule_id == id)
51        .map(|(_, name)| *name)
52}
53
54/// Returns the list of valid tool names derived from rules.json.
55///
56/// These are tools that have at least one rule specifically targeting them.
57pub fn valid_tools() -> &'static [&'static str] {
58    VALID_TOOLS
59}
60
61/// Returns the tool name for a given rule ID prefix, if any.
62///
63/// Only returns a tool if ALL rules with that prefix have the same tool.
64/// Prefixes with mixed tools or no tools return None.
65///
66/// # Example
67/// ```
68/// use agnix_rules::get_tool_for_prefix;
69///
70/// assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
71/// assert_eq!(get_tool_for_prefix("COP-"), Some("github-copilot"));
72/// assert_eq!(get_tool_for_prefix("CUR-"), Some("cursor"));
73/// // Generic prefixes without a consistent tool return None
74/// assert_eq!(get_tool_for_prefix("MCP-"), None);
75/// ```
76pub fn get_tool_for_prefix(prefix: &str) -> Option<&'static str> {
77    TOOL_RULE_PREFIXES
78        .iter()
79        .find(|(p, _)| *p == prefix)
80        .map(|(_, tool)| *tool)
81}
82
83/// Returns all rule prefixes associated with a tool.
84///
85/// # Example
86/// ```
87/// use agnix_rules::get_prefixes_for_tool;
88///
89/// let prefixes = get_prefixes_for_tool("claude-code");
90/// assert!(prefixes.contains(&"CC-HK-"));
91/// assert!(prefixes.contains(&"CC-SK-"));
92/// ```
93pub fn get_prefixes_for_tool(tool: &str) -> Vec<&'static str> {
94    TOOL_RULE_PREFIXES
95        .iter()
96        .filter(|(_, t)| t.eq_ignore_ascii_case(tool))
97        .map(|(prefix, _)| *prefix)
98        .collect()
99}
100
101/// Check if a tool name is valid (exists in rules.json).
102///
103/// This performs case-insensitive matching.
104pub fn is_valid_tool(tool: &str) -> bool {
105    VALID_TOOLS.iter().any(|t| t.eq_ignore_ascii_case(tool))
106}
107
108/// Normalize a tool name to its canonical form from rules.json.
109///
110/// Returns the canonical name if found, None otherwise.
111/// Performs case-insensitive matching.
112///
113/// # Example
114/// ```
115/// use agnix_rules::normalize_tool_name;
116///
117/// assert_eq!(normalize_tool_name("Claude-Code"), Some("claude-code"));
118/// assert_eq!(normalize_tool_name("GITHUB-COPILOT"), Some("github-copilot"));
119/// assert_eq!(normalize_tool_name("unknown"), None);
120/// ```
121pub fn normalize_tool_name(tool: &str) -> Option<&'static str> {
122    VALID_TOOLS
123        .iter()
124        .find(|t| t.eq_ignore_ascii_case(tool))
125        .copied()
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_rules_data_not_empty() {
134        assert!(!RULES_DATA.is_empty(), "RULES_DATA should not be empty");
135    }
136
137    #[test]
138    fn test_rule_count() {
139        assert_eq!(rule_count(), RULES_DATA.len());
140    }
141
142    #[test]
143    fn test_get_rule_name_exists() {
144        // AS-001 should always exist
145        let name = get_rule_name("AS-001");
146        assert!(name.is_some(), "AS-001 should exist");
147    }
148
149    #[test]
150    fn test_get_rule_name_not_exists() {
151        let name = get_rule_name("NONEXISTENT-999");
152        assert!(name.is_none(), "Nonexistent rule should return None");
153    }
154
155    #[test]
156    fn test_no_duplicate_ids() {
157        let mut ids: Vec<&str> = RULES_DATA.iter().map(|(id, _)| *id).collect();
158        let original_len = ids.len();
159        ids.sort();
160        ids.dedup();
161        assert_eq!(ids.len(), original_len, "Should have no duplicate rule IDs");
162    }
163
164    // ===== VALID_TOOLS Tests =====
165
166    #[test]
167    fn test_valid_tools_not_empty() {
168        assert!(!VALID_TOOLS.is_empty(), "VALID_TOOLS should not be empty");
169    }
170
171    #[test]
172    fn test_valid_tools_contains_claude_code() {
173        assert!(
174            VALID_TOOLS.contains(&"claude-code"),
175            "VALID_TOOLS should contain 'claude-code'"
176        );
177    }
178
179    #[test]
180    fn test_valid_tools_contains_github_copilot() {
181        assert!(
182            VALID_TOOLS.contains(&"github-copilot"),
183            "VALID_TOOLS should contain 'github-copilot'"
184        );
185    }
186
187    #[test]
188    fn test_valid_tools_contains_cursor() {
189        assert!(
190            VALID_TOOLS.contains(&"cursor"),
191            "VALID_TOOLS should contain 'cursor'"
192        );
193    }
194
195    #[test]
196    fn test_valid_tools_helper() {
197        let tools = valid_tools();
198        assert!(!tools.is_empty());
199        assert!(tools.contains(&"claude-code"));
200    }
201
202    // ===== TOOL_RULE_PREFIXES Tests =====
203
204    #[test]
205    fn test_tool_rule_prefixes_not_empty() {
206        assert!(
207            !TOOL_RULE_PREFIXES.is_empty(),
208            "TOOL_RULE_PREFIXES should not be empty"
209        );
210    }
211
212    #[test]
213    fn test_tool_rule_prefixes_cc_hk() {
214        // CC-HK-* rules are for claude-code
215        let found = TOOL_RULE_PREFIXES
216            .iter()
217            .find(|(prefix, _)| *prefix == "CC-HK-");
218        assert!(found.is_some(), "Should have CC-HK- prefix");
219        assert_eq!(found.unwrap().1, "claude-code");
220    }
221
222    #[test]
223    fn test_tool_rule_prefixes_cop() {
224        // COP-* rules are for github-copilot
225        let found = TOOL_RULE_PREFIXES
226            .iter()
227            .find(|(prefix, _)| *prefix == "COP-");
228        assert!(found.is_some(), "Should have COP- prefix");
229        assert_eq!(found.unwrap().1, "github-copilot");
230    }
231
232    #[test]
233    fn test_tool_rule_prefixes_cur() {
234        // CUR-* rules are for cursor
235        let found = TOOL_RULE_PREFIXES
236            .iter()
237            .find(|(prefix, _)| *prefix == "CUR-");
238        assert!(found.is_some(), "Should have CUR- prefix");
239        assert_eq!(found.unwrap().1, "cursor");
240    }
241
242    #[test]
243    fn test_get_tool_for_prefix_claude_code() {
244        assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
245        assert_eq!(get_tool_for_prefix("CC-SK-"), Some("claude-code"));
246        assert_eq!(get_tool_for_prefix("CC-AG-"), Some("claude-code"));
247        assert_eq!(get_tool_for_prefix("CC-PL-"), Some("claude-code"));
248        // Note: CC-MEM- is NOT in the mapping because some CC-MEM-* rules
249        // have empty applies_to (making them generic)
250        assert_eq!(get_tool_for_prefix("CC-MEM-"), None);
251    }
252
253    #[test]
254    fn test_get_tool_for_prefix_copilot() {
255        assert_eq!(get_tool_for_prefix("COP-"), Some("github-copilot"));
256    }
257
258    #[test]
259    fn test_get_tool_for_prefix_cursor() {
260        assert_eq!(get_tool_for_prefix("CUR-"), Some("cursor"));
261    }
262
263    #[test]
264    fn test_get_tool_for_prefix_generic() {
265        // These prefixes have no tool specified, so they are not in the mapping
266        // MCP-*, XML-*, XP-* rules don't specify a tool - they're generic
267        assert_eq!(get_tool_for_prefix("MCP-"), None);
268        assert_eq!(get_tool_for_prefix("XML-"), None);
269        assert_eq!(get_tool_for_prefix("XP-"), None);
270        // Note: Some prefixes like AS-*, PE-*, AGM-*, REF-* have mixed tools
271        // (some rules have tool, some don't), so the build script excludes them
272        // from the mapping to avoid ambiguity
273    }
274
275    #[test]
276    fn test_get_tool_for_prefix_unknown() {
277        assert_eq!(get_tool_for_prefix("UNKNOWN-"), None);
278    }
279
280    // ===== Mixed-Tool Prefix Scenario Tests (Review-requested coverage) =====
281
282    #[test]
283    fn test_mixed_tool_prefix_as() {
284        // AS-* prefix is all generic (no tool specified for any AS-* rule)
285        // so it returns None - no consistent tool
286        assert_eq!(get_tool_for_prefix("AS-"), None);
287    }
288
289    #[test]
290    fn test_mixed_tool_prefix_cc_mem() {
291        // CC-MEM-* prefix has mixed tools: some rules have "claude-code",
292        // some have null/no tool. Since they're not all the same tool,
293        // the prefix returns None.
294        assert_eq!(get_tool_for_prefix("CC-MEM-"), None);
295    }
296
297    #[test]
298    fn test_consistent_tool_prefix_cc_hk() {
299        // CC-HK-* prefix is consistent: all rules have "claude-code"
300        // so it returns Some("claude-code")
301        assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
302    }
303
304    #[test]
305    fn test_get_prefixes_for_tool_claude_code() {
306        let prefixes = get_prefixes_for_tool("claude-code");
307        assert!(!prefixes.is_empty());
308        assert!(prefixes.contains(&"CC-HK-"));
309        assert!(prefixes.contains(&"CC-SK-"));
310        assert!(prefixes.contains(&"CC-AG-"));
311        assert!(prefixes.contains(&"CC-PL-"));
312        // Note: CC-MEM- is NOT in the list because some CC-MEM-* rules
313        // have empty applies_to (making them generic rules)
314        assert!(!prefixes.contains(&"CC-MEM-"));
315    }
316
317    #[test]
318    fn test_get_prefixes_for_tool_copilot() {
319        let prefixes = get_prefixes_for_tool("github-copilot");
320        assert!(!prefixes.is_empty());
321        assert!(prefixes.contains(&"COP-"));
322    }
323
324    #[test]
325    fn test_get_prefixes_for_tool_cursor() {
326        let prefixes = get_prefixes_for_tool("cursor");
327        assert!(!prefixes.is_empty());
328        assert!(prefixes.contains(&"CUR-"));
329    }
330
331    #[test]
332    fn test_get_prefixes_for_tool_unknown() {
333        let prefixes = get_prefixes_for_tool("unknown-tool");
334        assert!(prefixes.is_empty());
335    }
336
337    // ===== is_valid_tool Tests =====
338
339    #[test]
340    fn test_is_valid_tool_claude_code() {
341        assert!(is_valid_tool("claude-code"));
342        assert!(is_valid_tool("Claude-Code")); // case insensitive
343        assert!(is_valid_tool("CLAUDE-CODE")); // case insensitive
344    }
345
346    #[test]
347    fn test_is_valid_tool_copilot() {
348        assert!(is_valid_tool("github-copilot"));
349        assert!(is_valid_tool("GitHub-Copilot")); // case insensitive
350    }
351
352    #[test]
353    fn test_is_valid_tool_unknown() {
354        assert!(!is_valid_tool("unknown-tool"));
355        assert!(!is_valid_tool(""));
356    }
357
358    // ===== normalize_tool_name Tests =====
359
360    #[test]
361    fn test_normalize_tool_name_claude_code() {
362        assert_eq!(normalize_tool_name("claude-code"), Some("claude-code"));
363        assert_eq!(normalize_tool_name("Claude-Code"), Some("claude-code"));
364        assert_eq!(normalize_tool_name("CLAUDE-CODE"), Some("claude-code"));
365    }
366
367    #[test]
368    fn test_normalize_tool_name_copilot() {
369        assert_eq!(
370            normalize_tool_name("github-copilot"),
371            Some("github-copilot")
372        );
373        assert_eq!(
374            normalize_tool_name("GitHub-Copilot"),
375            Some("github-copilot")
376        );
377    }
378
379    #[test]
380    fn test_normalize_tool_name_unknown() {
381        assert_eq!(normalize_tool_name("unknown-tool"), None);
382        assert_eq!(normalize_tool_name(""), None);
383    }
384
385    // ===== get_prefixes_for_tool Edge Case Tests =====
386
387    #[test]
388    fn test_get_prefixes_for_tool_empty_string() {
389        // Empty string should return empty Vec (no tool matches empty)
390        let prefixes = get_prefixes_for_tool("");
391        assert!(
392            prefixes.is_empty(),
393            "Empty string tool should return empty Vec"
394        );
395    }
396
397    #[test]
398    fn test_get_prefixes_for_tool_unknown_tool() {
399        // Unknown tool should return empty Vec
400        let prefixes = get_prefixes_for_tool("nonexistent-tool");
401        assert!(prefixes.is_empty(), "Unknown tool should return empty Vec");
402    }
403
404    #[test]
405    fn test_get_prefixes_for_tool_claude_code_multiple_prefixes() {
406        // claude-code should have multiple prefixes (CC-HK-, CC-SK-, CC-AG-, CC-PL-)
407        let prefixes = get_prefixes_for_tool("claude-code");
408        assert!(
409            prefixes.len() > 1,
410            "claude-code should have multiple prefixes, got {}",
411            prefixes.len()
412        );
413        // Verify some expected prefixes are present
414        assert!(
415            prefixes.contains(&"CC-HK-"),
416            "claude-code prefixes should include CC-HK-"
417        );
418        assert!(
419            prefixes.contains(&"CC-SK-"),
420            "claude-code prefixes should include CC-SK-"
421        );
422    }
423
424    // ===== get_tool_for_prefix Edge Case Tests =====
425
426    #[test]
427    fn test_get_tool_for_prefix_empty_string() {
428        // Empty prefix should return None
429        assert_eq!(
430            get_tool_for_prefix(""),
431            None,
432            "Empty prefix should return None"
433        );
434    }
435
436    #[test]
437    fn test_get_tool_for_prefix_unknown_prefix() {
438        // Unknown prefix should return None
439        assert_eq!(
440            get_tool_for_prefix("NONEXISTENT-"),
441            None,
442            "Unknown prefix should return None"
443        );
444        assert_eq!(
445            get_tool_for_prefix("XX-"),
446            None,
447            "XX- prefix should return None"
448        );
449    }
450
451    #[test]
452    fn test_get_tool_for_prefix_partial_match_not_supported() {
453        // Partial prefixes should not match
454        assert_eq!(
455            get_tool_for_prefix("CC-"),
456            None,
457            "Partial prefix CC- (without HK/SK/AG) should not match"
458        );
459        assert_eq!(
460            get_tool_for_prefix("C"),
461            None,
462            "Single character should not match"
463        );
464    }
465}