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    fn test_authoring_families_not_empty() {
218        assert!(
219            !AUTHORING_FAMILIES.is_empty(),
220            "AUTHORING_FAMILIES should not be empty"
221        );
222    }
223
224    #[test]
225    fn test_authoring_families_contains_core_families() {
226        let families = authoring_families();
227        assert!(families.contains(&"skill"));
228        assert!(families.contains(&"agent"));
229        assert!(families.contains(&"hooks"));
230        assert!(families.contains(&"mcp"));
231    }
232
233    #[test]
234    fn test_authoring_catalog_json_is_valid_json() {
235        let parsed: serde_json::Value = serde_json::from_str(authoring_catalog_json())
236            .expect("AUTHORING_CATALOG_JSON should be valid JSON");
237        assert!(
238            parsed.is_object(),
239            "authoring catalog should be a JSON object"
240        );
241    }
242
243    // ===== TOOL_RULE_PREFIXES Tests =====
244
245    #[test]
246    #[allow(clippy::const_is_empty)]
247    fn test_tool_rule_prefixes_not_empty() {
248        assert!(
249            !TOOL_RULE_PREFIXES.is_empty(),
250            "TOOL_RULE_PREFIXES should not be empty"
251        );
252    }
253
254    #[test]
255    fn test_tool_rule_prefixes_cc_hk() {
256        // CC-HK-* rules are for claude-code
257        let found = TOOL_RULE_PREFIXES
258            .iter()
259            .find(|(prefix, _)| *prefix == "CC-HK-");
260        assert!(found.is_some(), "Should have CC-HK- prefix");
261        assert_eq!(found.unwrap().1, "claude-code");
262    }
263
264    #[test]
265    fn test_tool_rule_prefixes_cop() {
266        // COP-* rules are for github-copilot
267        let found = TOOL_RULE_PREFIXES
268            .iter()
269            .find(|(prefix, _)| *prefix == "COP-");
270        assert!(found.is_some(), "Should have COP- prefix");
271        assert_eq!(found.unwrap().1, "github-copilot");
272    }
273
274    #[test]
275    fn test_tool_rule_prefixes_cur() {
276        // CUR-* rules are for cursor
277        let found = TOOL_RULE_PREFIXES
278            .iter()
279            .find(|(prefix, _)| *prefix == "CUR-");
280        assert!(found.is_some(), "Should have CUR- prefix");
281        assert_eq!(found.unwrap().1, "cursor");
282    }
283
284    #[test]
285    fn test_get_tool_for_prefix_claude_code() {
286        assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
287        assert_eq!(get_tool_for_prefix("CC-SK-"), Some("claude-code"));
288        assert_eq!(get_tool_for_prefix("CC-AG-"), Some("claude-code"));
289        assert_eq!(get_tool_for_prefix("CC-PL-"), Some("claude-code"));
290        // Note: CC-MEM- is NOT in the mapping because some CC-MEM-* rules
291        // have empty applies_to (making them generic)
292        assert_eq!(get_tool_for_prefix("CC-MEM-"), None);
293    }
294
295    #[test]
296    fn test_get_tool_for_prefix_copilot() {
297        assert_eq!(get_tool_for_prefix("COP-"), Some("github-copilot"));
298    }
299
300    #[test]
301    fn test_get_tool_for_prefix_cursor() {
302        assert_eq!(get_tool_for_prefix("CUR-"), Some("cursor"));
303    }
304
305    #[test]
306    fn test_get_tool_for_prefix_generic() {
307        // These prefixes have no tool specified, so they are not in the mapping
308        // MCP-*, XML-*, XP-* rules don't specify a tool - they're generic
309        assert_eq!(get_tool_for_prefix("MCP-"), None);
310        assert_eq!(get_tool_for_prefix("XML-"), None);
311        assert_eq!(get_tool_for_prefix("XP-"), None);
312        // Note: Some prefixes like AS-*, PE-*, AGM-*, REF-* have mixed tools
313        // (some rules have tool, some don't), so the build script excludes them
314        // from the mapping to avoid ambiguity
315    }
316
317    #[test]
318    fn test_get_tool_for_prefix_unknown() {
319        assert_eq!(get_tool_for_prefix("UNKNOWN-"), None);
320    }
321
322    // ===== Mixed-Tool Prefix Scenario Tests (Review-requested coverage) =====
323
324    #[test]
325    fn test_mixed_tool_prefix_as() {
326        // AS-* prefix is all generic (no tool specified for any AS-* rule)
327        // so it returns None - no consistent tool
328        assert_eq!(get_tool_for_prefix("AS-"), None);
329    }
330
331    #[test]
332    fn test_mixed_tool_prefix_cc_mem() {
333        // CC-MEM-* prefix has mixed tools: some rules have "claude-code",
334        // some have null/no tool. Since they're not all the same tool,
335        // the prefix returns None.
336        assert_eq!(get_tool_for_prefix("CC-MEM-"), None);
337    }
338
339    #[test]
340    fn test_consistent_tool_prefix_cc_hk() {
341        // CC-HK-* prefix is consistent: all rules have "claude-code"
342        // so it returns Some("claude-code")
343        assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
344    }
345
346    #[test]
347    fn test_get_prefixes_for_tool_claude_code() {
348        let prefixes = get_prefixes_for_tool("claude-code");
349        assert!(!prefixes.is_empty());
350        assert!(prefixes.contains(&"CC-HK-"));
351        assert!(prefixes.contains(&"CC-SK-"));
352        assert!(prefixes.contains(&"CC-AG-"));
353        assert!(prefixes.contains(&"CC-PL-"));
354        // Note: CC-MEM- is NOT in the list because some CC-MEM-* rules
355        // have empty applies_to (making them generic rules)
356        assert!(!prefixes.contains(&"CC-MEM-"));
357    }
358
359    #[test]
360    fn test_get_prefixes_for_tool_copilot() {
361        let prefixes = get_prefixes_for_tool("github-copilot");
362        assert!(!prefixes.is_empty());
363        assert!(prefixes.contains(&"COP-"));
364    }
365
366    #[test]
367    fn test_get_prefixes_for_tool_cursor() {
368        let prefixes = get_prefixes_for_tool("cursor");
369        assert!(!prefixes.is_empty());
370        assert!(prefixes.contains(&"CUR-"));
371    }
372
373    #[test]
374    fn test_get_prefixes_for_tool_unknown() {
375        let prefixes = get_prefixes_for_tool("unknown-tool");
376        assert!(prefixes.is_empty());
377    }
378
379    // ===== is_valid_tool Tests =====
380
381    #[test]
382    fn test_is_valid_tool_claude_code() {
383        assert!(is_valid_tool("claude-code"));
384        assert!(is_valid_tool("Claude-Code")); // case insensitive
385        assert!(is_valid_tool("CLAUDE-CODE")); // case insensitive
386    }
387
388    #[test]
389    fn test_is_valid_tool_copilot() {
390        assert!(is_valid_tool("github-copilot"));
391        assert!(is_valid_tool("GitHub-Copilot")); // case insensitive
392    }
393
394    #[test]
395    fn test_is_valid_tool_unknown() {
396        assert!(!is_valid_tool("unknown-tool"));
397        assert!(!is_valid_tool(""));
398    }
399
400    // ===== normalize_tool_name Tests =====
401
402    #[test]
403    fn test_normalize_tool_name_claude_code() {
404        assert_eq!(normalize_tool_name("claude-code"), Some("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    }
408
409    #[test]
410    fn test_normalize_tool_name_copilot() {
411        assert_eq!(
412            normalize_tool_name("github-copilot"),
413            Some("github-copilot")
414        );
415        assert_eq!(
416            normalize_tool_name("GitHub-Copilot"),
417            Some("github-copilot")
418        );
419    }
420
421    #[test]
422    fn test_normalize_tool_name_unknown() {
423        assert_eq!(normalize_tool_name("unknown-tool"), None);
424        assert_eq!(normalize_tool_name(""), None);
425    }
426
427    // ===== get_prefixes_for_tool Edge Case Tests =====
428
429    #[test]
430    fn test_get_prefixes_for_tool_empty_string() {
431        // Empty string should return empty Vec (no tool matches empty)
432        let prefixes = get_prefixes_for_tool("");
433        assert!(
434            prefixes.is_empty(),
435            "Empty string tool should return empty Vec"
436        );
437    }
438
439    #[test]
440    fn test_get_prefixes_for_tool_unknown_tool() {
441        // Unknown tool should return empty Vec
442        let prefixes = get_prefixes_for_tool("nonexistent-tool");
443        assert!(prefixes.is_empty(), "Unknown tool should return empty Vec");
444    }
445
446    #[test]
447    fn test_get_prefixes_for_tool_claude_code_multiple_prefixes() {
448        // claude-code should have multiple prefixes (CC-HK-, CC-SK-, CC-AG-, CC-PL-)
449        let prefixes = get_prefixes_for_tool("claude-code");
450        assert!(
451            prefixes.len() > 1,
452            "claude-code should have multiple prefixes, got {}",
453            prefixes.len()
454        );
455        // Verify some expected prefixes are present
456        assert!(
457            prefixes.contains(&"CC-HK-"),
458            "claude-code prefixes should include CC-HK-"
459        );
460        assert!(
461            prefixes.contains(&"CC-SK-"),
462            "claude-code prefixes should include CC-SK-"
463        );
464    }
465
466    // ===== get_tool_for_prefix Edge Case Tests =====
467
468    #[test]
469    fn test_get_tool_for_prefix_empty_string() {
470        // Empty prefix should return None
471        assert_eq!(
472            get_tool_for_prefix(""),
473            None,
474            "Empty prefix should return None"
475        );
476    }
477
478    #[test]
479    fn test_get_tool_for_prefix_unknown_prefix() {
480        // Unknown prefix should return None
481        assert_eq!(
482            get_tool_for_prefix("NONEXISTENT-"),
483            None,
484            "Unknown prefix should return None"
485        );
486        assert_eq!(
487            get_tool_for_prefix("XX-"),
488            None,
489            "XX- prefix should return None"
490        );
491    }
492
493    #[test]
494    fn test_get_tool_for_prefix_partial_match_not_supported() {
495        // Partial prefixes should not match
496        assert_eq!(
497            get_tool_for_prefix("CC-"),
498            None,
499            "Partial prefix CC- (without HK/SK/AG) should not match"
500        );
501        assert_eq!(
502            get_tool_for_prefix("C"),
503            None,
504            "Single character should not match"
505        );
506    }
507}