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