1use crate::file_utils::safe_read_file;
4use crate::fs::{FileSystem, RealFileSystem};
5use crate::schemas::mcp::DEFAULT_MCP_PROTOCOL_VERSION;
6use rust_i18n::t;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
18pub struct ToolVersions {
19 #[serde(default)]
21 #[schemars(description = "Claude Code version for version-aware validation (e.g., \"1.0.0\")")]
22 pub claude_code: Option<String>,
23
24 #[serde(default)]
26 #[schemars(description = "Codex CLI version for version-aware validation (e.g., \"0.1.0\")")]
27 pub codex: Option<String>,
28
29 #[serde(default)]
31 #[schemars(description = "Cursor version for version-aware validation (e.g., \"0.45.0\")")]
32 pub cursor: Option<String>,
33
34 #[serde(default)]
36 #[schemars(
37 description = "GitHub Copilot version for version-aware validation (e.g., \"1.0.0\")"
38 )]
39 pub copilot: Option<String>,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
47pub struct SpecRevisions {
48 #[serde(default)]
50 #[schemars(
51 description = "MCP protocol version for revision-specific validation (e.g., \"2025-06-18\", \"2024-11-05\")"
52 )]
53 pub mcp_protocol: Option<String>,
54
55 #[serde(default)]
57 #[schemars(description = "Agent Skills specification revision")]
58 pub agent_skills_spec: Option<String>,
59
60 #[serde(default)]
62 #[schemars(description = "AGENTS.md specification revision")]
63 pub agents_md_spec: Option<String>,
64}
65
66#[derive(Clone)]
106struct RuntimeContext {
107 fs: Arc<dyn FileSystem>,
112}
113
114impl Default for RuntimeContext {
115 fn default() -> Self {
116 Self {
117 fs: Arc::new(RealFileSystem),
118 }
119 }
120}
121
122impl std::fmt::Debug for RuntimeContext {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("RuntimeContext")
125 .field("fs", &"Arc<dyn FileSystem>")
126 .finish()
127 }
128}
129
130trait RuleFilter {
135 fn is_rule_enabled(&self, rule_id: &str) -> bool;
137}
138
139struct DefaultRuleFilter<'a> {
146 rules: &'a RuleConfig,
147 target: TargetTool,
148 tools: &'a [String],
149}
150
151impl<'a> DefaultRuleFilter<'a> {
152 fn new(rules: &'a RuleConfig, target: TargetTool, tools: &'a [String]) -> Self {
153 Self {
154 rules,
155 target,
156 tools,
157 }
158 }
159
160 fn is_rule_for_target(&self, rule_id: &str) -> bool {
162 if !self.tools.is_empty() {
164 return self.is_rule_for_tools(rule_id);
165 }
166
167 if rule_id.starts_with("CC-") {
169 return matches!(self.target, TargetTool::ClaudeCode | TargetTool::Generic);
170 }
171 true
173 }
174
175 fn is_rule_for_tools(&self, rule_id: &str) -> bool {
177 for (prefix, tool) in agnix_rules::TOOL_RULE_PREFIXES {
178 if rule_id.starts_with(prefix) {
179 return self
182 .tools
183 .iter()
184 .any(|t| t.eq_ignore_ascii_case(tool) || Self::is_tool_alias(t, tool));
185 }
186 }
187
188 true
190 }
191
192 fn is_tool_alias(user_tool: &str, canonical_tool: &str) -> bool {
205 match canonical_tool {
207 "github-copilot" => user_tool.eq_ignore_ascii_case("copilot"),
208 _ => false,
209 }
210 }
211
212 fn is_category_enabled(&self, rule_id: &str) -> bool {
214 match rule_id {
215 s if s.starts_with("AS-") || s.starts_with("CC-SK-") => self.rules.skills,
216 s if s.starts_with("CC-HK-") => self.rules.hooks,
217 s if s.starts_with("CC-AG-") => self.rules.agents,
218 s if s.starts_with("CC-MEM-") => self.rules.memory,
219 s if s.starts_with("CC-PL-") => self.rules.plugins,
220 s if s.starts_with("XML-") => self.rules.xml,
221 s if s.starts_with("MCP-") => self.rules.mcp,
222 s if s.starts_with("REF-") || s.starts_with("imports::") => self.rules.imports,
223 s if s.starts_with("XP-") => self.rules.cross_platform,
224 s if s.starts_with("AGM-") => self.rules.agents_md,
225 s if s.starts_with("COP-") => self.rules.copilot,
226 s if s.starts_with("CUR-") => self.rules.cursor,
227 s if s.starts_with("PE-") => self.rules.prompt_engineering,
228 _ => true,
230 }
231 }
232}
233
234impl RuleFilter for DefaultRuleFilter<'_> {
235 fn is_rule_enabled(&self, rule_id: &str) -> bool {
236 if self.rules.disabled_rules.iter().any(|r| r == rule_id) {
238 return false;
239 }
240
241 if !self.is_rule_for_target(rule_id) {
243 return false;
244 }
245
246 self.is_category_enabled(rule_id)
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253#[serde(default)]
254pub struct LintConfig {
255 #[schemars(description = "Minimum severity level to report (Error, Warning, Info)")]
257 pub severity: SeverityLevel,
258
259 #[schemars(description = "Configuration for enabling/disabling validation rules by category")]
261 pub rules: RuleConfig,
262
263 #[schemars(
265 description = "Glob patterns for paths to exclude from validation (e.g., [\"node_modules/**\", \"dist/**\"])"
266 )]
267 pub exclude: Vec<String>,
268
269 #[schemars(description = "Target tool for validation (deprecated: use 'tools' array instead)")]
272 pub target: TargetTool,
273
274 #[serde(default)]
279 #[schemars(
280 description = "Tools to validate for. Valid values: \"claude-code\", \"cursor\", \"codex\", \"copilot\", \"generic\""
281 )]
282 pub tools: Vec<String>,
283
284 #[schemars(
287 description = "Expected MCP protocol version (deprecated: use spec_revisions.mcp_protocol instead)"
288 )]
289 pub mcp_protocol_version: Option<String>,
290
291 #[serde(default)]
293 #[schemars(description = "Pin specific tool versions for version-aware validation")]
294 pub tool_versions: ToolVersions,
295
296 #[serde(default)]
298 #[schemars(description = "Pin specific specification revisions for revision-aware validation")]
299 pub spec_revisions: SpecRevisions,
300
301 #[serde(default)]
304 #[schemars(
305 description = "Output locale for translated messages (e.g., \"en\", \"es\", \"zh-CN\")"
306 )]
307 pub locale: Option<String>,
308
309 #[serde(default = "default_max_files")]
317 pub max_files_to_validate: Option<usize>,
318 #[serde(skip)]
323 #[schemars(skip)]
324 pub root_dir: Option<PathBuf>,
325
326 #[serde(skip)]
331 #[schemars(skip)]
332 pub import_cache: Option<crate::parsers::ImportCache>,
333
334 #[serde(skip)]
339 #[schemars(skip)]
340 runtime: RuntimeContext,
341}
342
343pub const DEFAULT_MAX_FILES: usize = 10_000;
354
355fn default_max_files() -> Option<usize> {
357 Some(DEFAULT_MAX_FILES)
358}
359
360impl Default for LintConfig {
361 fn default() -> Self {
362 Self {
363 severity: SeverityLevel::Warning,
364 rules: RuleConfig::default(),
365 exclude: vec![
366 "node_modules/**".to_string(),
367 ".git/**".to_string(),
368 "target/**".to_string(),
369 ],
370 target: TargetTool::Generic,
371 tools: Vec::new(),
372 mcp_protocol_version: None,
373 tool_versions: ToolVersions::default(),
374 spec_revisions: SpecRevisions::default(),
375 locale: None,
376 max_files_to_validate: Some(DEFAULT_MAX_FILES),
377 root_dir: None,
378 import_cache: None,
379 runtime: RuntimeContext::default(),
380 }
381 }
382}
383
384#[derive(
385 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
386)]
387#[schemars(description = "Severity level for filtering diagnostics")]
388pub enum SeverityLevel {
389 Error,
391 Warning,
393 Info,
395}
396
397fn default_true() -> bool {
399 true
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
403#[schemars(description = "Configuration for enabling/disabling validation rules by category")]
404pub struct RuleConfig {
405 #[serde(default = "default_true")]
407 #[schemars(description = "Enable Agent Skills validation rules (AS-*, CC-SK-*)")]
408 pub skills: bool,
409
410 #[serde(default = "default_true")]
412 #[schemars(description = "Enable Claude Code hooks validation rules (CC-HK-*)")]
413 pub hooks: bool,
414
415 #[serde(default = "default_true")]
417 #[schemars(description = "Enable Claude Code agents validation rules (CC-AG-*)")]
418 pub agents: bool,
419
420 #[serde(default = "default_true")]
422 #[schemars(description = "Enable Claude Code memory validation rules (CC-MEM-*)")]
423 pub memory: bool,
424
425 #[serde(default = "default_true")]
427 #[schemars(description = "Enable Claude Code plugins validation rules (CC-PL-*)")]
428 pub plugins: bool,
429
430 #[serde(default = "default_true")]
432 #[schemars(description = "Enable XML tag balance validation rules (XML-*)")]
433 pub xml: bool,
434
435 #[serde(default = "default_true")]
437 #[schemars(description = "Enable Model Context Protocol validation rules (MCP-*)")]
438 pub mcp: bool,
439
440 #[serde(default = "default_true")]
442 #[schemars(description = "Enable import reference validation rules (REF-*)")]
443 pub imports: bool,
444
445 #[serde(default = "default_true")]
447 #[schemars(description = "Enable cross-platform validation rules (XP-*)")]
448 pub cross_platform: bool,
449
450 #[serde(default = "default_true")]
452 #[schemars(description = "Enable AGENTS.md validation rules (AGM-*)")]
453 pub agents_md: bool,
454
455 #[serde(default = "default_true")]
457 #[schemars(description = "Enable GitHub Copilot validation rules (COP-*)")]
458 pub copilot: bool,
459
460 #[serde(default = "default_true")]
462 #[schemars(description = "Enable Cursor project rules validation (CUR-*)")]
463 pub cursor: bool,
464
465 #[serde(default = "default_true")]
467 #[schemars(description = "Enable prompt engineering validation rules (PE-*)")]
468 pub prompt_engineering: bool,
469
470 #[serde(default = "default_true")]
472 #[schemars(description = "Detect generic placeholder instructions in CLAUDE.md")]
473 pub generic_instructions: bool,
474
475 #[serde(default = "default_true")]
477 #[schemars(description = "Validate YAML frontmatter in skill files")]
478 pub frontmatter_validation: bool,
479
480 #[serde(default = "default_true")]
482 #[schemars(description = "Check XML tag balance (legacy: use 'xml' instead)")]
483 pub xml_balance: bool,
484
485 #[serde(default = "default_true")]
487 #[schemars(description = "Validate @import references (legacy: use 'imports' instead)")]
488 pub import_references: bool,
489
490 #[serde(default)]
492 #[schemars(
493 description = "List of rule IDs to explicitly disable (e.g., [\"CC-AG-001\", \"AS-005\"])"
494 )]
495 pub disabled_rules: Vec<String>,
496}
497
498impl Default for RuleConfig {
499 fn default() -> Self {
500 Self {
501 skills: true,
502 hooks: true,
503 agents: true,
504 memory: true,
505 plugins: true,
506 xml: true,
507 mcp: true,
508 imports: true,
509 cross_platform: true,
510 agents_md: true,
511 copilot: true,
512 cursor: true,
513 prompt_engineering: true,
514 generic_instructions: true,
515 frontmatter_validation: true,
516 xml_balance: true,
517 import_references: true,
518 disabled_rules: Vec::new(),
519 }
520 }
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
524#[schemars(
525 description = "Target tool for validation (deprecated: use 'tools' array for multi-tool support)"
526)]
527pub enum TargetTool {
528 Generic,
530 ClaudeCode,
532 Cursor,
534 Codex,
536}
537
538impl LintConfig {
539 pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
541 let content = safe_read_file(path.as_ref())?;
542 let config = toml::from_str(&content)?;
543 Ok(config)
544 }
545
546 pub fn load_or_default(path: Option<&PathBuf>) -> (Self, Option<String>) {
553 match path {
554 Some(p) => match Self::load(p) {
555 Ok(config) => (config, None),
556 Err(e) => {
557 let warning = t!(
558 "core.config.load_warning",
559 path = p.display().to_string(),
560 error = e.to_string()
561 );
562 (Self::default(), Some(warning.to_string()))
563 }
564 },
565 None => (Self::default(), None),
566 }
567 }
568
569 #[inline]
581 pub fn root_dir(&self) -> Option<&PathBuf> {
582 self.root_dir.as_ref()
583 }
584
585 #[inline]
587 pub fn get_root_dir(&self) -> Option<&PathBuf> {
588 self.root_dir()
589 }
590
591 pub fn set_root_dir(&mut self, root_dir: PathBuf) {
596 self.root_dir = Some(root_dir);
597 }
598
599 pub fn set_import_cache(&mut self, cache: crate::parsers::ImportCache) {
608 self.import_cache = Some(cache);
609 }
610
611 #[inline]
620 pub fn import_cache(&self) -> Option<&crate::parsers::ImportCache> {
621 self.import_cache.as_ref()
622 }
623
624 #[inline]
626 pub fn get_import_cache(&self) -> Option<&crate::parsers::ImportCache> {
627 self.import_cache()
628 }
629
630 pub fn fs(&self) -> &Arc<dyn FileSystem> {
636 &self.runtime.fs
637 }
638
639 pub fn set_fs(&mut self, fs: Arc<dyn FileSystem>) {
649 self.runtime.fs = fs;
650 }
651
652 pub fn get_mcp_protocol_version(&self) -> &str {
656 self.spec_revisions
657 .mcp_protocol
658 .as_deref()
659 .or(self.mcp_protocol_version.as_deref())
660 .unwrap_or(DEFAULT_MCP_PROTOCOL_VERSION)
661 }
662
663 pub fn is_mcp_revision_pinned(&self) -> bool {
665 self.spec_revisions.mcp_protocol.is_some() || self.mcp_protocol_version.is_some()
666 }
667
668 pub fn is_claude_code_version_pinned(&self) -> bool {
670 self.tool_versions.claude_code.is_some()
671 }
672
673 pub fn get_claude_code_version(&self) -> Option<&str> {
675 self.tool_versions.claude_code.as_deref()
676 }
677
678 pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
691 let filter = DefaultRuleFilter::new(&self.rules, self.target, &self.tools);
692 filter.is_rule_enabled(rule_id)
693 }
694
695 pub fn is_tool_alias(user_tool: &str, canonical_tool: &str) -> bool {
708 DefaultRuleFilter::is_tool_alias(user_tool, canonical_tool)
709 }
710
711 pub fn validate(&self) -> Vec<ConfigWarning> {
718 let mut warnings = Vec::new();
719
720 let known_prefixes = [
723 "AS-",
724 "CC-SK-",
725 "CC-HK-",
726 "CC-AG-",
727 "CC-MEM-",
728 "CC-PL-",
729 "XML-",
730 "MCP-",
731 "REF-",
732 "XP-",
733 "AGM-",
734 "COP-",
735 "CUR-",
736 "PE-",
737 "VER-",
738 "imports::",
739 ];
740 for rule_id in &self.rules.disabled_rules {
741 let matches_known = known_prefixes
742 .iter()
743 .any(|prefix| rule_id.starts_with(prefix));
744 if !matches_known {
745 warnings.push(ConfigWarning {
746 field: "rules.disabled_rules".to_string(),
747 message: t!(
748 "core.config.unknown_rule",
749 rule = rule_id.as_str(),
750 prefixes = known_prefixes.join(", ")
751 )
752 .to_string(),
753 suggestion: Some(t!("core.config.unknown_rule_suggestion").to_string()),
754 });
755 }
756 }
757
758 let known_tools = [
760 "claude-code",
761 "cursor",
762 "codex",
763 "copilot",
764 "github-copilot",
765 "generic",
766 ];
767 for tool in &self.tools {
768 let tool_lower = tool.to_lowercase();
769 if !known_tools
770 .iter()
771 .any(|k| k.eq_ignore_ascii_case(&tool_lower))
772 {
773 warnings.push(ConfigWarning {
774 field: "tools".to_string(),
775 message: t!(
776 "core.config.unknown_tool",
777 tool = tool.as_str(),
778 valid = known_tools.join(", ")
779 )
780 .to_string(),
781 suggestion: Some(t!("core.config.unknown_tool_suggestion").to_string()),
782 });
783 }
784 }
785
786 if self.target != TargetTool::Generic && self.tools.is_empty() {
788 warnings.push(ConfigWarning {
791 field: "target".to_string(),
792 message: t!("core.config.deprecated_target").to_string(),
793 suggestion: Some(t!("core.config.deprecated_target_suggestion").to_string()),
794 });
795 }
796 if self.mcp_protocol_version.is_some() {
797 warnings.push(ConfigWarning {
798 field: "mcp_protocol_version".to_string(),
799 message: t!("core.config.deprecated_mcp_version").to_string(),
800 suggestion: Some(t!("core.config.deprecated_mcp_version_suggestion").to_string()),
801 });
802 }
803
804 warnings
805 }
806}
807
808#[derive(Debug, Clone, PartialEq, Eq)]
813pub struct ConfigWarning {
814 pub field: String,
816 pub message: String,
818 pub suggestion: Option<String>,
820}
821
822pub fn generate_schema() -> schemars::schema::RootSchema {
837 schemars::schema_for!(LintConfig)
838}
839
840#[cfg(test)]
841#[allow(clippy::field_reassign_with_default)]
842mod tests {
843 use super::*;
844
845 #[test]
846 fn test_default_config_enables_all_rules() {
847 let config = LintConfig::default();
848
849 assert!(config.is_rule_enabled("CC-AG-001"));
851 assert!(config.is_rule_enabled("CC-HK-001"));
852 assert!(config.is_rule_enabled("AS-005"));
853 assert!(config.is_rule_enabled("CC-SK-006"));
854 assert!(config.is_rule_enabled("CC-MEM-005"));
855 assert!(config.is_rule_enabled("CC-PL-001"));
856 assert!(config.is_rule_enabled("XML-001"));
857 assert!(config.is_rule_enabled("REF-001"));
858 }
859
860 #[test]
861 fn test_disabled_rules_list() {
862 let mut config = LintConfig::default();
863 config.rules.disabled_rules = vec!["CC-AG-001".to_string(), "AS-005".to_string()];
864
865 assert!(!config.is_rule_enabled("CC-AG-001"));
866 assert!(!config.is_rule_enabled("AS-005"));
867 assert!(config.is_rule_enabled("CC-AG-002"));
868 assert!(config.is_rule_enabled("AS-006"));
869 }
870
871 #[test]
872 fn test_category_disabled_skills() {
873 let mut config = LintConfig::default();
874 config.rules.skills = false;
875
876 assert!(!config.is_rule_enabled("AS-005"));
877 assert!(!config.is_rule_enabled("AS-006"));
878 assert!(!config.is_rule_enabled("CC-SK-006"));
879 assert!(!config.is_rule_enabled("CC-SK-007"));
880
881 assert!(config.is_rule_enabled("CC-AG-001"));
883 assert!(config.is_rule_enabled("CC-HK-001"));
884 }
885
886 #[test]
887 fn test_category_disabled_hooks() {
888 let mut config = LintConfig::default();
889 config.rules.hooks = false;
890
891 assert!(!config.is_rule_enabled("CC-HK-001"));
892 assert!(!config.is_rule_enabled("CC-HK-009"));
893
894 assert!(config.is_rule_enabled("CC-AG-001"));
896 assert!(config.is_rule_enabled("AS-005"));
897 }
898
899 #[test]
900 fn test_category_disabled_agents() {
901 let mut config = LintConfig::default();
902 config.rules.agents = false;
903
904 assert!(!config.is_rule_enabled("CC-AG-001"));
905 assert!(!config.is_rule_enabled("CC-AG-006"));
906
907 assert!(config.is_rule_enabled("CC-HK-001"));
909 assert!(config.is_rule_enabled("AS-005"));
910 }
911
912 #[test]
913 fn test_category_disabled_memory() {
914 let mut config = LintConfig::default();
915 config.rules.memory = false;
916
917 assert!(!config.is_rule_enabled("CC-MEM-005"));
918
919 assert!(config.is_rule_enabled("CC-AG-001"));
921 }
922
923 #[test]
924 fn test_category_disabled_plugins() {
925 let mut config = LintConfig::default();
926 config.rules.plugins = false;
927
928 assert!(!config.is_rule_enabled("CC-PL-001"));
929
930 assert!(config.is_rule_enabled("CC-AG-001"));
932 }
933
934 #[test]
935 fn test_category_disabled_xml() {
936 let mut config = LintConfig::default();
937 config.rules.xml = false;
938
939 assert!(!config.is_rule_enabled("XML-001"));
940 assert!(!config.is_rule_enabled("XML-002"));
941 assert!(!config.is_rule_enabled("XML-003"));
942
943 assert!(config.is_rule_enabled("CC-AG-001"));
945 }
946
947 #[test]
948 fn test_category_disabled_imports() {
949 let mut config = LintConfig::default();
950 config.rules.imports = false;
951
952 assert!(!config.is_rule_enabled("REF-001"));
953 assert!(!config.is_rule_enabled("imports::not_found"));
954
955 assert!(config.is_rule_enabled("CC-AG-001"));
957 }
958
959 #[test]
960 fn test_target_cursor_disables_cc_rules() {
961 let mut config = LintConfig::default();
962 config.target = TargetTool::Cursor;
963
964 assert!(!config.is_rule_enabled("CC-AG-001"));
966 assert!(!config.is_rule_enabled("CC-HK-001"));
967 assert!(!config.is_rule_enabled("CC-SK-006"));
968 assert!(!config.is_rule_enabled("CC-MEM-005"));
969
970 assert!(config.is_rule_enabled("AS-005"));
972 assert!(config.is_rule_enabled("AS-006"));
973
974 assert!(config.is_rule_enabled("XML-001"));
976 assert!(config.is_rule_enabled("REF-001"));
977 }
978
979 #[test]
980 fn test_target_codex_disables_cc_rules() {
981 let mut config = LintConfig::default();
982 config.target = TargetTool::Codex;
983
984 assert!(!config.is_rule_enabled("CC-AG-001"));
986 assert!(!config.is_rule_enabled("CC-HK-001"));
987
988 assert!(config.is_rule_enabled("AS-005"));
990 }
991
992 #[test]
993 fn test_target_claude_code_enables_cc_rules() {
994 let mut config = LintConfig::default();
995 config.target = TargetTool::ClaudeCode;
996
997 assert!(config.is_rule_enabled("CC-AG-001"));
999 assert!(config.is_rule_enabled("CC-HK-001"));
1000 assert!(config.is_rule_enabled("AS-005"));
1001 }
1002
1003 #[test]
1004 fn test_target_generic_enables_all() {
1005 let config = LintConfig::default(); assert!(config.is_rule_enabled("CC-AG-001"));
1009 assert!(config.is_rule_enabled("CC-HK-001"));
1010 assert!(config.is_rule_enabled("AS-005"));
1011 assert!(config.is_rule_enabled("XML-001"));
1012 }
1013
1014 #[test]
1015 fn test_unknown_rules_enabled_by_default() {
1016 let config = LintConfig::default();
1017
1018 assert!(config.is_rule_enabled("UNKNOWN-001"));
1020 assert!(config.is_rule_enabled("skill::schema"));
1021 assert!(config.is_rule_enabled("agent::parse"));
1022 }
1023
1024 #[test]
1025 fn test_disabled_rules_takes_precedence() {
1026 let mut config = LintConfig::default();
1027 config.rules.disabled_rules = vec!["AS-005".to_string()];
1028
1029 assert!(config.rules.skills);
1031 assert!(!config.is_rule_enabled("AS-005"));
1032 assert!(config.is_rule_enabled("AS-006"));
1033 }
1034
1035 #[test]
1036 fn test_toml_deserialization_with_new_fields() {
1037 let toml_str = r#"
1038severity = "Warning"
1039target = "ClaudeCode"
1040exclude = []
1041
1042[rules]
1043skills = true
1044hooks = false
1045agents = true
1046disabled_rules = ["CC-AG-002"]
1047"#;
1048
1049 let config: LintConfig = toml::from_str(toml_str).unwrap();
1050
1051 assert_eq!(config.target, TargetTool::ClaudeCode);
1052 assert!(config.rules.skills);
1053 assert!(!config.rules.hooks);
1054 assert!(config.rules.agents);
1055 assert!(
1056 config
1057 .rules
1058 .disabled_rules
1059 .contains(&"CC-AG-002".to_string())
1060 );
1061
1062 assert!(config.is_rule_enabled("CC-AG-001"));
1064 assert!(!config.is_rule_enabled("CC-AG-002")); assert!(!config.is_rule_enabled("CC-HK-001")); }
1067
1068 #[test]
1069 fn test_toml_deserialization_defaults() {
1070 let toml_str = r#"
1072severity = "Warning"
1073target = "Generic"
1074exclude = []
1075
1076[rules]
1077"#;
1078
1079 let config: LintConfig = toml::from_str(toml_str).unwrap();
1080
1081 assert!(config.rules.skills);
1083 assert!(config.rules.hooks);
1084 assert!(config.rules.agents);
1085 assert!(config.rules.memory);
1086 assert!(config.rules.plugins);
1087 assert!(config.rules.xml);
1088 assert!(config.rules.mcp);
1089 assert!(config.rules.imports);
1090 assert!(config.rules.cross_platform);
1091 assert!(config.rules.prompt_engineering);
1092 assert!(config.rules.disabled_rules.is_empty());
1093 }
1094
1095 #[test]
1098 fn test_category_disabled_mcp() {
1099 let mut config = LintConfig::default();
1100 config.rules.mcp = false;
1101
1102 assert!(!config.is_rule_enabled("MCP-001"));
1103 assert!(!config.is_rule_enabled("MCP-002"));
1104 assert!(!config.is_rule_enabled("MCP-003"));
1105 assert!(!config.is_rule_enabled("MCP-004"));
1106 assert!(!config.is_rule_enabled("MCP-005"));
1107 assert!(!config.is_rule_enabled("MCP-006"));
1108
1109 assert!(config.is_rule_enabled("CC-AG-001"));
1111 assert!(config.is_rule_enabled("AS-005"));
1112 }
1113
1114 #[test]
1115 fn test_mcp_rules_enabled_by_default() {
1116 let config = LintConfig::default();
1117
1118 assert!(config.is_rule_enabled("MCP-001"));
1119 assert!(config.is_rule_enabled("MCP-002"));
1120 assert!(config.is_rule_enabled("MCP-003"));
1121 assert!(config.is_rule_enabled("MCP-004"));
1122 assert!(config.is_rule_enabled("MCP-005"));
1123 assert!(config.is_rule_enabled("MCP-006"));
1124 assert!(config.is_rule_enabled("MCP-007"));
1125 assert!(config.is_rule_enabled("MCP-008"));
1126 }
1127
1128 #[test]
1131 fn test_default_mcp_protocol_version() {
1132 let config = LintConfig::default();
1133 assert_eq!(config.get_mcp_protocol_version(), "2025-06-18");
1134 }
1135
1136 #[test]
1137 fn test_custom_mcp_protocol_version() {
1138 let mut config = LintConfig::default();
1139 config.mcp_protocol_version = Some("2024-11-05".to_string());
1140 assert_eq!(config.get_mcp_protocol_version(), "2024-11-05");
1141 }
1142
1143 #[test]
1144 fn test_mcp_protocol_version_none_fallback() {
1145 let mut config = LintConfig::default();
1146 config.mcp_protocol_version = None;
1147 assert_eq!(config.get_mcp_protocol_version(), "2025-06-18");
1149 }
1150
1151 #[test]
1152 fn test_toml_deserialization_mcp_protocol_version() {
1153 let toml_str = r#"
1154severity = "Warning"
1155target = "Generic"
1156exclude = []
1157mcp_protocol_version = "2024-11-05"
1158
1159[rules]
1160"#;
1161
1162 let config: LintConfig = toml::from_str(toml_str).unwrap();
1163 assert_eq!(config.get_mcp_protocol_version(), "2024-11-05");
1164 }
1165
1166 #[test]
1167 fn test_toml_deserialization_mcp_protocol_version_default() {
1168 let toml_str = r#"
1170severity = "Warning"
1171target = "Generic"
1172exclude = []
1173
1174[rules]
1175"#;
1176
1177 let config: LintConfig = toml::from_str(toml_str).unwrap();
1178 assert_eq!(config.get_mcp_protocol_version(), "2025-06-18");
1179 }
1180
1181 #[test]
1184 fn test_default_config_enables_xp_rules() {
1185 let config = LintConfig::default();
1186
1187 assert!(config.is_rule_enabled("XP-001"));
1188 assert!(config.is_rule_enabled("XP-002"));
1189 assert!(config.is_rule_enabled("XP-003"));
1190 }
1191
1192 #[test]
1193 fn test_category_disabled_cross_platform() {
1194 let mut config = LintConfig::default();
1195 config.rules.cross_platform = false;
1196
1197 assert!(!config.is_rule_enabled("XP-001"));
1198 assert!(!config.is_rule_enabled("XP-002"));
1199 assert!(!config.is_rule_enabled("XP-003"));
1200
1201 assert!(config.is_rule_enabled("CC-AG-001"));
1203 assert!(config.is_rule_enabled("AS-005"));
1204 }
1205
1206 #[test]
1207 fn test_xp_rules_work_with_all_targets() {
1208 let targets = [
1211 TargetTool::Generic,
1212 TargetTool::ClaudeCode,
1213 TargetTool::Cursor,
1214 TargetTool::Codex,
1215 ];
1216
1217 for target in targets {
1218 let mut config = LintConfig::default();
1219 config.target = target;
1220
1221 assert!(
1222 config.is_rule_enabled("XP-001"),
1223 "XP-001 should be enabled for {:?}",
1224 target
1225 );
1226 assert!(
1227 config.is_rule_enabled("XP-002"),
1228 "XP-002 should be enabled for {:?}",
1229 target
1230 );
1231 assert!(
1232 config.is_rule_enabled("XP-003"),
1233 "XP-003 should be enabled for {:?}",
1234 target
1235 );
1236 }
1237 }
1238
1239 #[test]
1240 fn test_disabled_specific_xp_rule() {
1241 let mut config = LintConfig::default();
1242 config.rules.disabled_rules = vec!["XP-001".to_string()];
1243
1244 assert!(!config.is_rule_enabled("XP-001"));
1245 assert!(config.is_rule_enabled("XP-002"));
1246 assert!(config.is_rule_enabled("XP-003"));
1247 }
1248
1249 #[test]
1250 fn test_toml_deserialization_cross_platform() {
1251 let toml_str = r#"
1252severity = "Warning"
1253target = "Generic"
1254exclude = []
1255
1256[rules]
1257cross_platform = false
1258"#;
1259
1260 let config: LintConfig = toml::from_str(toml_str).unwrap();
1261
1262 assert!(!config.rules.cross_platform);
1263 assert!(!config.is_rule_enabled("XP-001"));
1264 assert!(!config.is_rule_enabled("XP-002"));
1265 assert!(!config.is_rule_enabled("XP-003"));
1266 }
1267
1268 #[test]
1271 fn test_default_config_enables_agm_rules() {
1272 let config = LintConfig::default();
1273
1274 assert!(config.is_rule_enabled("AGM-001"));
1275 assert!(config.is_rule_enabled("AGM-002"));
1276 assert!(config.is_rule_enabled("AGM-003"));
1277 assert!(config.is_rule_enabled("AGM-004"));
1278 assert!(config.is_rule_enabled("AGM-005"));
1279 assert!(config.is_rule_enabled("AGM-006"));
1280 }
1281
1282 #[test]
1283 fn test_category_disabled_agents_md() {
1284 let mut config = LintConfig::default();
1285 config.rules.agents_md = false;
1286
1287 assert!(!config.is_rule_enabled("AGM-001"));
1288 assert!(!config.is_rule_enabled("AGM-002"));
1289 assert!(!config.is_rule_enabled("AGM-003"));
1290 assert!(!config.is_rule_enabled("AGM-004"));
1291 assert!(!config.is_rule_enabled("AGM-005"));
1292 assert!(!config.is_rule_enabled("AGM-006"));
1293
1294 assert!(config.is_rule_enabled("CC-AG-001"));
1296 assert!(config.is_rule_enabled("AS-005"));
1297 assert!(config.is_rule_enabled("XP-001"));
1298 }
1299
1300 #[test]
1301 fn test_agm_rules_work_with_all_targets() {
1302 let targets = [
1305 TargetTool::Generic,
1306 TargetTool::ClaudeCode,
1307 TargetTool::Cursor,
1308 TargetTool::Codex,
1309 ];
1310
1311 for target in targets {
1312 let mut config = LintConfig::default();
1313 config.target = target;
1314
1315 assert!(
1316 config.is_rule_enabled("AGM-001"),
1317 "AGM-001 should be enabled for {:?}",
1318 target
1319 );
1320 assert!(
1321 config.is_rule_enabled("AGM-006"),
1322 "AGM-006 should be enabled for {:?}",
1323 target
1324 );
1325 }
1326 }
1327
1328 #[test]
1329 fn test_disabled_specific_agm_rule() {
1330 let mut config = LintConfig::default();
1331 config.rules.disabled_rules = vec!["AGM-001".to_string()];
1332
1333 assert!(!config.is_rule_enabled("AGM-001"));
1334 assert!(config.is_rule_enabled("AGM-002"));
1335 assert!(config.is_rule_enabled("AGM-003"));
1336 assert!(config.is_rule_enabled("AGM-004"));
1337 assert!(config.is_rule_enabled("AGM-005"));
1338 assert!(config.is_rule_enabled("AGM-006"));
1339 }
1340
1341 #[test]
1342 fn test_toml_deserialization_agents_md() {
1343 let toml_str = r#"
1344severity = "Warning"
1345target = "Generic"
1346exclude = []
1347
1348[rules]
1349agents_md = false
1350"#;
1351
1352 let config: LintConfig = toml::from_str(toml_str).unwrap();
1353
1354 assert!(!config.rules.agents_md);
1355 assert!(!config.is_rule_enabled("AGM-001"));
1356 assert!(!config.is_rule_enabled("AGM-006"));
1357 }
1358
1359 #[test]
1362 fn test_default_config_enables_pe_rules() {
1363 let config = LintConfig::default();
1364
1365 assert!(config.is_rule_enabled("PE-001"));
1366 assert!(config.is_rule_enabled("PE-002"));
1367 assert!(config.is_rule_enabled("PE-003"));
1368 assert!(config.is_rule_enabled("PE-004"));
1369 }
1370
1371 #[test]
1372 fn test_category_disabled_prompt_engineering() {
1373 let mut config = LintConfig::default();
1374 config.rules.prompt_engineering = false;
1375
1376 assert!(!config.is_rule_enabled("PE-001"));
1377 assert!(!config.is_rule_enabled("PE-002"));
1378 assert!(!config.is_rule_enabled("PE-003"));
1379 assert!(!config.is_rule_enabled("PE-004"));
1380
1381 assert!(config.is_rule_enabled("CC-AG-001"));
1383 assert!(config.is_rule_enabled("AS-005"));
1384 assert!(config.is_rule_enabled("XP-001"));
1385 }
1386
1387 #[test]
1388 fn test_pe_rules_work_with_all_targets() {
1389 let targets = [
1391 TargetTool::Generic,
1392 TargetTool::ClaudeCode,
1393 TargetTool::Cursor,
1394 TargetTool::Codex,
1395 ];
1396
1397 for target in targets {
1398 let mut config = LintConfig::default();
1399 config.target = target;
1400
1401 assert!(
1402 config.is_rule_enabled("PE-001"),
1403 "PE-001 should be enabled for {:?}",
1404 target
1405 );
1406 assert!(
1407 config.is_rule_enabled("PE-002"),
1408 "PE-002 should be enabled for {:?}",
1409 target
1410 );
1411 assert!(
1412 config.is_rule_enabled("PE-003"),
1413 "PE-003 should be enabled for {:?}",
1414 target
1415 );
1416 assert!(
1417 config.is_rule_enabled("PE-004"),
1418 "PE-004 should be enabled for {:?}",
1419 target
1420 );
1421 }
1422 }
1423
1424 #[test]
1425 fn test_disabled_specific_pe_rule() {
1426 let mut config = LintConfig::default();
1427 config.rules.disabled_rules = vec!["PE-001".to_string()];
1428
1429 assert!(!config.is_rule_enabled("PE-001"));
1430 assert!(config.is_rule_enabled("PE-002"));
1431 assert!(config.is_rule_enabled("PE-003"));
1432 assert!(config.is_rule_enabled("PE-004"));
1433 }
1434
1435 #[test]
1436 fn test_toml_deserialization_prompt_engineering() {
1437 let toml_str = r#"
1438severity = "Warning"
1439target = "Generic"
1440exclude = []
1441
1442[rules]
1443prompt_engineering = false
1444"#;
1445
1446 let config: LintConfig = toml::from_str(toml_str).unwrap();
1447
1448 assert!(!config.rules.prompt_engineering);
1449 assert!(!config.is_rule_enabled("PE-001"));
1450 assert!(!config.is_rule_enabled("PE-002"));
1451 assert!(!config.is_rule_enabled("PE-003"));
1452 assert!(!config.is_rule_enabled("PE-004"));
1453 }
1454
1455 #[test]
1458 fn test_default_config_enables_cop_rules() {
1459 let config = LintConfig::default();
1460
1461 assert!(config.is_rule_enabled("COP-001"));
1462 assert!(config.is_rule_enabled("COP-002"));
1463 assert!(config.is_rule_enabled("COP-003"));
1464 assert!(config.is_rule_enabled("COP-004"));
1465 }
1466
1467 #[test]
1468 fn test_category_disabled_copilot() {
1469 let mut config = LintConfig::default();
1470 config.rules.copilot = false;
1471
1472 assert!(!config.is_rule_enabled("COP-001"));
1473 assert!(!config.is_rule_enabled("COP-002"));
1474 assert!(!config.is_rule_enabled("COP-003"));
1475 assert!(!config.is_rule_enabled("COP-004"));
1476
1477 assert!(config.is_rule_enabled("CC-AG-001"));
1479 assert!(config.is_rule_enabled("AS-005"));
1480 assert!(config.is_rule_enabled("XP-001"));
1481 }
1482
1483 #[test]
1484 fn test_cop_rules_work_with_all_targets() {
1485 let targets = [
1487 TargetTool::Generic,
1488 TargetTool::ClaudeCode,
1489 TargetTool::Cursor,
1490 TargetTool::Codex,
1491 ];
1492
1493 for target in targets {
1494 let mut config = LintConfig::default();
1495 config.target = target;
1496
1497 assert!(
1498 config.is_rule_enabled("COP-001"),
1499 "COP-001 should be enabled for {:?}",
1500 target
1501 );
1502 assert!(
1503 config.is_rule_enabled("COP-002"),
1504 "COP-002 should be enabled for {:?}",
1505 target
1506 );
1507 assert!(
1508 config.is_rule_enabled("COP-003"),
1509 "COP-003 should be enabled for {:?}",
1510 target
1511 );
1512 assert!(
1513 config.is_rule_enabled("COP-004"),
1514 "COP-004 should be enabled for {:?}",
1515 target
1516 );
1517 }
1518 }
1519
1520 #[test]
1521 fn test_disabled_specific_cop_rule() {
1522 let mut config = LintConfig::default();
1523 config.rules.disabled_rules = vec!["COP-001".to_string()];
1524
1525 assert!(!config.is_rule_enabled("COP-001"));
1526 assert!(config.is_rule_enabled("COP-002"));
1527 assert!(config.is_rule_enabled("COP-003"));
1528 assert!(config.is_rule_enabled("COP-004"));
1529 }
1530
1531 #[test]
1532 fn test_toml_deserialization_copilot() {
1533 let toml_str = r#"
1534severity = "Warning"
1535target = "Generic"
1536exclude = []
1537
1538[rules]
1539copilot = false
1540"#;
1541
1542 let config: LintConfig = toml::from_str(toml_str).unwrap();
1543
1544 assert!(!config.rules.copilot);
1545 assert!(!config.is_rule_enabled("COP-001"));
1546 assert!(!config.is_rule_enabled("COP-002"));
1547 assert!(!config.is_rule_enabled("COP-003"));
1548 assert!(!config.is_rule_enabled("COP-004"));
1549 }
1550
1551 #[test]
1554 fn test_default_config_enables_cur_rules() {
1555 let config = LintConfig::default();
1556
1557 assert!(config.is_rule_enabled("CUR-001"));
1558 assert!(config.is_rule_enabled("CUR-002"));
1559 assert!(config.is_rule_enabled("CUR-003"));
1560 assert!(config.is_rule_enabled("CUR-004"));
1561 assert!(config.is_rule_enabled("CUR-005"));
1562 assert!(config.is_rule_enabled("CUR-006"));
1563 }
1564
1565 #[test]
1566 fn test_category_disabled_cursor() {
1567 let mut config = LintConfig::default();
1568 config.rules.cursor = false;
1569
1570 assert!(!config.is_rule_enabled("CUR-001"));
1571 assert!(!config.is_rule_enabled("CUR-002"));
1572 assert!(!config.is_rule_enabled("CUR-003"));
1573 assert!(!config.is_rule_enabled("CUR-004"));
1574 assert!(!config.is_rule_enabled("CUR-005"));
1575 assert!(!config.is_rule_enabled("CUR-006"));
1576
1577 assert!(config.is_rule_enabled("CC-AG-001"));
1579 assert!(config.is_rule_enabled("AS-005"));
1580 assert!(config.is_rule_enabled("COP-001"));
1581 }
1582
1583 #[test]
1584 fn test_cur_rules_work_with_all_targets() {
1585 let targets = [
1587 TargetTool::Generic,
1588 TargetTool::ClaudeCode,
1589 TargetTool::Cursor,
1590 TargetTool::Codex,
1591 ];
1592
1593 for target in targets {
1594 let mut config = LintConfig::default();
1595 config.target = target;
1596
1597 assert!(
1598 config.is_rule_enabled("CUR-001"),
1599 "CUR-001 should be enabled for {:?}",
1600 target
1601 );
1602 assert!(
1603 config.is_rule_enabled("CUR-006"),
1604 "CUR-006 should be enabled for {:?}",
1605 target
1606 );
1607 }
1608 }
1609
1610 #[test]
1611 fn test_disabled_specific_cur_rule() {
1612 let mut config = LintConfig::default();
1613 config.rules.disabled_rules = vec!["CUR-001".to_string()];
1614
1615 assert!(!config.is_rule_enabled("CUR-001"));
1616 assert!(config.is_rule_enabled("CUR-002"));
1617 assert!(config.is_rule_enabled("CUR-003"));
1618 assert!(config.is_rule_enabled("CUR-004"));
1619 assert!(config.is_rule_enabled("CUR-005"));
1620 assert!(config.is_rule_enabled("CUR-006"));
1621 }
1622
1623 #[test]
1624 fn test_toml_deserialization_cursor() {
1625 let toml_str = r#"
1626severity = "Warning"
1627target = "Generic"
1628exclude = []
1629
1630[rules]
1631cursor = false
1632"#;
1633
1634 let config: LintConfig = toml::from_str(toml_str).unwrap();
1635
1636 assert!(!config.rules.cursor);
1637 assert!(!config.is_rule_enabled("CUR-001"));
1638 assert!(!config.is_rule_enabled("CUR-002"));
1639 assert!(!config.is_rule_enabled("CUR-003"));
1640 assert!(!config.is_rule_enabled("CUR-004"));
1641 assert!(!config.is_rule_enabled("CUR-005"));
1642 assert!(!config.is_rule_enabled("CUR-006"));
1643 }
1644
1645 #[test]
1648 fn test_invalid_toml_returns_warning() {
1649 let dir = tempfile::tempdir().unwrap();
1650 let config_path = dir.path().join(".agnix.toml");
1651 std::fs::write(&config_path, "this is not valid toml [[[").unwrap();
1652
1653 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
1654
1655 assert_eq!(config.target, TargetTool::Generic);
1657 assert!(config.rules.skills);
1658
1659 assert!(warning.is_some());
1661 let msg = warning.unwrap();
1662 assert!(msg.contains("Failed to parse config"));
1663 assert!(msg.contains("Using defaults"));
1664 }
1665
1666 #[test]
1667 fn test_missing_config_no_warning() {
1668 let (config, warning) = LintConfig::load_or_default(None);
1669
1670 assert_eq!(config.target, TargetTool::Generic);
1671 assert!(warning.is_none());
1672 }
1673
1674 #[test]
1675 fn test_valid_config_no_warning() {
1676 let dir = tempfile::tempdir().unwrap();
1677 let config_path = dir.path().join(".agnix.toml");
1678 std::fs::write(
1679 &config_path,
1680 r#"
1681severity = "Warning"
1682target = "ClaudeCode"
1683exclude = []
1684
1685[rules]
1686skills = false
1687"#,
1688 )
1689 .unwrap();
1690
1691 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
1692
1693 assert_eq!(config.target, TargetTool::ClaudeCode);
1694 assert!(!config.rules.skills);
1695 assert!(warning.is_none());
1696 }
1697
1698 #[test]
1699 fn test_nonexistent_config_file_returns_warning() {
1700 let nonexistent = PathBuf::from("/nonexistent/path/.agnix.toml");
1701 let (config, warning) = LintConfig::load_or_default(Some(&nonexistent));
1702
1703 assert_eq!(config.target, TargetTool::Generic);
1705
1706 assert!(warning.is_some());
1708 let msg = warning.unwrap();
1709 assert!(msg.contains("Failed to parse config"));
1710 }
1711
1712 #[test]
1715 fn test_old_config_with_removed_fields_still_parses() {
1716 let toml_str = r#"
1719severity = "Warning"
1720target = "Generic"
1721exclude = []
1722
1723[rules]
1724skills = true
1725hooks = true
1726tool_names = true
1727required_fields = true
1728"#;
1729
1730 let config: LintConfig = toml::from_str(toml_str)
1731 .expect("Failed to parse config with removed fields for backward compatibility");
1732
1733 assert_eq!(config.target, TargetTool::Generic);
1735 assert!(config.rules.skills);
1736 assert!(config.rules.hooks);
1737 }
1739
1740 #[test]
1743 fn test_tool_versions_default_unpinned() {
1744 let config = LintConfig::default();
1745
1746 assert!(config.tool_versions.claude_code.is_none());
1747 assert!(config.tool_versions.codex.is_none());
1748 assert!(config.tool_versions.cursor.is_none());
1749 assert!(config.tool_versions.copilot.is_none());
1750 assert!(!config.is_claude_code_version_pinned());
1751 }
1752
1753 #[test]
1754 fn test_tool_versions_claude_code_pinned() {
1755 let toml_str = r#"
1756severity = "Warning"
1757target = "ClaudeCode"
1758exclude = []
1759
1760[rules]
1761
1762[tool_versions]
1763claude_code = "1.0.0"
1764"#;
1765
1766 let config: LintConfig = toml::from_str(toml_str).unwrap();
1767 assert!(config.is_claude_code_version_pinned());
1768 assert_eq!(config.get_claude_code_version(), Some("1.0.0"));
1769 }
1770
1771 #[test]
1772 fn test_tool_versions_multiple_pinned() {
1773 let toml_str = r#"
1774severity = "Warning"
1775target = "Generic"
1776exclude = []
1777
1778[rules]
1779
1780[tool_versions]
1781claude_code = "1.0.0"
1782codex = "0.1.0"
1783cursor = "0.45.0"
1784copilot = "1.0.0"
1785"#;
1786
1787 let config: LintConfig = toml::from_str(toml_str).unwrap();
1788 assert_eq!(config.tool_versions.claude_code, Some("1.0.0".to_string()));
1789 assert_eq!(config.tool_versions.codex, Some("0.1.0".to_string()));
1790 assert_eq!(config.tool_versions.cursor, Some("0.45.0".to_string()));
1791 assert_eq!(config.tool_versions.copilot, Some("1.0.0".to_string()));
1792 }
1793
1794 #[test]
1797 fn test_spec_revisions_default_unpinned() {
1798 let config = LintConfig::default();
1799
1800 assert!(config.spec_revisions.mcp_protocol.is_none());
1801 assert!(config.spec_revisions.agent_skills_spec.is_none());
1802 assert!(config.spec_revisions.agents_md_spec.is_none());
1803 assert!(!config.is_mcp_revision_pinned());
1805 }
1806
1807 #[test]
1808 fn test_spec_revisions_mcp_pinned() {
1809 let toml_str = r#"
1810severity = "Warning"
1811target = "Generic"
1812exclude = []
1813
1814[rules]
1815
1816[spec_revisions]
1817mcp_protocol = "2024-11-05"
1818"#;
1819
1820 let config: LintConfig = toml::from_str(toml_str).unwrap();
1821 assert!(config.is_mcp_revision_pinned());
1822 assert_eq!(config.get_mcp_protocol_version(), "2024-11-05");
1823 }
1824
1825 #[test]
1826 fn test_spec_revisions_precedence_over_legacy() {
1827 let toml_str = r#"
1829severity = "Warning"
1830target = "Generic"
1831exclude = []
1832mcp_protocol_version = "2024-11-05"
1833
1834[rules]
1835
1836[spec_revisions]
1837mcp_protocol = "2025-06-18"
1838"#;
1839
1840 let config: LintConfig = toml::from_str(toml_str).unwrap();
1841 assert_eq!(config.get_mcp_protocol_version(), "2025-06-18");
1842 }
1843
1844 #[test]
1845 fn test_spec_revisions_fallback_to_legacy() {
1846 let toml_str = r#"
1848severity = "Warning"
1849target = "Generic"
1850exclude = []
1851mcp_protocol_version = "2024-11-05"
1852
1853[rules]
1854
1855[spec_revisions]
1856"#;
1857
1858 let config: LintConfig = toml::from_str(toml_str).unwrap();
1859 assert_eq!(config.get_mcp_protocol_version(), "2024-11-05");
1860 }
1861
1862 #[test]
1863 fn test_spec_revisions_multiple_pinned() {
1864 let toml_str = r#"
1865severity = "Warning"
1866target = "Generic"
1867exclude = []
1868
1869[rules]
1870
1871[spec_revisions]
1872mcp_protocol = "2024-11-05"
1873agent_skills_spec = "1.0.0"
1874agents_md_spec = "1.0.0"
1875"#;
1876
1877 let config: LintConfig = toml::from_str(toml_str).unwrap();
1878 assert_eq!(
1879 config.spec_revisions.mcp_protocol,
1880 Some("2024-11-05".to_string())
1881 );
1882 assert_eq!(
1883 config.spec_revisions.agent_skills_spec,
1884 Some("1.0.0".to_string())
1885 );
1886 assert_eq!(
1887 config.spec_revisions.agents_md_spec,
1888 Some("1.0.0".to_string())
1889 );
1890 }
1891
1892 #[test]
1895 fn test_config_without_tool_versions_defaults() {
1896 let toml_str = r#"
1898severity = "Warning"
1899target = "ClaudeCode"
1900exclude = []
1901
1902[rules]
1903skills = true
1904"#;
1905
1906 let config: LintConfig = toml::from_str(toml_str).unwrap();
1907 assert!(!config.is_claude_code_version_pinned());
1908 assert!(config.tool_versions.claude_code.is_none());
1909 }
1910
1911 #[test]
1912 fn test_config_without_spec_revisions_defaults() {
1913 let toml_str = r#"
1915severity = "Warning"
1916target = "Generic"
1917exclude = []
1918
1919[rules]
1920"#;
1921
1922 let config: LintConfig = toml::from_str(toml_str).unwrap();
1923 assert!(!config.is_mcp_revision_pinned());
1925 assert_eq!(config.get_mcp_protocol_version(), "2025-06-18");
1927 }
1928
1929 #[test]
1930 fn test_is_mcp_revision_pinned_with_none_mcp_protocol_version() {
1931 let mut config = LintConfig::default();
1933 config.mcp_protocol_version = None;
1934 config.spec_revisions.mcp_protocol = None;
1935
1936 assert!(!config.is_mcp_revision_pinned());
1937 assert_eq!(config.get_mcp_protocol_version(), "2025-06-18");
1939 }
1940
1941 #[test]
1944 fn test_tools_array_empty_uses_target() {
1945 let mut config = LintConfig::default();
1947 config.tools = vec![];
1948 config.target = TargetTool::Cursor;
1949
1950 assert!(!config.is_rule_enabled("CC-AG-001"));
1952 assert!(!config.is_rule_enabled("CC-HK-001"));
1953
1954 assert!(config.is_rule_enabled("AS-005"));
1956 }
1957
1958 #[test]
1959 fn test_tools_array_claude_code_only() {
1960 let mut config = LintConfig::default();
1961 config.tools = vec!["claude-code".to_string()];
1962
1963 assert!(config.is_rule_enabled("CC-AG-001"));
1965 assert!(config.is_rule_enabled("CC-HK-001"));
1966 assert!(config.is_rule_enabled("CC-SK-006"));
1967
1968 assert!(!config.is_rule_enabled("COP-001"));
1970 assert!(!config.is_rule_enabled("CUR-001"));
1971
1972 assert!(config.is_rule_enabled("AS-005"));
1974 assert!(config.is_rule_enabled("XP-001"));
1975 assert!(config.is_rule_enabled("AGM-001"));
1976 }
1977
1978 #[test]
1979 fn test_tools_array_cursor_only() {
1980 let mut config = LintConfig::default();
1981 config.tools = vec!["cursor".to_string()];
1982
1983 assert!(config.is_rule_enabled("CUR-001"));
1985 assert!(config.is_rule_enabled("CUR-006"));
1986
1987 assert!(!config.is_rule_enabled("CC-AG-001"));
1989 assert!(!config.is_rule_enabled("COP-001"));
1990
1991 assert!(config.is_rule_enabled("AS-005"));
1993 assert!(config.is_rule_enabled("XP-001"));
1994 }
1995
1996 #[test]
1997 fn test_tools_array_copilot_only() {
1998 let mut config = LintConfig::default();
1999 config.tools = vec!["copilot".to_string()];
2000
2001 assert!(config.is_rule_enabled("COP-001"));
2003 assert!(config.is_rule_enabled("COP-002"));
2004
2005 assert!(!config.is_rule_enabled("CC-AG-001"));
2007 assert!(!config.is_rule_enabled("CUR-001"));
2008
2009 assert!(config.is_rule_enabled("AS-005"));
2011 assert!(config.is_rule_enabled("XP-001"));
2012 }
2013
2014 #[test]
2015 fn test_tools_array_multiple_tools() {
2016 let mut config = LintConfig::default();
2017 config.tools = vec!["claude-code".to_string(), "cursor".to_string()];
2018
2019 assert!(config.is_rule_enabled("CC-AG-001"));
2021 assert!(config.is_rule_enabled("CC-HK-001"));
2022 assert!(config.is_rule_enabled("CUR-001"));
2023 assert!(config.is_rule_enabled("CUR-006"));
2024
2025 assert!(!config.is_rule_enabled("COP-001"));
2027
2028 assert!(config.is_rule_enabled("AS-005"));
2030 assert!(config.is_rule_enabled("XP-001"));
2031 }
2032
2033 #[test]
2034 fn test_tools_array_case_insensitive() {
2035 let mut config = LintConfig::default();
2036 config.tools = vec!["Claude-Code".to_string(), "CURSOR".to_string()];
2037
2038 assert!(config.is_rule_enabled("CC-AG-001"));
2040 assert!(config.is_rule_enabled("CUR-001"));
2041 }
2042
2043 #[test]
2044 fn test_tools_array_overrides_target() {
2045 let mut config = LintConfig::default();
2046 config.target = TargetTool::Cursor; config.tools = vec!["claude-code".to_string()]; assert!(config.is_rule_enabled("CC-AG-001"));
2051 assert!(!config.is_rule_enabled("CUR-001")); }
2053
2054 #[test]
2055 fn test_tools_toml_deserialization() {
2056 let toml_str = r#"
2057severity = "Warning"
2058target = "Generic"
2059exclude = []
2060tools = ["claude-code", "cursor"]
2061
2062[rules]
2063"#;
2064
2065 let config: LintConfig = toml::from_str(toml_str).unwrap();
2066
2067 assert_eq!(config.tools.len(), 2);
2068 assert!(config.tools.contains(&"claude-code".to_string()));
2069 assert!(config.tools.contains(&"cursor".to_string()));
2070
2071 assert!(config.is_rule_enabled("CC-AG-001"));
2073 assert!(config.is_rule_enabled("CUR-001"));
2074 assert!(!config.is_rule_enabled("COP-001"));
2075 }
2076
2077 #[test]
2078 fn test_tools_toml_backward_compatible() {
2079 let toml_str = r#"
2081severity = "Warning"
2082target = "ClaudeCode"
2083exclude = []
2084
2085[rules]
2086"#;
2087
2088 let config: LintConfig = toml::from_str(toml_str).unwrap();
2089
2090 assert!(config.tools.is_empty());
2091 assert!(config.is_rule_enabled("CC-AG-001"));
2093 }
2094
2095 #[test]
2096 fn test_tools_disabled_rules_still_works() {
2097 let mut config = LintConfig::default();
2098 config.tools = vec!["claude-code".to_string()];
2099 config.rules.disabled_rules = vec!["CC-AG-001".to_string()];
2100
2101 assert!(!config.is_rule_enabled("CC-AG-001"));
2103 assert!(config.is_rule_enabled("CC-AG-002"));
2105 assert!(config.is_rule_enabled("CC-HK-001"));
2106 }
2107
2108 #[test]
2109 fn test_tools_category_disabled_still_works() {
2110 let mut config = LintConfig::default();
2111 config.tools = vec!["claude-code".to_string()];
2112 config.rules.hooks = false;
2113
2114 assert!(!config.is_rule_enabled("CC-HK-001"));
2116 assert!(config.is_rule_enabled("CC-AG-001"));
2118 }
2119
2120 #[test]
2123 fn test_is_tool_alias_unknown_alias_returns_false() {
2124 assert!(!LintConfig::is_tool_alias("unknown", "github-copilot"));
2126 assert!(!LintConfig::is_tool_alias("gh-copilot", "github-copilot"));
2127 assert!(!LintConfig::is_tool_alias("", "github-copilot"));
2128 }
2129
2130 #[test]
2131 fn test_is_tool_alias_canonical_name_not_alias_of_itself() {
2132 assert!(!LintConfig::is_tool_alias(
2136 "github-copilot",
2137 "github-copilot"
2138 ));
2139 assert!(!LintConfig::is_tool_alias(
2140 "GitHub-Copilot",
2141 "github-copilot"
2142 ));
2143 }
2144
2145 #[test]
2146 fn test_is_tool_alias_copilot_is_alias_for_github_copilot() {
2147 assert!(LintConfig::is_tool_alias("copilot", "github-copilot"));
2149 assert!(LintConfig::is_tool_alias("Copilot", "github-copilot"));
2150 assert!(LintConfig::is_tool_alias("COPILOT", "github-copilot"));
2151 }
2152
2153 #[test]
2154 fn test_is_tool_alias_no_aliases_for_other_tools() {
2155 assert!(!LintConfig::is_tool_alias("claude", "claude-code"));
2157 assert!(!LintConfig::is_tool_alias("cc", "claude-code"));
2158 assert!(!LintConfig::is_tool_alias("cur", "cursor"));
2159 }
2160
2161 #[test]
2164 fn test_partial_config_only_rules_section() {
2165 let toml_str = r#"
2166[rules]
2167disabled_rules = ["CC-MEM-006"]
2168"#;
2169 let config: LintConfig = toml::from_str(toml_str).unwrap();
2170
2171 assert_eq!(config.severity, SeverityLevel::Warning);
2173 assert_eq!(config.target, TargetTool::Generic);
2174 assert!(config.rules.skills);
2175 assert!(config.rules.hooks);
2176
2177 assert_eq!(config.rules.disabled_rules, vec!["CC-MEM-006"]);
2179 assert!(!config.is_rule_enabled("CC-MEM-006"));
2180 }
2181
2182 #[test]
2183 fn test_partial_config_only_severity() {
2184 let toml_str = r#"severity = "Error""#;
2185 let config: LintConfig = toml::from_str(toml_str).unwrap();
2186
2187 assert_eq!(config.severity, SeverityLevel::Error);
2188 assert_eq!(config.target, TargetTool::Generic);
2189 assert!(config.rules.skills);
2190 }
2191
2192 #[test]
2193 fn test_partial_config_only_target() {
2194 let toml_str = r#"target = "ClaudeCode""#;
2195 let config: LintConfig = toml::from_str(toml_str).unwrap();
2196
2197 assert_eq!(config.target, TargetTool::ClaudeCode);
2198 assert_eq!(config.severity, SeverityLevel::Warning);
2199 }
2200
2201 #[test]
2202 fn test_partial_config_only_exclude() {
2203 let toml_str = r#"exclude = ["vendor/**", "dist/**"]"#;
2204 let config: LintConfig = toml::from_str(toml_str).unwrap();
2205
2206 assert_eq!(config.exclude, vec!["vendor/**", "dist/**"]);
2207 assert_eq!(config.severity, SeverityLevel::Warning);
2208 }
2209
2210 #[test]
2211 fn test_partial_config_only_disabled_rules() {
2212 let toml_str = r#"
2213[rules]
2214disabled_rules = ["AS-001", "CC-SK-007", "PE-003"]
2215"#;
2216 let config: LintConfig = toml::from_str(toml_str).unwrap();
2217
2218 assert!(!config.is_rule_enabled("AS-001"));
2219 assert!(!config.is_rule_enabled("CC-SK-007"));
2220 assert!(!config.is_rule_enabled("PE-003"));
2221 assert!(config.is_rule_enabled("AS-002"));
2223 assert!(config.is_rule_enabled("CC-SK-001"));
2224 }
2225
2226 #[test]
2227 fn test_partial_config_disable_single_category() {
2228 let toml_str = r#"
2229[rules]
2230skills = false
2231"#;
2232 let config: LintConfig = toml::from_str(toml_str).unwrap();
2233
2234 assert!(!config.rules.skills);
2235 assert!(config.rules.hooks);
2237 assert!(config.rules.agents);
2238 assert!(config.rules.memory);
2239 }
2240
2241 #[test]
2242 fn test_partial_config_tools_array() {
2243 let toml_str = r#"tools = ["claude-code", "cursor"]"#;
2244 let config: LintConfig = toml::from_str(toml_str).unwrap();
2245
2246 assert_eq!(config.tools, vec!["claude-code", "cursor"]);
2247 assert!(config.is_rule_enabled("CC-SK-001")); assert!(config.is_rule_enabled("CUR-001")); }
2250
2251 #[test]
2252 fn test_partial_config_combined_options() {
2253 let toml_str = r#"
2254severity = "Error"
2255target = "ClaudeCode"
2256
2257[rules]
2258xml = false
2259disabled_rules = ["CC-MEM-006"]
2260"#;
2261 let config: LintConfig = toml::from_str(toml_str).unwrap();
2262
2263 assert_eq!(config.severity, SeverityLevel::Error);
2264 assert_eq!(config.target, TargetTool::ClaudeCode);
2265 assert!(!config.rules.xml);
2266 assert!(!config.is_rule_enabled("CC-MEM-006"));
2267 assert!(config.exclude.contains(&"node_modules/**".to_string()));
2269 }
2270
2271 #[test]
2274 fn test_disabled_rules_empty_array() {
2275 let toml_str = r#"
2276[rules]
2277disabled_rules = []
2278"#;
2279 let config: LintConfig = toml::from_str(toml_str).unwrap();
2280
2281 assert!(config.rules.disabled_rules.is_empty());
2282 assert!(config.is_rule_enabled("AS-001"));
2283 assert!(config.is_rule_enabled("CC-SK-001"));
2284 }
2285
2286 #[test]
2287 fn test_disabled_rules_case_sensitive() {
2288 let toml_str = r#"
2289[rules]
2290disabled_rules = ["as-001"]
2291"#;
2292 let config: LintConfig = toml::from_str(toml_str).unwrap();
2293
2294 assert!(config.is_rule_enabled("AS-001")); assert!(!config.is_rule_enabled("as-001")); }
2298
2299 #[test]
2300 fn test_disabled_rules_multiple_from_same_category() {
2301 let toml_str = r#"
2302[rules]
2303disabled_rules = ["AS-001", "AS-002", "AS-003", "AS-004"]
2304"#;
2305 let config: LintConfig = toml::from_str(toml_str).unwrap();
2306
2307 assert!(!config.is_rule_enabled("AS-001"));
2308 assert!(!config.is_rule_enabled("AS-002"));
2309 assert!(!config.is_rule_enabled("AS-003"));
2310 assert!(!config.is_rule_enabled("AS-004"));
2311 assert!(config.is_rule_enabled("AS-005"));
2313 }
2314
2315 #[test]
2316 fn test_disabled_rules_across_categories() {
2317 let toml_str = r#"
2318[rules]
2319disabled_rules = ["AS-001", "CC-SK-007", "MCP-001", "PE-003", "XP-001"]
2320"#;
2321 let config: LintConfig = toml::from_str(toml_str).unwrap();
2322
2323 assert!(!config.is_rule_enabled("AS-001"));
2324 assert!(!config.is_rule_enabled("CC-SK-007"));
2325 assert!(!config.is_rule_enabled("MCP-001"));
2326 assert!(!config.is_rule_enabled("PE-003"));
2327 assert!(!config.is_rule_enabled("XP-001"));
2328 }
2329
2330 #[test]
2331 fn test_disabled_rules_nonexistent_rule() {
2332 let toml_str = r#"
2333[rules]
2334disabled_rules = ["FAKE-001", "NONEXISTENT-999"]
2335"#;
2336 let config: LintConfig = toml::from_str(toml_str).unwrap();
2337
2338 assert!(!config.is_rule_enabled("FAKE-001"));
2340 assert!(!config.is_rule_enabled("NONEXISTENT-999"));
2341 assert!(config.is_rule_enabled("AS-001"));
2343 }
2344
2345 #[test]
2346 fn test_disabled_rules_with_category_disabled() {
2347 let toml_str = r#"
2348[rules]
2349skills = false
2350disabled_rules = ["AS-001"]
2351"#;
2352 let config: LintConfig = toml::from_str(toml_str).unwrap();
2353
2354 assert!(!config.is_rule_enabled("AS-001"));
2356 assert!(!config.is_rule_enabled("AS-002")); }
2358
2359 #[test]
2362 fn test_config_file_empty() {
2363 let dir = tempfile::tempdir().unwrap();
2364 let config_path = dir.path().join(".agnix.toml");
2365 std::fs::write(&config_path, "").unwrap();
2366
2367 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
2368
2369 assert_eq!(config.severity, SeverityLevel::Warning);
2371 assert_eq!(config.target, TargetTool::Generic);
2372 assert!(config.rules.skills);
2373 assert!(warning.is_none());
2374 }
2375
2376 #[test]
2377 fn test_config_file_only_comments() {
2378 let dir = tempfile::tempdir().unwrap();
2379 let config_path = dir.path().join(".agnix.toml");
2380 std::fs::write(
2381 &config_path,
2382 r#"
2383# This is a comment
2384# Another comment
2385"#,
2386 )
2387 .unwrap();
2388
2389 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
2390
2391 assert_eq!(config.severity, SeverityLevel::Warning);
2393 assert!(warning.is_none());
2394 }
2395
2396 #[test]
2397 fn test_config_file_with_comments() {
2398 let dir = tempfile::tempdir().unwrap();
2399 let config_path = dir.path().join(".agnix.toml");
2400 std::fs::write(
2401 &config_path,
2402 r#"
2403# Severity level
2404severity = "Error"
2405
2406# Disable specific rules
2407[rules]
2408# Disable negative instruction warnings
2409disabled_rules = ["CC-MEM-006"]
2410"#,
2411 )
2412 .unwrap();
2413
2414 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
2415
2416 assert_eq!(config.severity, SeverityLevel::Error);
2417 assert!(!config.is_rule_enabled("CC-MEM-006"));
2418 assert!(warning.is_none());
2419 }
2420
2421 #[test]
2422 fn test_config_invalid_severity_value() {
2423 let dir = tempfile::tempdir().unwrap();
2424 let config_path = dir.path().join(".agnix.toml");
2425 std::fs::write(&config_path, r#"severity = "InvalidLevel""#).unwrap();
2426
2427 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
2428
2429 assert_eq!(config.severity, SeverityLevel::Warning);
2431 assert!(warning.is_some());
2432 }
2433
2434 #[test]
2435 fn test_config_invalid_target_value() {
2436 let dir = tempfile::tempdir().unwrap();
2437 let config_path = dir.path().join(".agnix.toml");
2438 std::fs::write(&config_path, r#"target = "InvalidTool""#).unwrap();
2439
2440 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
2441
2442 assert_eq!(config.target, TargetTool::Generic);
2444 assert!(warning.is_some());
2445 }
2446
2447 #[test]
2448 fn test_config_wrong_type_for_disabled_rules() {
2449 let dir = tempfile::tempdir().unwrap();
2450 let config_path = dir.path().join(".agnix.toml");
2451 std::fs::write(
2452 &config_path,
2453 r#"
2454[rules]
2455disabled_rules = "AS-001"
2456"#,
2457 )
2458 .unwrap();
2459
2460 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
2461
2462 assert!(config.rules.disabled_rules.is_empty());
2464 assert!(warning.is_some());
2465 }
2466
2467 #[test]
2468 fn test_config_wrong_type_for_exclude() {
2469 let dir = tempfile::tempdir().unwrap();
2470 let config_path = dir.path().join(".agnix.toml");
2471 std::fs::write(&config_path, r#"exclude = "node_modules""#).unwrap();
2472
2473 let (config, warning) = LintConfig::load_or_default(Some(&config_path));
2474
2475 assert!(warning.is_some());
2477 assert!(config.exclude.contains(&"node_modules/**".to_string()));
2479 }
2480
2481 #[test]
2484 fn test_target_and_tools_interaction() {
2485 let toml_str = r#"
2487target = "Cursor"
2488tools = ["claude-code"]
2489"#;
2490 let config: LintConfig = toml::from_str(toml_str).unwrap();
2491
2492 assert!(config.is_rule_enabled("CC-SK-001"));
2494 assert!(!config.is_rule_enabled("CUR-001"));
2496 }
2497
2498 #[test]
2499 fn test_category_disabled_overrides_target() {
2500 let toml_str = r#"
2501target = "ClaudeCode"
2502
2503[rules]
2504skills = false
2505"#;
2506 let config: LintConfig = toml::from_str(toml_str).unwrap();
2507
2508 assert!(!config.is_rule_enabled("AS-001"));
2510 assert!(!config.is_rule_enabled("CC-SK-001"));
2511 }
2512
2513 #[test]
2514 fn test_disabled_rules_overrides_category_enabled() {
2515 let toml_str = r#"
2516[rules]
2517skills = true
2518disabled_rules = ["AS-001"]
2519"#;
2520 let config: LintConfig = toml::from_str(toml_str).unwrap();
2521
2522 assert!(!config.is_rule_enabled("AS-001"));
2524 assert!(config.is_rule_enabled("AS-002"));
2525 }
2526
2527 #[test]
2530 fn test_config_serialize_deserialize_roundtrip() {
2531 let mut config = LintConfig::default();
2532 config.severity = SeverityLevel::Error;
2533 config.target = TargetTool::ClaudeCode;
2534 config.rules.skills = false;
2535 config.rules.disabled_rules = vec!["CC-MEM-006".to_string()];
2536
2537 let serialized = toml::to_string(&config).unwrap();
2538 let deserialized: LintConfig = toml::from_str(&serialized).unwrap();
2539
2540 assert_eq!(deserialized.severity, SeverityLevel::Error);
2541 assert_eq!(deserialized.target, TargetTool::ClaudeCode);
2542 assert!(!deserialized.rules.skills);
2543 assert_eq!(deserialized.rules.disabled_rules, vec!["CC-MEM-006"]);
2544 }
2545
2546 #[test]
2547 fn test_default_config_serializes_cleanly() {
2548 let config = LintConfig::default();
2549 let serialized = toml::to_string(&config).unwrap();
2550
2551 let _: LintConfig = toml::from_str(&serialized).unwrap();
2553 }
2554
2555 #[test]
2558 fn test_minimal_disable_warnings_config() {
2559 let toml_str = r#"
2561[rules]
2562disabled_rules = [
2563 "CC-MEM-006", # Negative instructions
2564 "PE-003", # Weak language
2565 "XP-001", # Hard-coded paths
2566]
2567"#;
2568 let config: LintConfig = toml::from_str(toml_str).unwrap();
2569
2570 assert!(!config.is_rule_enabled("CC-MEM-006"));
2571 assert!(!config.is_rule_enabled("PE-003"));
2572 assert!(!config.is_rule_enabled("XP-001"));
2573 assert!(config.is_rule_enabled("AS-001"));
2575 assert!(config.is_rule_enabled("MCP-001"));
2576 }
2577
2578 #[test]
2579 fn test_multi_tool_project_config() {
2580 let toml_str = r#"
2582tools = ["claude-code", "cursor"]
2583exclude = ["node_modules/**", ".git/**", "dist/**"]
2584
2585[rules]
2586disabled_rules = ["VER-001"] # Don't warn about version pinning
2587"#;
2588 let config: LintConfig = toml::from_str(toml_str).unwrap();
2589
2590 assert!(config.is_rule_enabled("CC-SK-001"));
2591 assert!(config.is_rule_enabled("CUR-001"));
2592 assert!(!config.is_rule_enabled("VER-001"));
2593 }
2594
2595 #[test]
2596 fn test_strict_ci_config() {
2597 let toml_str = r#"
2599severity = "Error"
2600target = "ClaudeCode"
2601
2602[rules]
2603# Enable everything
2604skills = true
2605hooks = true
2606memory = true
2607xml = true
2608mcp = true
2609disabled_rules = []
2610"#;
2611 let config: LintConfig = toml::from_str(toml_str).unwrap();
2612
2613 assert_eq!(config.severity, SeverityLevel::Error);
2614 assert!(config.rules.skills);
2615 assert!(config.rules.hooks);
2616 assert!(config.rules.disabled_rules.is_empty());
2617 }
2618
2619 #[test]
2622 fn test_default_config_uses_real_filesystem() {
2623 let config = LintConfig::default();
2624
2625 let fs = config.fs();
2627
2628 assert!(fs.exists(Path::new("Cargo.toml")));
2630 assert!(!fs.exists(Path::new("nonexistent_xyz_abc.txt")));
2631 }
2632
2633 #[test]
2634 fn test_set_fs_replaces_filesystem() {
2635 use crate::fs::{FileSystem, MockFileSystem};
2636
2637 let mut config = LintConfig::default();
2638
2639 let mock_fs = Arc::new(MockFileSystem::new());
2641 mock_fs.add_file("/mock/test.md", "mock content");
2642
2643 let fs_arc: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
2645 config.set_fs(fs_arc);
2646
2647 let fs = config.fs();
2649 assert!(fs.exists(Path::new("/mock/test.md")));
2650 assert!(!fs.exists(Path::new("Cargo.toml"))); let content = fs.read_to_string(Path::new("/mock/test.md")).unwrap();
2654 assert_eq!(content, "mock content");
2655 }
2656
2657 #[test]
2658 fn test_set_fs_is_not_serialized() {
2659 use crate::fs::MockFileSystem;
2660
2661 let mut config = LintConfig::default();
2662 config.set_fs(Arc::new(MockFileSystem::new()));
2663
2664 let serialized = toml::to_string(&config).unwrap();
2666 let deserialized: LintConfig = toml::from_str(&serialized).unwrap();
2667
2668 let fs = deserialized.fs();
2671 assert!(fs.exists(Path::new("Cargo.toml")));
2673 }
2674
2675 #[test]
2676 fn test_fs_can_be_shared_across_threads() {
2677 use crate::fs::{FileSystem, MockFileSystem};
2678 use std::thread;
2679
2680 let mut config = LintConfig::default();
2681 let mock_fs = Arc::new(MockFileSystem::new());
2682 mock_fs.add_file("/test/file.md", "content");
2683
2684 let fs_arc: Arc<dyn FileSystem> = mock_fs;
2686 config.set_fs(fs_arc);
2687
2688 let fs = Arc::clone(config.fs());
2690
2691 let handle = thread::spawn(move || {
2693 assert!(fs.exists(Path::new("/test/file.md")));
2694 let content = fs.read_to_string(Path::new("/test/file.md")).unwrap();
2695 assert_eq!(content, "content");
2696 });
2697
2698 handle.join().unwrap();
2699 }
2700
2701 #[test]
2702 fn test_config_fs_returns_arc_ref() {
2703 let config = LintConfig::default();
2704
2705 let fs1 = config.fs();
2707 let fs2 = config.fs();
2708
2709 assert!(Arc::ptr_eq(fs1, fs2));
2711 }
2712
2713 #[test]
2719 fn test_runtime_context_default_values() {
2720 let config = LintConfig::default();
2721
2722 assert!(config.root_dir().is_none());
2727 assert!(config.import_cache().is_none());
2728 assert!(config.fs().exists(Path::new("Cargo.toml")));
2730 }
2731
2732 #[test]
2733 fn test_runtime_context_root_dir_accessor() {
2734 let mut config = LintConfig::default();
2735 assert!(config.root_dir().is_none());
2736
2737 config.set_root_dir(PathBuf::from("/test/path"));
2738 assert_eq!(config.root_dir(), Some(&PathBuf::from("/test/path")));
2739 }
2740
2741 #[test]
2742 fn test_runtime_context_clone_shares_fs() {
2743 use crate::fs::{FileSystem, MockFileSystem};
2744
2745 let mut config = LintConfig::default();
2746 let mock_fs = Arc::new(MockFileSystem::new());
2747 mock_fs.add_file("/shared/file.md", "content");
2748
2749 let fs_arc: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
2750 config.set_fs(fs_arc);
2751
2752 let cloned = config.clone();
2754
2755 assert!(Arc::ptr_eq(config.fs(), cloned.fs()));
2757
2758 assert!(config.fs().exists(Path::new("/shared/file.md")));
2760 assert!(cloned.fs().exists(Path::new("/shared/file.md")));
2761 }
2762
2763 #[test]
2764 fn test_runtime_context_not_serialized() {
2765 let mut config = LintConfig::default();
2766 config.set_root_dir(PathBuf::from("/test/root"));
2767
2768 let serialized = toml::to_string(&config).unwrap();
2770
2771 assert!(!serialized.contains("root_dir"));
2773 assert!(!serialized.contains("/test/root"));
2774
2775 let deserialized: LintConfig = toml::from_str(&serialized).unwrap();
2777
2778 assert!(deserialized.root_dir().is_none());
2780 }
2781
2782 #[test]
2788 fn test_rule_filter_disabled_rules_checked_first() {
2789 let mut config = LintConfig::default();
2790 config.rules.disabled_rules = vec!["AS-001".to_string()];
2791
2792 assert!(!config.is_rule_enabled("AS-001"));
2794
2795 assert!(config.is_rule_enabled("AS-002"));
2797 }
2798
2799 #[test]
2800 fn test_rule_filter_target_checked_second() {
2801 let mut config = LintConfig::default();
2802 config.target = TargetTool::Cursor;
2803
2804 assert!(!config.is_rule_enabled("CC-SK-001"));
2806
2807 assert!(config.is_rule_enabled("AS-001"));
2809 }
2810
2811 #[test]
2812 fn test_rule_filter_category_checked_third() {
2813 let mut config = LintConfig::default();
2814 config.rules.skills = false;
2815
2816 assert!(!config.is_rule_enabled("AS-001"));
2818 assert!(!config.is_rule_enabled("CC-SK-001"));
2819
2820 assert!(config.is_rule_enabled("CC-HK-001"));
2822 assert!(config.is_rule_enabled("MCP-001"));
2823 }
2824
2825 #[test]
2826 fn test_rule_filter_order_of_checks() {
2827 let mut config = LintConfig::default();
2828 config.target = TargetTool::ClaudeCode;
2829 config.rules.skills = true;
2830 config.rules.disabled_rules = vec!["CC-SK-001".to_string()];
2831
2832 assert!(!config.is_rule_enabled("CC-SK-001"));
2834
2835 assert!(config.is_rule_enabled("CC-SK-002"));
2837 }
2838
2839 #[test]
2840 fn test_rule_filter_is_tool_alias_works_through_config() {
2841 assert!(LintConfig::is_tool_alias("copilot", "github-copilot"));
2843 assert!(!LintConfig::is_tool_alias("unknown", "github-copilot"));
2844 }
2845
2846 #[test]
2849 fn test_serde_roundtrip_preserves_all_public_fields() {
2850 let mut config = LintConfig::default();
2851 config.severity = SeverityLevel::Error;
2852 config.target = TargetTool::ClaudeCode;
2853 config.tools = vec!["claude-code".to_string(), "cursor".to_string()];
2854 config.exclude = vec!["custom/**".to_string()];
2855 config.mcp_protocol_version = Some("2024-11-05".to_string());
2856 config.tool_versions.claude_code = Some("1.0.0".to_string());
2857 config.spec_revisions.mcp_protocol = Some("2025-06-18".to_string());
2858 config.rules.skills = false;
2859 config.rules.disabled_rules = vec!["MCP-001".to_string()];
2860
2861 config.set_root_dir(PathBuf::from("/test/root"));
2863
2864 let serialized = toml::to_string(&config).unwrap();
2866
2867 let deserialized: LintConfig = toml::from_str(&serialized).unwrap();
2869
2870 assert_eq!(deserialized.severity, SeverityLevel::Error);
2872 assert_eq!(deserialized.target, TargetTool::ClaudeCode);
2873 assert_eq!(deserialized.tools, vec!["claude-code", "cursor"]);
2874 assert_eq!(deserialized.exclude, vec!["custom/**"]);
2875 assert_eq!(
2876 deserialized.mcp_protocol_version,
2877 Some("2024-11-05".to_string())
2878 );
2879 assert_eq!(
2880 deserialized.tool_versions.claude_code,
2881 Some("1.0.0".to_string())
2882 );
2883 assert_eq!(
2884 deserialized.spec_revisions.mcp_protocol,
2885 Some("2025-06-18".to_string())
2886 );
2887 assert!(!deserialized.rules.skills);
2888 assert_eq!(deserialized.rules.disabled_rules, vec!["MCP-001"]);
2889
2890 assert!(deserialized.root_dir().is_none());
2892 }
2893
2894 #[test]
2895 fn test_serde_runtime_fields_not_included() {
2896 use crate::fs::MockFileSystem;
2897
2898 let mut config = LintConfig::default();
2899 config.set_root_dir(PathBuf::from("/test"));
2900 config.set_fs(Arc::new(MockFileSystem::new()));
2901
2902 let serialized = toml::to_string(&config).unwrap();
2903
2904 assert!(!serialized.contains("runtime"));
2906 assert!(!serialized.contains("root_dir"));
2907 assert!(!serialized.contains("import_cache"));
2908 assert!(!serialized.contains("fs"));
2909 }
2910
2911 #[test]
2914 fn test_generate_schema_produces_valid_json() {
2915 let schema = super::generate_schema();
2916 let json = serde_json::to_string_pretty(&schema).unwrap();
2917
2918 let _: serde_json::Value = serde_json::from_str(&json).unwrap();
2920
2921 assert!(json.contains("\"$schema\""));
2923 assert!(json.contains("\"title\": \"LintConfig\""));
2924 assert!(json.contains("\"type\": \"object\""));
2925 }
2926
2927 #[test]
2928 fn test_generate_schema_includes_all_fields() {
2929 let schema = super::generate_schema();
2930 let json = serde_json::to_string(&schema).unwrap();
2931
2932 assert!(json.contains("\"severity\""));
2934 assert!(json.contains("\"rules\""));
2935 assert!(json.contains("\"exclude\""));
2936 assert!(json.contains("\"target\""));
2937 assert!(json.contains("\"tools\""));
2938 assert!(json.contains("\"tool_versions\""));
2939 assert!(json.contains("\"spec_revisions\""));
2940
2941 assert!(!json.contains("\"root_dir\""));
2943 assert!(!json.contains("\"import_cache\""));
2944 assert!(!json.contains("\"runtime\""));
2945 }
2946
2947 #[test]
2948 fn test_generate_schema_includes_definitions() {
2949 let schema = super::generate_schema();
2950 let json = serde_json::to_string(&schema).unwrap();
2951
2952 assert!(json.contains("\"RuleConfig\""));
2954 assert!(json.contains("\"SeverityLevel\""));
2955 assert!(json.contains("\"TargetTool\""));
2956 assert!(json.contains("\"ToolVersions\""));
2957 assert!(json.contains("\"SpecRevisions\""));
2958 }
2959
2960 #[test]
2961 fn test_generate_schema_includes_descriptions() {
2962 let schema = super::generate_schema();
2963 let json = serde_json::to_string(&schema).unwrap();
2964
2965 assert!(json.contains("\"description\""));
2967 assert!(json.contains("Minimum severity level to report"));
2968 assert!(json.contains("Glob patterns for paths to exclude"));
2969 assert!(json.contains("Enable Agent Skills validation rules"));
2970 }
2971
2972 #[test]
2975 fn test_validate_empty_config_no_warnings() {
2976 let config = LintConfig::default();
2977 let warnings = config.validate();
2978
2979 assert!(warnings.is_empty());
2981 }
2982
2983 #[test]
2984 fn test_validate_valid_disabled_rules() {
2985 let mut config = LintConfig::default();
2986 config.rules.disabled_rules = vec![
2987 "AS-001".to_string(),
2988 "CC-SK-007".to_string(),
2989 "MCP-001".to_string(),
2990 "PE-003".to_string(),
2991 "XP-001".to_string(),
2992 "AGM-001".to_string(),
2993 "COP-001".to_string(),
2994 "CUR-001".to_string(),
2995 "XML-001".to_string(),
2996 "REF-001".to_string(),
2997 "VER-001".to_string(),
2998 ];
2999
3000 let warnings = config.validate();
3001
3002 assert!(warnings.is_empty());
3004 }
3005
3006 #[test]
3007 fn test_validate_invalid_disabled_rule_pattern() {
3008 let mut config = LintConfig::default();
3009 config.rules.disabled_rules = vec!["INVALID-001".to_string(), "UNKNOWN-999".to_string()];
3010
3011 let warnings = config.validate();
3012
3013 assert_eq!(warnings.len(), 2);
3014 assert!(warnings[0].field.contains("disabled_rules"));
3015 assert!(warnings[0].message.contains("Unknown rule ID pattern"));
3016 assert!(warnings[1].message.contains("UNKNOWN-999"));
3017 }
3018
3019 #[test]
3020 fn test_validate_ver_prefix_accepted() {
3021 let mut config = LintConfig::default();
3023 config.rules.disabled_rules = vec!["VER-001".to_string()];
3024
3025 let warnings = config.validate();
3026
3027 assert!(warnings.is_empty());
3028 }
3029
3030 #[test]
3031 fn test_validate_valid_tools() {
3032 let mut config = LintConfig::default();
3033 config.tools = vec![
3034 "claude-code".to_string(),
3035 "cursor".to_string(),
3036 "codex".to_string(),
3037 "copilot".to_string(),
3038 "github-copilot".to_string(),
3039 "generic".to_string(),
3040 ];
3041
3042 let warnings = config.validate();
3043
3044 assert!(warnings.is_empty());
3046 }
3047
3048 #[test]
3049 fn test_validate_invalid_tool() {
3050 let mut config = LintConfig::default();
3051 config.tools = vec!["unknown-tool".to_string(), "invalid".to_string()];
3052
3053 let warnings = config.validate();
3054
3055 assert_eq!(warnings.len(), 2);
3056 assert!(warnings[0].field == "tools");
3057 assert!(warnings[0].message.contains("Unknown tool"));
3058 assert!(warnings[0].message.contains("unknown-tool"));
3059 }
3060
3061 #[test]
3062 fn test_validate_deprecated_mcp_protocol_version() {
3063 let mut config = LintConfig::default();
3064 config.mcp_protocol_version = Some("2024-11-05".to_string());
3065
3066 let warnings = config.validate();
3067
3068 assert_eq!(warnings.len(), 1);
3069 assert!(warnings[0].field == "mcp_protocol_version");
3070 assert!(warnings[0].message.contains("deprecated"));
3071 assert!(
3072 warnings[0]
3073 .suggestion
3074 .as_ref()
3075 .unwrap()
3076 .contains("spec_revisions.mcp_protocol")
3077 );
3078 }
3079
3080 #[test]
3081 fn test_validate_mixed_valid_invalid() {
3082 let mut config = LintConfig::default();
3083 config.rules.disabled_rules = vec![
3084 "AS-001".to_string(), "INVALID-1".to_string(), "CC-SK-001".to_string(), ];
3088 config.tools = vec![
3089 "claude-code".to_string(), "bad-tool".to_string(), ];
3092
3093 let warnings = config.validate();
3094
3095 assert_eq!(warnings.len(), 2);
3097 }
3098
3099 #[test]
3100 fn test_config_warning_has_suggestion() {
3101 let mut config = LintConfig::default();
3102 config.rules.disabled_rules = vec!["INVALID-001".to_string()];
3103
3104 let warnings = config.validate();
3105
3106 assert!(!warnings.is_empty());
3107 assert!(warnings[0].suggestion.is_some());
3108 }
3109
3110 #[test]
3111 fn test_validate_case_insensitive_tools() {
3112 let mut config = LintConfig::default();
3114 config.tools = vec![
3115 "CLAUDE-CODE".to_string(),
3116 "CuRsOr".to_string(),
3117 "COPILOT".to_string(),
3118 ];
3119
3120 let warnings = config.validate();
3121
3122 assert!(
3124 warnings.is_empty(),
3125 "Expected no warnings for valid tools with different cases, got: {:?}",
3126 warnings
3127 );
3128 }
3129
3130 #[test]
3131 fn test_validate_multiple_warnings_same_category() {
3132 let mut config = LintConfig::default();
3134 config.rules.disabled_rules = vec![
3135 "INVALID-001".to_string(),
3136 "FAKE-RULE".to_string(),
3137 "NOT-A-RULE".to_string(),
3138 ];
3139
3140 let warnings = config.validate();
3141
3142 assert_eq!(warnings.len(), 3, "Expected 3 warnings for 3 invalid rules");
3144
3145 let warning_messages: Vec<&str> = warnings.iter().map(|w| w.message.as_str()).collect();
3147 assert!(warning_messages.iter().any(|m| m.contains("INVALID-001")));
3148 assert!(warning_messages.iter().any(|m| m.contains("FAKE-RULE")));
3149 assert!(warning_messages.iter().any(|m| m.contains("NOT-A-RULE")));
3150 }
3151
3152 #[test]
3153 fn test_validate_multiple_invalid_tools() {
3154 let mut config = LintConfig::default();
3155 config.tools = vec![
3156 "unknown-tool".to_string(),
3157 "bad-editor".to_string(),
3158 "claude-code".to_string(), ];
3160
3161 let warnings = config.validate();
3162
3163 assert_eq!(warnings.len(), 2, "Expected 2 warnings for 2 invalid tools");
3165 }
3166
3167 #[test]
3168 fn test_validate_empty_string_in_tools() {
3169 let mut config = LintConfig::default();
3171 config.tools = vec!["".to_string(), "claude-code".to_string()];
3172
3173 let warnings = config.validate();
3174
3175 assert_eq!(warnings.len(), 1);
3177 assert!(warnings[0].message.contains("Unknown tool ''"));
3178 }
3179
3180 #[test]
3181 fn test_validate_deprecated_target_field() {
3182 let mut config = LintConfig::default();
3183 config.target = TargetTool::ClaudeCode;
3184 let warnings = config.validate();
3187
3188 assert_eq!(warnings.len(), 1);
3189 assert_eq!(warnings[0].field, "target");
3190 assert!(warnings[0].message.contains("deprecated"));
3191 assert!(warnings[0].suggestion.as_ref().unwrap().contains("tools"));
3192 }
3193
3194 #[test]
3195 fn test_validate_target_with_tools_no_warning() {
3196 let mut config = LintConfig::default();
3199 config.target = TargetTool::ClaudeCode;
3200 config.tools = vec!["claude-code".to_string()];
3201
3202 let warnings = config.validate();
3203
3204 assert!(warnings.is_empty());
3206 }
3207}