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    #[allow(clippy::const_is_empty)]
134    fn test_rules_data_not_empty() {
135        assert!(!RULES_DATA.is_empty(), "RULES_DATA should not be empty");
136    }
137
138    #[test]
139    fn test_rule_count() {
140        assert_eq!(rule_count(), RULES_DATA.len());
141    }
142
143    #[test]
144    fn test_get_rule_name_exists() {
145        // AS-001 should always exist
146        let name = get_rule_name("AS-001");
147        assert!(name.is_some(), "AS-001 should exist");
148    }
149
150    #[test]
151    fn test_get_rule_name_not_exists() {
152        let name = get_rule_name("NONEXISTENT-999");
153        assert!(name.is_none(), "Nonexistent rule should return None");
154    }
155
156    #[test]
157    fn test_no_duplicate_ids() {
158        let mut ids: Vec<&str> = RULES_DATA.iter().map(|(id, _)| *id).collect();
159        let original_len = ids.len();
160        ids.sort();
161        ids.dedup();
162        assert_eq!(ids.len(), original_len, "Should have no duplicate rule IDs");
163    }
164
165    // ===== VALID_TOOLS Tests =====
166
167    #[test]
168    #[allow(clippy::const_is_empty)]
169    fn test_valid_tools_not_empty() {
170        assert!(!VALID_TOOLS.is_empty(), "VALID_TOOLS should not be empty");
171    }
172
173    #[test]
174    fn test_valid_tools_contains_claude_code() {
175        assert!(
176            VALID_TOOLS.contains(&"claude-code"),
177            "VALID_TOOLS should contain 'claude-code'"
178        );
179    }
180
181    #[test]
182    fn test_valid_tools_contains_github_copilot() {
183        assert!(
184            VALID_TOOLS.contains(&"github-copilot"),
185            "VALID_TOOLS should contain 'github-copilot'"
186        );
187    }
188
189    #[test]
190    fn test_valid_tools_contains_cursor() {
191        assert!(
192            VALID_TOOLS.contains(&"cursor"),
193            "VALID_TOOLS should contain 'cursor'"
194        );
195    }
196
197    #[test]
198    fn test_valid_tools_helper() {
199        let tools = valid_tools();
200        assert!(!tools.is_empty());
201        assert!(tools.contains(&"claude-code"));
202    }
203
204    // ===== TOOL_RULE_PREFIXES Tests =====
205
206    #[test]
207    #[allow(clippy::const_is_empty)]
208    fn test_tool_rule_prefixes_not_empty() {
209        assert!(
210            !TOOL_RULE_PREFIXES.is_empty(),
211            "TOOL_RULE_PREFIXES should not be empty"
212        );
213    }
214
215    #[test]
216    fn test_tool_rule_prefixes_cc_hk() {
217        // CC-HK-* rules are for claude-code
218        let found = TOOL_RULE_PREFIXES
219            .iter()
220            .find(|(prefix, _)| *prefix == "CC-HK-");
221        assert!(found.is_some(), "Should have CC-HK- prefix");
222        assert_eq!(found.unwrap().1, "claude-code");
223    }
224
225    #[test]
226    fn test_tool_rule_prefixes_cop() {
227        // COP-* rules are for github-copilot
228        let found = TOOL_RULE_PREFIXES
229            .iter()
230            .find(|(prefix, _)| *prefix == "COP-");
231        assert!(found.is_some(), "Should have COP- prefix");
232        assert_eq!(found.unwrap().1, "github-copilot");
233    }
234
235    #[test]
236    fn test_tool_rule_prefixes_cur() {
237        // CUR-* rules are for cursor
238        let found = TOOL_RULE_PREFIXES
239            .iter()
240            .find(|(prefix, _)| *prefix == "CUR-");
241        assert!(found.is_some(), "Should have CUR- prefix");
242        assert_eq!(found.unwrap().1, "cursor");
243    }
244
245    #[test]
246    fn test_get_tool_for_prefix_claude_code() {
247        assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
248        assert_eq!(get_tool_for_prefix("CC-SK-"), Some("claude-code"));
249        assert_eq!(get_tool_for_prefix("CC-AG-"), Some("claude-code"));
250        assert_eq!(get_tool_for_prefix("CC-PL-"), Some("claude-code"));
251        // Note: CC-MEM- is NOT in the mapping because some CC-MEM-* rules
252        // have empty applies_to (making them generic)
253        assert_eq!(get_tool_for_prefix("CC-MEM-"), None);
254    }
255
256    #[test]
257    fn test_get_tool_for_prefix_copilot() {
258        assert_eq!(get_tool_for_prefix("COP-"), Some("github-copilot"));
259    }
260
261    #[test]
262    fn test_get_tool_for_prefix_cursor() {
263        assert_eq!(get_tool_for_prefix("CUR-"), Some("cursor"));
264    }
265
266    #[test]
267    fn test_get_tool_for_prefix_generic() {
268        // These prefixes have no tool specified, so they are not in the mapping
269        // MCP-*, XML-*, XP-* rules don't specify a tool - they're generic
270        assert_eq!(get_tool_for_prefix("MCP-"), None);
271        assert_eq!(get_tool_for_prefix("XML-"), None);
272        assert_eq!(get_tool_for_prefix("XP-"), None);
273        // Note: Some prefixes like AS-*, PE-*, AGM-*, REF-* have mixed tools
274        // (some rules have tool, some don't), so the build script excludes them
275        // from the mapping to avoid ambiguity
276    }
277
278    #[test]
279    fn test_get_tool_for_prefix_unknown() {
280        assert_eq!(get_tool_for_prefix("UNKNOWN-"), None);
281    }
282
283    // ===== Mixed-Tool Prefix Scenario Tests (Review-requested coverage) =====
284
285    #[test]
286    fn test_mixed_tool_prefix_as() {
287        // AS-* prefix is all generic (no tool specified for any AS-* rule)
288        // so it returns None - no consistent tool
289        assert_eq!(get_tool_for_prefix("AS-"), None);
290    }
291
292    #[test]
293    fn test_mixed_tool_prefix_cc_mem() {
294        // CC-MEM-* prefix has mixed tools: some rules have "claude-code",
295        // some have null/no tool. Since they're not all the same tool,
296        // the prefix returns None.
297        assert_eq!(get_tool_for_prefix("CC-MEM-"), None);
298    }
299
300    #[test]
301    fn test_consistent_tool_prefix_cc_hk() {
302        // CC-HK-* prefix is consistent: all rules have "claude-code"
303        // so it returns Some("claude-code")
304        assert_eq!(get_tool_for_prefix("CC-HK-"), Some("claude-code"));
305    }
306
307    #[test]
308    fn test_get_prefixes_for_tool_claude_code() {
309        let prefixes = get_prefixes_for_tool("claude-code");
310        assert!(!prefixes.is_empty());
311        assert!(prefixes.contains(&"CC-HK-"));
312        assert!(prefixes.contains(&"CC-SK-"));
313        assert!(prefixes.contains(&"CC-AG-"));
314        assert!(prefixes.contains(&"CC-PL-"));
315        // Note: CC-MEM- is NOT in the list because some CC-MEM-* rules
316        // have empty applies_to (making them generic rules)
317        assert!(!prefixes.contains(&"CC-MEM-"));
318    }
319
320    #[test]
321    fn test_get_prefixes_for_tool_copilot() {
322        let prefixes = get_prefixes_for_tool("github-copilot");
323        assert!(!prefixes.is_empty());
324        assert!(prefixes.contains(&"COP-"));
325    }
326
327    #[test]
328    fn test_get_prefixes_for_tool_cursor() {
329        let prefixes = get_prefixes_for_tool("cursor");
330        assert!(!prefixes.is_empty());
331        assert!(prefixes.contains(&"CUR-"));
332    }
333
334    #[test]
335    fn test_get_prefixes_for_tool_unknown() {
336        let prefixes = get_prefixes_for_tool("unknown-tool");
337        assert!(prefixes.is_empty());
338    }
339
340    // ===== is_valid_tool Tests =====
341
342    #[test]
343    fn test_is_valid_tool_claude_code() {
344        assert!(is_valid_tool("claude-code"));
345        assert!(is_valid_tool("Claude-Code")); // case insensitive
346        assert!(is_valid_tool("CLAUDE-CODE")); // case insensitive
347    }
348
349    #[test]
350    fn test_is_valid_tool_copilot() {
351        assert!(is_valid_tool("github-copilot"));
352        assert!(is_valid_tool("GitHub-Copilot")); // case insensitive
353    }
354
355    #[test]
356    fn test_is_valid_tool_unknown() {
357        assert!(!is_valid_tool("unknown-tool"));
358        assert!(!is_valid_tool(""));
359    }
360
361    // ===== normalize_tool_name Tests =====
362
363    #[test]
364    fn test_normalize_tool_name_claude_code() {
365        assert_eq!(normalize_tool_name("claude-code"), Some("claude-code"));
366        assert_eq!(normalize_tool_name("Claude-Code"), Some("claude-code"));
367        assert_eq!(normalize_tool_name("CLAUDE-CODE"), Some("claude-code"));
368    }
369
370    #[test]
371    fn test_normalize_tool_name_copilot() {
372        assert_eq!(
373            normalize_tool_name("github-copilot"),
374            Some("github-copilot")
375        );
376        assert_eq!(
377            normalize_tool_name("GitHub-Copilot"),
378            Some("github-copilot")
379        );
380    }
381
382    #[test]
383    fn test_normalize_tool_name_unknown() {
384        assert_eq!(normalize_tool_name("unknown-tool"), None);
385        assert_eq!(normalize_tool_name(""), None);
386    }
387
388    // ===== get_prefixes_for_tool Edge Case Tests =====
389
390    #[test]
391    fn test_get_prefixes_for_tool_empty_string() {
392        // Empty string should return empty Vec (no tool matches empty)
393        let prefixes = get_prefixes_for_tool("");
394        assert!(
395            prefixes.is_empty(),
396            "Empty string tool should return empty Vec"
397        );
398    }
399
400    #[test]
401    fn test_get_prefixes_for_tool_unknown_tool() {
402        // Unknown tool should return empty Vec
403        let prefixes = get_prefixes_for_tool("nonexistent-tool");
404        assert!(prefixes.is_empty(), "Unknown tool should return empty Vec");
405    }
406
407    #[test]
408    fn test_get_prefixes_for_tool_claude_code_multiple_prefixes() {
409        // claude-code should have multiple prefixes (CC-HK-, CC-SK-, CC-AG-, CC-PL-)
410        let prefixes = get_prefixes_for_tool("claude-code");
411        assert!(
412            prefixes.len() > 1,
413            "claude-code should have multiple prefixes, got {}",
414            prefixes.len()
415        );
416        // Verify some expected prefixes are present
417        assert!(
418            prefixes.contains(&"CC-HK-"),
419            "claude-code prefixes should include CC-HK-"
420        );
421        assert!(
422            prefixes.contains(&"CC-SK-"),
423            "claude-code prefixes should include CC-SK-"
424        );
425    }
426
427    // ===== get_tool_for_prefix Edge Case Tests =====
428
429    #[test]
430    fn test_get_tool_for_prefix_empty_string() {
431        // Empty prefix should return None
432        assert_eq!(
433            get_tool_for_prefix(""),
434            None,
435            "Empty prefix should return None"
436        );
437    }
438
439    #[test]
440    fn test_get_tool_for_prefix_unknown_prefix() {
441        // Unknown prefix should return None
442        assert_eq!(
443            get_tool_for_prefix("NONEXISTENT-"),
444            None,
445            "Unknown prefix should return None"
446        );
447        assert_eq!(
448            get_tool_for_prefix("XX-"),
449            None,
450            "XX- prefix should return None"
451        );
452    }
453
454    #[test]
455    fn test_get_tool_for_prefix_partial_match_not_supported() {
456        // Partial prefixes should not match
457        assert_eq!(
458            get_tool_for_prefix("CC-"),
459            None,
460            "Partial prefix CC- (without HK/SK/AG) should not match"
461        );
462        assert_eq!(
463            get_tool_for_prefix("C"),
464            None,
465            "Single character should not match"
466        );
467    }
468}