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 authoring family IDs derived from rules.json `authoring.families`.
62pub fn authoring_families() -> &'static [&'static str] {
63    AUTHORING_FAMILIES
64}
65
66/// Returns the raw authoring catalog JSON generated from rules.json.
67pub fn authoring_catalog_json() -> &'static str {
68    AUTHORING_CATALOG_JSON
69}
70
71/// Returns the tool name for a given rule ID prefix, if any.
72///
73/// Only returns a tool if ALL rules with that prefix have the same tool.
74/// Prefixes with mixed tools or no tools return None.
75///
76/// # Example
77/// ```
78/// use agnix_rules::get_tool_for_prefix;
79///
80/// assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
81/// assert_eq!(get_tool_for_prefix("COP-"), Some("github-copilot"));
82/// assert_eq!(get_tool_for_prefix("CUR-"), Some("cursor"));
83/// // Generic prefixes without a consistent tool return None
84/// assert_eq!(get_tool_for_prefix("MCP-"), None);
85/// ```
86pub fn get_tool_for_prefix(prefix: &str) -> Option<&'static str> {
87    TOOL_RULE_PREFIXES
88        .iter()
89        .find(|(p, _)| *p == prefix)
90        .map(|(_, tool)| *tool)
91}
92
93/// Returns all rule prefixes associated with a tool.
94///
95/// # Example
96/// ```
97/// use agnix_rules::get_prefixes_for_tool;
98///
99/// let prefixes = get_prefixes_for_tool("claude-code");
100/// assert!(prefixes.contains(&"CC-HK-"));
101/// assert!(prefixes.contains(&"CC-SK-"));
102/// ```
103pub fn get_prefixes_for_tool(tool: &str) -> Vec<&'static str> {
104    TOOL_RULE_PREFIXES
105        .iter()
106        .filter(|(_, t)| t.eq_ignore_ascii_case(tool))
107        .map(|(prefix, _)| *prefix)
108        .collect()
109}
110
111/// Check if a tool name is valid (exists in rules.json).
112///
113/// This performs case-insensitive matching.
114pub fn is_valid_tool(tool: &str) -> bool {
115    VALID_TOOLS.iter().any(|t| t.eq_ignore_ascii_case(tool))
116}
117
118/// Normalize a tool name to its canonical form from rules.json.
119///
120/// Returns the canonical name if found, None otherwise.
121/// Performs case-insensitive matching.
122///
123/// # Example
124/// ```
125/// use agnix_rules::normalize_tool_name;
126///
127/// assert_eq!(normalize_tool_name("Claude-Code"), Some("claude-code"));
128/// assert_eq!(normalize_tool_name("GITHUB-COPILOT"), Some("github-copilot"));
129/// assert_eq!(normalize_tool_name("unknown"), None);
130/// ```
131pub fn normalize_tool_name(tool: &str) -> Option<&'static str> {
132    VALID_TOOLS
133        .iter()
134        .find(|t| t.eq_ignore_ascii_case(tool))
135        .copied()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    #[allow(clippy::const_is_empty)]
144    fn test_rules_data_not_empty() {
145        assert!(!RULES_DATA.is_empty(), "RULES_DATA should not be empty");
146    }
147
148    #[test]
149    fn test_rule_count() {
150        assert_eq!(rule_count(), RULES_DATA.len());
151    }
152
153    #[test]
154    fn test_get_rule_name_exists() {
155        // AS-001 should always exist
156        let name = get_rule_name("AS-001");
157        assert!(name.is_some(), "AS-001 should exist");
158    }
159
160    #[test]
161    fn test_get_rule_name_not_exists() {
162        let name = get_rule_name("NONEXISTENT-999");
163        assert!(name.is_none(), "Nonexistent rule should return None");
164    }
165
166    #[test]
167    fn test_no_duplicate_ids() {
168        let mut ids: Vec<&str> = RULES_DATA.iter().map(|(id, _)| *id).collect();
169        let original_len = ids.len();
170        ids.sort();
171        ids.dedup();
172        assert_eq!(ids.len(), original_len, "Should have no duplicate rule IDs");
173    }
174
175    // ===== VALID_TOOLS Tests =====
176
177    #[test]
178    #[allow(clippy::const_is_empty)]
179    fn test_valid_tools_not_empty() {
180        assert!(!VALID_TOOLS.is_empty(), "VALID_TOOLS should not be empty");
181    }
182
183    #[test]
184    fn test_valid_tools_contains_claude_code() {
185        assert!(
186            VALID_TOOLS.contains(&"claude-code"),
187            "VALID_TOOLS should contain 'claude-code'"
188        );
189    }
190
191    #[test]
192    fn test_valid_tools_contains_github_copilot() {
193        assert!(
194            VALID_TOOLS.contains(&"github-copilot"),
195            "VALID_TOOLS should contain 'github-copilot'"
196        );
197    }
198
199    #[test]
200    fn test_valid_tools_contains_cursor() {
201        assert!(
202            VALID_TOOLS.contains(&"cursor"),
203            "VALID_TOOLS should contain 'cursor'"
204        );
205    }
206
207    #[test]
208    fn test_valid_tools_helper() {
209        let tools = valid_tools();
210        assert!(!tools.is_empty());
211        assert!(tools.contains(&"claude-code"));
212    }
213
214    // ===== AUTHORING catalog tests =====
215
216    #[test]
217    #[allow(clippy::const_is_empty)]
218    fn test_authoring_families_not_empty() {
219        assert!(
220            !AUTHORING_FAMILIES.is_empty(),
221            "AUTHORING_FAMILIES should not be empty"
222        );
223    }
224
225    #[test]
226    fn test_authoring_families_contains_core_families() {
227        let families = authoring_families();
228        assert!(families.contains(&"skill"));
229        assert!(families.contains(&"agent"));
230        assert!(families.contains(&"hooks"));
231        assert!(families.contains(&"mcp"));
232    }
233
234    #[test]
235    fn test_authoring_catalog_json_is_valid_json() {
236        let parsed: serde_json::Value = serde_json::from_str(authoring_catalog_json())
237            .expect("AUTHORING_CATALOG_JSON should be valid JSON");
238        assert!(
239            parsed.is_object(),
240            "authoring catalog should be a JSON object"
241        );
242    }
243
244    // ===== TOOL_RULE_PREFIXES Tests =====
245
246    #[test]
247    #[allow(clippy::const_is_empty)]
248    fn test_tool_rule_prefixes_not_empty() {
249        assert!(
250            !TOOL_RULE_PREFIXES.is_empty(),
251            "TOOL_RULE_PREFIXES should not be empty"
252        );
253    }
254
255    #[test]
256    fn test_tool_rule_prefixes_cc_hk() {
257        // CC-HK-* rules are for claude-code
258        let found = TOOL_RULE_PREFIXES
259            .iter()
260            .find(|(prefix, _)| *prefix == "CC-HK-");
261        assert!(found.is_some(), "Should have CC-HK- prefix");
262        assert_eq!(found.unwrap().1, "claude-code");
263    }
264
265    #[test]
266    fn test_tool_rule_prefixes_cop() {
267        // COP-* rules are for github-copilot
268        let found = TOOL_RULE_PREFIXES
269            .iter()
270            .find(|(prefix, _)| *prefix == "COP-");
271        assert!(found.is_some(), "Should have COP- prefix");
272        assert_eq!(found.unwrap().1, "github-copilot");
273    }
274
275    #[test]
276    fn test_tool_rule_prefixes_cur() {
277        // CUR-* rules are for cursor
278        let found = TOOL_RULE_PREFIXES
279            .iter()
280            .find(|(prefix, _)| *prefix == "CUR-");
281        assert!(found.is_some(), "Should have CUR- prefix");
282        assert_eq!(found.unwrap().1, "cursor");
283    }
284
285    #[test]
286    fn test_get_tool_for_prefix_claude_code() {
287        assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
288        assert_eq!(get_tool_for_prefix("CC-SK-"), Some("claude-code"));
289        assert_eq!(get_tool_for_prefix("CC-AG-"), Some("claude-code"));
290        assert_eq!(get_tool_for_prefix("CC-PL-"), Some("claude-code"));
291        // Note: CC-MEM- is NOT in the mapping because some CC-MEM-* rules
292        // have empty applies_to (making them generic)
293        assert_eq!(get_tool_for_prefix("CC-MEM-"), None);
294    }
295
296    #[test]
297    fn test_get_tool_for_prefix_copilot() {
298        assert_eq!(get_tool_for_prefix("COP-"), Some("github-copilot"));
299    }
300
301    #[test]
302    fn test_get_tool_for_prefix_cursor() {
303        assert_eq!(get_tool_for_prefix("CUR-"), Some("cursor"));
304    }
305
306    #[test]
307    fn test_get_tool_for_prefix_generic() {
308        // These prefixes have no tool specified, so they are not in the mapping
309        // MCP-*, XML-*, XP-* rules don't specify a tool - they're generic
310        assert_eq!(get_tool_for_prefix("MCP-"), None);
311        assert_eq!(get_tool_for_prefix("XML-"), None);
312        assert_eq!(get_tool_for_prefix("XP-"), None);
313        // Note: Some prefixes like AS-*, PE-*, AGM-*, REF-* have mixed tools
314        // (some rules have tool, some don't), so the build script excludes them
315        // from the mapping to avoid ambiguity
316    }
317
318    #[test]
319    fn test_get_tool_for_prefix_unknown() {
320        assert_eq!(get_tool_for_prefix("UNKNOWN-"), None);
321    }
322
323    // ===== Mixed-Tool Prefix Scenario Tests (Review-requested coverage) =====
324
325    #[test]
326    fn test_mixed_tool_prefix_as() {
327        // AS-* prefix is all generic (no tool specified for any AS-* rule)
328        // so it returns None - no consistent tool
329        assert_eq!(get_tool_for_prefix("AS-"), None);
330    }
331
332    #[test]
333    fn test_mixed_tool_prefix_cc_mem() {
334        // CC-MEM-* prefix has mixed tools: some rules have "claude-code",
335        // some have null/no tool. Since they're not all the same tool,
336        // the prefix returns None.
337        assert_eq!(get_tool_for_prefix("CC-MEM-"), None);
338    }
339
340    #[test]
341    fn test_consistent_tool_prefix_cc_hk() {
342        // CC-HK-* prefix is consistent: all rules have "claude-code"
343        // so it returns Some("claude-code")
344        assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
345    }
346
347    #[test]
348    fn test_get_prefixes_for_tool_claude_code() {
349        let prefixes = get_prefixes_for_tool("claude-code");
350        assert!(!prefixes.is_empty());
351        assert!(prefixes.contains(&"CC-HK-"));
352        assert!(prefixes.contains(&"CC-SK-"));
353        assert!(prefixes.contains(&"CC-AG-"));
354        assert!(prefixes.contains(&"CC-PL-"));
355        // Note: CC-MEM- is NOT in the list because some CC-MEM-* rules
356        // have empty applies_to (making them generic rules)
357        assert!(!prefixes.contains(&"CC-MEM-"));
358    }
359
360    #[test]
361    fn test_get_prefixes_for_tool_copilot() {
362        let prefixes = get_prefixes_for_tool("github-copilot");
363        assert!(!prefixes.is_empty());
364        assert!(prefixes.contains(&"COP-"));
365    }
366
367    #[test]
368    fn test_get_prefixes_for_tool_cursor() {
369        let prefixes = get_prefixes_for_tool("cursor");
370        assert!(!prefixes.is_empty());
371        assert!(prefixes.contains(&"CUR-"));
372    }
373
374    #[test]
375    fn test_get_prefixes_for_tool_unknown() {
376        let prefixes = get_prefixes_for_tool("unknown-tool");
377        assert!(prefixes.is_empty());
378    }
379
380    // ===== is_valid_tool Tests =====
381
382    #[test]
383    fn test_is_valid_tool_claude_code() {
384        assert!(is_valid_tool("claude-code"));
385        assert!(is_valid_tool("Claude-Code")); // case insensitive
386        assert!(is_valid_tool("CLAUDE-CODE")); // case insensitive
387    }
388
389    #[test]
390    fn test_is_valid_tool_copilot() {
391        assert!(is_valid_tool("github-copilot"));
392        assert!(is_valid_tool("GitHub-Copilot")); // case insensitive
393    }
394
395    #[test]
396    fn test_is_valid_tool_unknown() {
397        assert!(!is_valid_tool("unknown-tool"));
398        assert!(!is_valid_tool(""));
399    }
400
401    // ===== normalize_tool_name Tests =====
402
403    #[test]
404    fn test_normalize_tool_name_claude_code() {
405        assert_eq!(normalize_tool_name("claude-code"), Some("claude-code"));
406        assert_eq!(normalize_tool_name("Claude-Code"), Some("claude-code"));
407        assert_eq!(normalize_tool_name("CLAUDE-CODE"), Some("claude-code"));
408    }
409
410    #[test]
411    fn test_normalize_tool_name_copilot() {
412        assert_eq!(
413            normalize_tool_name("github-copilot"),
414            Some("github-copilot")
415        );
416        assert_eq!(
417            normalize_tool_name("GitHub-Copilot"),
418            Some("github-copilot")
419        );
420    }
421
422    #[test]
423    fn test_normalize_tool_name_unknown() {
424        assert_eq!(normalize_tool_name("unknown-tool"), None);
425        assert_eq!(normalize_tool_name(""), None);
426    }
427
428    // ===== get_prefixes_for_tool Edge Case Tests =====
429
430    #[test]
431    fn test_get_prefixes_for_tool_empty_string() {
432        // Empty string should return empty Vec (no tool matches empty)
433        let prefixes = get_prefixes_for_tool("");
434        assert!(
435            prefixes.is_empty(),
436            "Empty string tool should return empty Vec"
437        );
438    }
439
440    #[test]
441    fn test_get_prefixes_for_tool_unknown_tool() {
442        // Unknown tool should return empty Vec
443        let prefixes = get_prefixes_for_tool("nonexistent-tool");
444        assert!(prefixes.is_empty(), "Unknown tool should return empty Vec");
445    }
446
447    #[test]
448    fn test_get_prefixes_for_tool_claude_code_multiple_prefixes() {
449        // claude-code should have multiple prefixes (CC-HK-, CC-SK-, CC-AG-, CC-PL-)
450        let prefixes = get_prefixes_for_tool("claude-code");
451        assert!(
452            prefixes.len() > 1,
453            "claude-code should have multiple prefixes, got {}",
454            prefixes.len()
455        );
456        // Verify some expected prefixes are present
457        assert!(
458            prefixes.contains(&"CC-HK-"),
459            "claude-code prefixes should include CC-HK-"
460        );
461        assert!(
462            prefixes.contains(&"CC-SK-"),
463            "claude-code prefixes should include CC-SK-"
464        );
465    }
466
467    // ===== get_tool_for_prefix Edge Case Tests =====
468
469    #[test]
470    fn test_get_tool_for_prefix_empty_string() {
471        // Empty prefix should return None
472        assert_eq!(
473            get_tool_for_prefix(""),
474            None,
475            "Empty prefix should return None"
476        );
477    }
478
479    #[test]
480    fn test_get_tool_for_prefix_unknown_prefix() {
481        // Unknown prefix should return None
482        assert_eq!(
483            get_tool_for_prefix("NONEXISTENT-"),
484            None,
485            "Unknown prefix should return None"
486        );
487        assert_eq!(
488            get_tool_for_prefix("XX-"),
489            None,
490            "XX- prefix should return None"
491        );
492    }
493
494    #[test]
495    fn test_get_tool_for_prefix_partial_match_not_supported() {
496        // Partial prefixes should not match
497        assert_eq!(
498            get_tool_for_prefix("CC-"),
499            None,
500            "Partial prefix CC- (without HK/SK/AG) should not match"
501        );
502        assert_eq!(
503            get_tool_for_prefix("C"),
504            None,
505            "Single character should not match"
506        );
507    }
508}