Skip to main content

agnix_core/
config.rs

1//! Linter configuration
2
3use 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/// Tool version pinning for version-aware validation
13///
14/// When tool versions are pinned, validators can apply version-specific
15/// behavior instead of using default assumptions. When not pinned,
16/// validators will use sensible defaults and add assumption notes.
17#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
18pub struct ToolVersions {
19    /// Claude Code version (e.g., "1.0.0")
20    #[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    /// Codex CLI version (e.g., "0.1.0")
25    #[serde(default)]
26    #[schemars(description = "Codex CLI version for version-aware validation (e.g., \"0.1.0\")")]
27    pub codex: Option<String>,
28
29    /// Cursor version (e.g., "0.45.0")
30    #[serde(default)]
31    #[schemars(description = "Cursor version for version-aware validation (e.g., \"0.45.0\")")]
32    pub cursor: Option<String>,
33
34    /// GitHub Copilot version (e.g., "1.0.0")
35    #[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/// Specification revision pinning for version-aware validation
43///
44/// When spec revisions are pinned, validators can apply revision-specific
45/// rules. When not pinned, validators use the latest known revision.
46#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
47pub struct SpecRevisions {
48    /// MCP protocol version (e.g., "2025-06-18", "2024-11-05")
49    #[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    /// Agent Skills specification revision
56    #[serde(default)]
57    #[schemars(description = "Agent Skills specification revision")]
58    pub agent_skills_spec: Option<String>,
59
60    /// AGENTS.md specification revision
61    #[serde(default)]
62    #[schemars(description = "AGENTS.md specification revision")]
63    pub agents_md_spec: Option<String>,
64}
65
66// =============================================================================
67// Internal Composition Types (Facade Pattern)
68// =============================================================================
69//
70// LintConfig uses internal composition to separate concerns while maintaining
71// a stable public API. These types are private implementation details:
72//
73// - RuntimeContext: Groups non-serialized runtime state (root_dir, import_cache, fs)
74// - DefaultRuleFilter: Encapsulates rule filtering logic (~100 lines)
75//
76// This pattern provides:
77// 1. Better code organization without breaking changes
78// 2. Easier testing of individual components
79// 3. Clear separation between serialized config and runtime state
80// =============================================================================
81
82/// Runtime context for validation operations (not serialized).
83///
84/// Groups non-serialized state that is set up at runtime and shared during
85/// validation. This includes the project root, import cache, and filesystem
86/// abstraction.
87///
88/// # Thread Safety
89///
90/// `RuntimeContext` is `Send + Sync` because:
91/// - `PathBuf` and `Option<T>` are `Send + Sync`
92/// - `ImportCache` uses interior mutability with thread-safe types
93/// - `Arc<dyn FileSystem>` shares the filesystem without deep-cloning
94///
95/// # Clone Behavior
96///
97/// When cloned, the `Arc<dyn FileSystem>` is shared (not deep-cloned),
98/// maintaining the same filesystem instance across clones.
99///
100/// # Note
101///
102/// The `root_dir` and `import_cache` fields are kept as direct public
103/// fields on `LintConfig` for backward compatibility. This struct only
104/// contains the filesystem abstraction.
105#[derive(Clone)]
106struct RuntimeContext {
107    /// File system abstraction for testability.
108    ///
109    /// Validators use this to perform file system operations. Defaults to
110    /// `RealFileSystem` which delegates to `std::fs` and `file_utils`.
111    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
130/// Rule filtering logic encapsulated for clarity.
131///
132/// This trait and its implementation extract the rule enablement logic
133/// from LintConfig, making it easier to test and maintain.
134trait RuleFilter {
135    /// Check if a specific rule is enabled based on config.
136    fn is_rule_enabled(&self, rule_id: &str) -> bool;
137}
138
139/// Default implementation of rule filtering logic.
140///
141/// Determines whether a rule is enabled based on:
142/// 1. Explicit disabled_rules list
143/// 2. Target tool or tools array filtering
144/// 3. Category enablement flags
145struct 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    /// Check if a rule applies to the current target tool(s)
161    fn is_rule_for_target(&self, rule_id: &str) -> bool {
162        // If tools array is specified, use it for filtering
163        if !self.tools.is_empty() {
164            return self.is_rule_for_tools(rule_id);
165        }
166
167        // Legacy: CC-* rules only apply to ClaudeCode or Generic targets
168        if rule_id.starts_with("CC-") {
169            return matches!(self.target, TargetTool::ClaudeCode | TargetTool::Generic);
170        }
171        // All other rules apply to all targets (see TOOL_RULE_PREFIXES for tool-specific rules)
172        true
173    }
174
175    /// Check if a rule applies based on the tools array
176    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                // Check if the required tool is in the tools list (case-insensitive)
180                // Also accept backward-compat aliases (e.g., "copilot" for "github-copilot")
181                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        // Generic rules (AS-*, XML-*, REF-*, XP-*, AGM-*, MCP-*, PE-*) apply to all tools
189        true
190    }
191
192    /// Check if a user-provided tool name is a backward-compatible alias
193    /// for the canonical tool name from rules.json.
194    ///
195    /// Currently only "github-copilot" has an alias ("copilot"). This exists for
196    /// backward compatibility: early versions of agnix used the shorter "copilot"
197    /// name in configs, and we need to continue supporting that for existing users.
198    /// The canonical names in rules.json use the full "github-copilot" to match
199    /// the official tool name from GitHub's documentation.
200    ///
201    /// Note: This function does NOT treat canonical names as aliases of themselves.
202    /// For example, "github-copilot" is NOT an alias for "github-copilot" - that's
203    /// handled by the direct eq_ignore_ascii_case comparison in is_rule_for_tools().
204    fn is_tool_alias(user_tool: &str, canonical_tool: &str) -> bool {
205        // Backward compatibility: accept short names as aliases
206        match canonical_tool {
207            "github-copilot" => user_tool.eq_ignore_ascii_case("copilot"),
208            _ => false,
209        }
210    }
211
212    /// Check if a rule's category is enabled
213    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            // Unknown rules are enabled by default
229            _ => true,
230        }
231    }
232}
233
234impl RuleFilter for DefaultRuleFilter<'_> {
235    fn is_rule_enabled(&self, rule_id: &str) -> bool {
236        // Check if explicitly disabled
237        if self.rules.disabled_rules.iter().any(|r| r == rule_id) {
238            return false;
239        }
240
241        // Check if rule applies to target
242        if !self.is_rule_for_target(rule_id) {
243            return false;
244        }
245
246        // Check if category is enabled
247        self.is_category_enabled(rule_id)
248    }
249}
250
251/// Configuration for the linter
252#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253#[serde(default)]
254pub struct LintConfig {
255    /// Severity level threshold
256    #[schemars(description = "Minimum severity level to report (Error, Warning, Info)")]
257    pub severity: SeverityLevel,
258
259    /// Rules to enable/disable
260    #[schemars(description = "Configuration for enabling/disabling validation rules by category")]
261    pub rules: RuleConfig,
262
263    /// Paths to exclude
264    #[schemars(
265        description = "Glob patterns for paths to exclude from validation (e.g., [\"node_modules/**\", \"dist/**\"])"
266    )]
267    pub exclude: Vec<String>,
268
269    /// Target tool (claude-code, cursor, codex, generic)
270    /// Deprecated: Use `tools` array instead for multi-tool support
271    #[schemars(description = "Target tool for validation (deprecated: use 'tools' array instead)")]
272    pub target: TargetTool,
273
274    /// Tools to validate for (e.g., ["claude-code", "cursor"])
275    /// When specified, agnix automatically enables rules for these tools
276    /// and disables rules for tools not in the list.
277    /// Valid values: "claude-code", "cursor", "codex", "copilot", "generic"
278    #[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    /// Expected MCP protocol version for validation (MCP-008)
285    /// Deprecated: Use spec_revisions.mcp_protocol instead
286    #[schemars(
287        description = "Expected MCP protocol version (deprecated: use spec_revisions.mcp_protocol instead)"
288    )]
289    pub mcp_protocol_version: Option<String>,
290
291    /// Tool version pinning for version-aware validation
292    #[serde(default)]
293    #[schemars(description = "Pin specific tool versions for version-aware validation")]
294    pub tool_versions: ToolVersions,
295
296    /// Specification revision pinning for version-aware validation
297    #[serde(default)]
298    #[schemars(description = "Pin specific specification revisions for revision-aware validation")]
299    pub spec_revisions: SpecRevisions,
300
301    /// Output locale for translated messages (e.g., "en", "es", "zh-CN").
302    /// When not set, the CLI locale detection is used.
303    #[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    /// Maximum number of files to validate before stopping.
310    ///
311    /// This is a security feature to prevent DoS attacks via projects with
312    /// millions of small files. When the limit is reached, validation stops
313    /// with a `TooManyFiles` error.
314    ///
315    /// Default: 10,000 files. Set to `None` to disable the limit (not recommended).
316    #[serde(default = "default_max_files")]
317    pub max_files_to_validate: Option<usize>,
318    /// Project root directory for validation (not serialized).
319    ///
320    /// When set, validators can use this to resolve relative paths and
321    /// detect project-escape attempts in import validation.
322    #[serde(skip)]
323    #[schemars(skip)]
324    pub root_dir: Option<PathBuf>,
325
326    /// Shared import cache for project-level validation (not serialized).
327    ///
328    /// When set, validators can use this cache to share parsed import data
329    /// across files, avoiding redundant parsing during import chain traversal.
330    #[serde(skip)]
331    #[schemars(skip)]
332    pub import_cache: Option<crate::parsers::ImportCache>,
333
334    /// Internal runtime context for validation operations (not serialized).
335    ///
336    /// Groups the filesystem abstraction. The `root_dir` and `import_cache`
337    /// fields are kept separate for backward compatibility.
338    #[serde(skip)]
339    #[schemars(skip)]
340    runtime: RuntimeContext,
341}
342
343/// Default maximum files to validate (security limit)
344///
345/// **Design Decision**: 10,000 files was chosen as a balance between:
346/// - Large enough for realistic projects (Linux kernel has ~70k files, but most are not validated)
347/// - Small enough to prevent DoS from projects with millions of tiny files
348/// - Completes validation in reasonable time (seconds to low minutes on typical hardware)
349/// - Atomic counter with SeqCst ordering provides thread-safe counting during parallel validation
350///
351/// Users can override with `--max-files N` or disable with `--max-files 0` (not recommended).
352/// Set to `None` to disable the limit entirely (use with caution).
353pub const DEFAULT_MAX_FILES: usize = 10_000;
354
355/// Helper function for serde default
356fn 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    /// Only show errors
390    Error,
391    /// Show errors and warnings
392    Warning,
393    /// Show all diagnostics including info
394    Info,
395}
396
397/// Helper function for serde default
398fn 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    /// Enable skills validation (AS-*, CC-SK-*)
406    #[serde(default = "default_true")]
407    #[schemars(description = "Enable Agent Skills validation rules (AS-*, CC-SK-*)")]
408    pub skills: bool,
409
410    /// Enable hooks validation (CC-HK-*)
411    #[serde(default = "default_true")]
412    #[schemars(description = "Enable Claude Code hooks validation rules (CC-HK-*)")]
413    pub hooks: bool,
414
415    /// Enable agents validation (CC-AG-*)
416    #[serde(default = "default_true")]
417    #[schemars(description = "Enable Claude Code agents validation rules (CC-AG-*)")]
418    pub agents: bool,
419
420    /// Enable memory validation (CC-MEM-*)
421    #[serde(default = "default_true")]
422    #[schemars(description = "Enable Claude Code memory validation rules (CC-MEM-*)")]
423    pub memory: bool,
424
425    /// Enable plugins validation (CC-PL-*)
426    #[serde(default = "default_true")]
427    #[schemars(description = "Enable Claude Code plugins validation rules (CC-PL-*)")]
428    pub plugins: bool,
429
430    /// Enable XML balance checking (XML-*)
431    #[serde(default = "default_true")]
432    #[schemars(description = "Enable XML tag balance validation rules (XML-*)")]
433    pub xml: bool,
434
435    /// Enable MCP validation (MCP-*)
436    #[serde(default = "default_true")]
437    #[schemars(description = "Enable Model Context Protocol validation rules (MCP-*)")]
438    pub mcp: bool,
439
440    /// Enable import reference validation (REF-*)
441    #[serde(default = "default_true")]
442    #[schemars(description = "Enable import reference validation rules (REF-*)")]
443    pub imports: bool,
444
445    /// Enable cross-platform validation (XP-*)
446    #[serde(default = "default_true")]
447    #[schemars(description = "Enable cross-platform validation rules (XP-*)")]
448    pub cross_platform: bool,
449
450    /// Enable AGENTS.md validation (AGM-*)
451    #[serde(default = "default_true")]
452    #[schemars(description = "Enable AGENTS.md validation rules (AGM-*)")]
453    pub agents_md: bool,
454
455    /// Enable GitHub Copilot validation (COP-*)
456    #[serde(default = "default_true")]
457    #[schemars(description = "Enable GitHub Copilot validation rules (COP-*)")]
458    pub copilot: bool,
459
460    /// Enable Cursor project rules validation (CUR-*)
461    #[serde(default = "default_true")]
462    #[schemars(description = "Enable Cursor project rules validation (CUR-*)")]
463    pub cursor: bool,
464
465    /// Enable prompt engineering validation (PE-*)
466    #[serde(default = "default_true")]
467    #[schemars(description = "Enable prompt engineering validation rules (PE-*)")]
468    pub prompt_engineering: bool,
469
470    /// Detect generic instructions in CLAUDE.md
471    #[serde(default = "default_true")]
472    #[schemars(description = "Detect generic placeholder instructions in CLAUDE.md")]
473    pub generic_instructions: bool,
474
475    /// Validate YAML frontmatter
476    #[serde(default = "default_true")]
477    #[schemars(description = "Validate YAML frontmatter in skill files")]
478    pub frontmatter_validation: bool,
479
480    /// Check XML tag balance (legacy - use xml instead)
481    #[serde(default = "default_true")]
482    #[schemars(description = "Check XML tag balance (legacy: use 'xml' instead)")]
483    pub xml_balance: bool,
484
485    /// Validate @import references (legacy - use imports instead)
486    #[serde(default = "default_true")]
487    #[schemars(description = "Validate @import references (legacy: use 'imports' instead)")]
488    pub import_references: bool,
489
490    /// Explicitly disabled rules by ID (e.g., ["CC-AG-001", "AS-005"])
491    #[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 Agent Skills standard
529    Generic,
530    /// Claude Code specific
531    ClaudeCode,
532    /// Cursor specific
533    Cursor,
534    /// Codex specific
535    Codex,
536}
537
538impl LintConfig {
539    /// Load config from file
540    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    /// Load config or use default, returning any parse warning
547    ///
548    /// Returns a tuple of (config, optional_warning). If a config path is provided
549    /// but the file cannot be loaded or parsed, returns the default config with a
550    /// warning message describing the error. This prevents silent fallback to
551    /// defaults on config typos or missing/unreadable config files.
552    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    // =========================================================================
570    // Runtime Context Accessors
571    // =========================================================================
572    //
573    // These methods delegate to RuntimeContext, maintaining the same public API.
574    // =========================================================================
575
576    /// Get the runtime validation root directory, if set.
577    ///
578    /// Note: For backward compatibility, you can also access `config.root_dir`
579    /// directly as a public field.
580    #[inline]
581    pub fn root_dir(&self) -> Option<&PathBuf> {
582        self.root_dir.as_ref()
583    }
584
585    /// Alias for `root_dir()` for consistency with other accessors.
586    #[inline]
587    pub fn get_root_dir(&self) -> Option<&PathBuf> {
588        self.root_dir()
589    }
590
591    /// Set the runtime validation root directory (not persisted)
592    ///
593    /// Note: For backward compatibility, you can also set `config.root_dir`
594    /// directly as a public field.
595    pub fn set_root_dir(&mut self, root_dir: PathBuf) {
596        self.root_dir = Some(root_dir);
597    }
598
599    /// Set the shared import cache for project-level validation (not persisted).
600    ///
601    /// When set, the ImportsValidator will use this cache to share parsed
602    /// import data across files, improving performance by avoiding redundant
603    /// parsing during import chain traversal.
604    ///
605    /// Note: For backward compatibility, you can also set `config.import_cache`
606    /// directly as a public field.
607    pub fn set_import_cache(&mut self, cache: crate::parsers::ImportCache) {
608        self.import_cache = Some(cache);
609    }
610
611    /// Get the shared import cache, if one has been set.
612    ///
613    /// Returns `None` for single-file validation or when the cache hasn't
614    /// been initialized. Returns `Some(&ImportCache)` during project-level
615    /// validation where import results are shared across files.
616    ///
617    /// Note: For backward compatibility, you can also access `config.import_cache`
618    /// directly as a public field.
619    #[inline]
620    pub fn import_cache(&self) -> Option<&crate::parsers::ImportCache> {
621        self.import_cache.as_ref()
622    }
623
624    /// Alias for `import_cache()` for consistency with other accessors.
625    #[inline]
626    pub fn get_import_cache(&self) -> Option<&crate::parsers::ImportCache> {
627        self.import_cache()
628    }
629
630    /// Get the file system abstraction.
631    ///
632    /// Validators should use this for file system operations instead of
633    /// directly calling `std::fs` functions. This enables unit testing
634    /// with `MockFileSystem`.
635    pub fn fs(&self) -> &Arc<dyn FileSystem> {
636        &self.runtime.fs
637    }
638
639    /// Set the file system abstraction (not persisted).
640    ///
641    /// This is primarily used for testing with `MockFileSystem`.
642    ///
643    /// # Important
644    ///
645    /// This should only be called during configuration setup, before validation
646    /// begins. Changing the filesystem during validation may cause inconsistent
647    /// results if validators have already cached file state.
648    pub fn set_fs(&mut self, fs: Arc<dyn FileSystem>) {
649        self.runtime.fs = fs;
650    }
651
652    /// Get the expected MCP protocol version
653    ///
654    /// Priority: spec_revisions.mcp_protocol > mcp_protocol_version > default
655    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    /// Check if MCP protocol revision is explicitly pinned
664    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    /// Check if Claude Code version is explicitly pinned
669    pub fn is_claude_code_version_pinned(&self) -> bool {
670        self.tool_versions.claude_code.is_some()
671    }
672
673    /// Get the pinned Claude Code version, if any
674    pub fn get_claude_code_version(&self) -> Option<&str> {
675        self.tool_versions.claude_code.as_deref()
676    }
677
678    // =========================================================================
679    // Rule Filtering (delegates to DefaultRuleFilter)
680    // =========================================================================
681
682    /// Check if a specific rule is enabled based on config
683    ///
684    /// A rule is enabled if:
685    /// 1. It's not in the disabled_rules list
686    /// 2. It's applicable to the current target tool
687    /// 3. Its category is enabled
688    ///
689    /// This delegates to `DefaultRuleFilter` which encapsulates the filtering logic.
690    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    /// Check if a user-provided tool name is a backward-compatible alias
696    /// for the canonical tool name from rules.json.
697    ///
698    /// Currently only "github-copilot" has an alias ("copilot"). This exists for
699    /// backward compatibility: early versions of agnix used the shorter "copilot"
700    /// name in configs, and we need to continue supporting that for existing users.
701    /// The canonical names in rules.json use the full "github-copilot" to match
702    /// the official tool name from GitHub's documentation.
703    ///
704    /// Note: This function does NOT treat canonical names as aliases of themselves.
705    /// For example, "github-copilot" is NOT an alias for "github-copilot" - that's
706    /// handled by the direct eq_ignore_ascii_case comparison in is_rule_for_tools().
707    pub fn is_tool_alias(user_tool: &str, canonical_tool: &str) -> bool {
708        DefaultRuleFilter::is_tool_alias(user_tool, canonical_tool)
709    }
710
711    /// Validate the configuration and return any warnings.
712    ///
713    /// This performs semantic validation beyond what TOML parsing can check:
714    /// - Validates that disabled_rules match known rule ID patterns
715    /// - Validates that tools array contains known tool names
716    /// - Warns on deprecated fields
717    pub fn validate(&self) -> Vec<ConfigWarning> {
718        let mut warnings = Vec::new();
719
720        // Validate disabled_rules match known patterns
721        // Note: imports:: is a legacy prefix used in some internal diagnostics
722        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        // Validate tools array contains known tools
759        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        // Warn on deprecated fields
787        if self.target != TargetTool::Generic && self.tools.is_empty() {
788            // Only warn if target is non-default and tools is empty
789            // (if both are set, tools takes precedence silently)
790            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/// Warning from configuration validation.
809///
810/// These warnings indicate potential issues with the configuration that
811/// don't prevent validation from running but may indicate user mistakes.
812#[derive(Debug, Clone, PartialEq, Eq)]
813pub struct ConfigWarning {
814    /// The field path that has the issue (e.g., "rules.disabled_rules")
815    pub field: String,
816    /// Description of the issue
817    pub message: String,
818    /// Optional suggestion for how to fix the issue
819    pub suggestion: Option<String>,
820}
821
822/// Generate a JSON Schema for the LintConfig type.
823///
824/// This can be used to provide editor autocompletion and validation
825/// for `.agnix.toml` configuration files.
826///
827/// # Example
828///
829/// ```rust
830/// use agnix_core::config::generate_schema;
831///
832/// let schema = generate_schema();
833/// let json = serde_json::to_string_pretty(&schema).unwrap();
834/// println!("{}", json);
835/// ```
836pub 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        // Test various rule IDs
850        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        // Other categories still enabled
882        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        // Other categories still enabled
895        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        // Other categories still enabled
908        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        // Other categories still enabled
920        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        // Other categories still enabled
931        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        // Other categories still enabled
944        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        // Other categories still enabled
956        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        // CC-* rules should be disabled for Cursor
965        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        // AS-* rules should still work
971        assert!(config.is_rule_enabled("AS-005"));
972        assert!(config.is_rule_enabled("AS-006"));
973
974        // XML and imports should still work
975        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        // CC-* rules should be disabled for Codex
985        assert!(!config.is_rule_enabled("CC-AG-001"));
986        assert!(!config.is_rule_enabled("CC-HK-001"));
987
988        // AS-* rules should still work
989        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        // All rules should be enabled
998        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(); // Default is Generic
1006
1007        // All rules should be enabled
1008        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        // Unknown rule IDs should be enabled
1019        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        // Even with skills enabled, this specific rule is disabled
1030        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        // Check rule enablement
1063        assert!(config.is_rule_enabled("CC-AG-001"));
1064        assert!(!config.is_rule_enabled("CC-AG-002")); // Disabled in list
1065        assert!(!config.is_rule_enabled("CC-HK-001")); // hooks category disabled
1066    }
1067
1068    #[test]
1069    fn test_toml_deserialization_defaults() {
1070        // Minimal config should use defaults
1071        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        // All categories should default to true
1082        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    // ===== MCP Category Tests =====
1096
1097    #[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        // Other categories still enabled
1110        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    // ===== MCP Protocol Version Config Tests =====
1129
1130    #[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        // Should fall back to default when None
1148        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        // Without specifying mcp_protocol_version, should use default
1169        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    // ===== Cross-Platform Category Tests =====
1182
1183    #[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        // Other categories still enabled
1202        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        // XP-* rules are NOT target-specific (unlike CC-* rules)
1209        // They should work with Cursor, Codex, and all targets
1210        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    // ===== AGENTS.md Category Tests =====
1269
1270    #[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        // Other categories still enabled
1295        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        // AGM-* rules are NOT target-specific (unlike CC-* rules)
1303        // They should work with Cursor, Codex, and all targets
1304        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    // ===== Prompt Engineering Category Tests =====
1360
1361    #[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        // Other categories still enabled
1382        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        // PE-* rules are NOT target-specific
1390        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    // ===== GitHub Copilot Category Tests =====
1456
1457    #[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        // Other categories still enabled
1478        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        // COP-* rules are NOT target-specific
1486        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    // ===== Cursor Category Tests =====
1552
1553    #[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        // Other categories still enabled
1578        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        // CUR-* rules are NOT target-specific
1586        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    // ===== Config Load Warning Tests =====
1646
1647    #[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        // Should return default config
1656        assert_eq!(config.target, TargetTool::Generic);
1657        assert!(config.rules.skills);
1658
1659        // Should have a warning message
1660        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        // Should return default config
1704        assert_eq!(config.target, TargetTool::Generic);
1705
1706        // Should have a warning about the missing file
1707        assert!(warning.is_some());
1708        let msg = warning.unwrap();
1709        assert!(msg.contains("Failed to parse config"));
1710    }
1711
1712    // ===== Backward Compatibility Tests =====
1713
1714    #[test]
1715    fn test_old_config_with_removed_fields_still_parses() {
1716        // Test that configs with the removed tool_names and required_fields
1717        // options still parse correctly (serde ignores unknown fields by default)
1718        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        // Config should parse successfully with expected values
1734        assert_eq!(config.target, TargetTool::Generic);
1735        assert!(config.rules.skills);
1736        assert!(config.rules.hooks);
1737        // The removed fields are simply ignored
1738    }
1739
1740    // ===== Tool Versions Tests =====
1741
1742    #[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    // ===== Spec Revisions Tests =====
1795
1796    #[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        // mcp_protocol_version is None by default, so is_mcp_revision_pinned returns false
1804        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        // spec_revisions.mcp_protocol should take precedence over mcp_protocol_version
1828        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        // When spec_revisions.mcp_protocol is not set, fall back to mcp_protocol_version
1847        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    // ===== Backward Compatibility with New Fields =====
1893
1894    #[test]
1895    fn test_config_without_tool_versions_defaults() {
1896        // Old configs without tool_versions section should still work
1897        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        // Old configs without spec_revisions section should still work
1914        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        // mcp_protocol_version is None when not specified, so is_mcp_revision_pinned returns false
1924        assert!(!config.is_mcp_revision_pinned());
1925        // get_mcp_protocol_version still returns default value
1926        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        // When both spec_revisions.mcp_protocol and mcp_protocol_version are None
1932        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        // Should still return default
1938        assert_eq!(config.get_mcp_protocol_version(), "2025-06-18");
1939    }
1940
1941    // ===== Tools Array Tests =====
1942
1943    #[test]
1944    fn test_tools_array_empty_uses_target() {
1945        // When tools is empty, fall back to target behavior
1946        let mut config = LintConfig::default();
1947        config.tools = vec![];
1948        config.target = TargetTool::Cursor;
1949
1950        // With Cursor target and empty tools, CC-* rules should be disabled
1951        assert!(!config.is_rule_enabled("CC-AG-001"));
1952        assert!(!config.is_rule_enabled("CC-HK-001"));
1953
1954        // AS-* rules should still work
1955        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        // CC-* rules should be enabled
1964        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        // COP-* and CUR-* rules should be disabled
1969        assert!(!config.is_rule_enabled("COP-001"));
1970        assert!(!config.is_rule_enabled("CUR-001"));
1971
1972        // Generic rules should still be enabled
1973        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        // CUR-* rules should be enabled
1984        assert!(config.is_rule_enabled("CUR-001"));
1985        assert!(config.is_rule_enabled("CUR-006"));
1986
1987        // CC-* and COP-* rules should be disabled
1988        assert!(!config.is_rule_enabled("CC-AG-001"));
1989        assert!(!config.is_rule_enabled("COP-001"));
1990
1991        // Generic rules should still be enabled
1992        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        // COP-* rules should be enabled
2002        assert!(config.is_rule_enabled("COP-001"));
2003        assert!(config.is_rule_enabled("COP-002"));
2004
2005        // CC-* and CUR-* rules should be disabled
2006        assert!(!config.is_rule_enabled("CC-AG-001"));
2007        assert!(!config.is_rule_enabled("CUR-001"));
2008
2009        // Generic rules should still be enabled
2010        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        // CC-* and CUR-* rules should both be enabled
2020        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        // COP-* rules should be disabled (not in tools)
2026        assert!(!config.is_rule_enabled("COP-001"));
2027
2028        // Generic rules should still be enabled
2029        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        // Should work case-insensitively
2039        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; // Legacy: would disable CC-*
2047        config.tools = vec!["claude-code".to_string()]; // New: should enable CC-*
2048
2049        // tools array should override target
2050        assert!(config.is_rule_enabled("CC-AG-001"));
2051        assert!(!config.is_rule_enabled("CUR-001")); // Cursor not in tools
2052    }
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        // Verify rule enablement
2072        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        // Old configs without tools field should still work
2080        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        // Falls back to target behavior
2092        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        // CC-AG-001 is explicitly disabled even though claude-code is in tools
2102        assert!(!config.is_rule_enabled("CC-AG-001"));
2103        // Other CC-* rules should still work
2104        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        // CC-HK-* rules should be disabled because hooks category is disabled
2115        assert!(!config.is_rule_enabled("CC-HK-001"));
2116        // Other CC-* rules should still work
2117        assert!(config.is_rule_enabled("CC-AG-001"));
2118    }
2119
2120    // ===== is_tool_alias Edge Case Tests =====
2121
2122    #[test]
2123    fn test_is_tool_alias_unknown_alias_returns_false() {
2124        // Unknown aliases should return false
2125        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        // Canonical name "github-copilot" is NOT treated as an alias of itself.
2133        // This is by design - canonical names match via direct comparison in
2134        // is_rule_for_tools(), not through the alias mechanism.
2135        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        // "copilot" is an alias for "github-copilot" (backward compatibility)
2148        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        // Other tools have no aliases defined
2156        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    // ===== Partial Config Tests =====
2162
2163    #[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        // Should use defaults for unspecified fields
2172        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        // disabled_rules should be set
2178        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        // Other rules should still be enabled
2222        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        // Other categories should still be enabled (default true)
2236        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")); // Claude Code rule
2248        assert!(config.is_rule_enabled("CUR-001")); // Cursor rule
2249    }
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        // exclude should use default
2268        assert!(config.exclude.contains(&"node_modules/**".to_string()));
2269    }
2270
2271    // ===== Disabled Rules Edge Cases =====
2272
2273    #[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        // Rule IDs are case-sensitive
2295        assert!(config.is_rule_enabled("AS-001")); // Not disabled (different case)
2296        assert!(!config.is_rule_enabled("as-001")); // Disabled
2297    }
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        // AS-005 should still be enabled
2312        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        // Should parse without error, nonexistent rules just have no effect
2339        assert!(!config.is_rule_enabled("FAKE-001"));
2340        assert!(!config.is_rule_enabled("NONEXISTENT-999"));
2341        // Real rules still work
2342        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        // Both category disabled AND individual rule disabled
2355        assert!(!config.is_rule_enabled("AS-001"));
2356        assert!(!config.is_rule_enabled("AS-002")); // Category disabled
2357    }
2358
2359    // ===== Config File Loading Edge Cases =====
2360
2361    #[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        // Empty file should use all defaults
2370        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        // Comments-only file should use all defaults
2392        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        // Should fall back to defaults with warning
2430        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        // Should fall back to defaults with warning
2443        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        // Should fall back to defaults with warning (wrong type)
2463        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        // Should fall back to defaults with warning (wrong type)
2476        assert!(warning.is_some());
2477        // Config should have default exclude values
2478        assert!(config.exclude.contains(&"node_modules/**".to_string()));
2479    }
2480
2481    // ===== Config Interaction Tests =====
2482
2483    #[test]
2484    fn test_target_and_tools_interaction() {
2485        // When both target and tools are set, tools takes precedence
2486        let toml_str = r#"
2487target = "Cursor"
2488tools = ["claude-code"]
2489"#;
2490        let config: LintConfig = toml::from_str(toml_str).unwrap();
2491
2492        // Claude Code rules should be enabled (from tools)
2493        assert!(config.is_rule_enabled("CC-SK-001"));
2494        // Cursor rules should be disabled (not in tools)
2495        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        // Even with ClaudeCode target, skills category is disabled
2509        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        // Category is enabled but specific rule is disabled
2523        assert!(!config.is_rule_enabled("AS-001"));
2524        assert!(config.is_rule_enabled("AS-002"));
2525    }
2526
2527    // ===== Serialization Round-Trip Tests =====
2528
2529    #[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        // Should be valid TOML
2552        let _: LintConfig = toml::from_str(&serialized).unwrap();
2553    }
2554
2555    // ===== Real-World Config Scenarios =====
2556
2557    #[test]
2558    fn test_minimal_disable_warnings_config() {
2559        // Common use case: user just wants to disable some noisy warnings
2560        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        // Everything else should work normally
2574        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        // Project that targets both Claude Code and Cursor
2581        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        // Strict config for CI pipeline
2598        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    // ===== FileSystem Abstraction Tests =====
2620
2621    #[test]
2622    fn test_default_config_uses_real_filesystem() {
2623        let config = LintConfig::default();
2624
2625        // Default fs() should be RealFileSystem
2626        let fs = config.fs();
2627
2628        // Verify it works by checking a file that should exist
2629        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        // Create a mock filesystem with a test file
2640        let mock_fs = Arc::new(MockFileSystem::new());
2641        mock_fs.add_file("/mock/test.md", "mock content");
2642
2643        // Replace the filesystem (coerce to trait object)
2644        let fs_arc: Arc<dyn FileSystem> = Arc::clone(&mock_fs) as Arc<dyn FileSystem>;
2645        config.set_fs(fs_arc);
2646
2647        // Verify fs() returns the mock
2648        let fs = config.fs();
2649        assert!(fs.exists(Path::new("/mock/test.md")));
2650        assert!(!fs.exists(Path::new("Cargo.toml"))); // Real file shouldn't exist in mock
2651
2652        // Verify we can read from the mock
2653        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        // Serialize and deserialize
2665        let serialized = toml::to_string(&config).unwrap();
2666        let deserialized: LintConfig = toml::from_str(&serialized).unwrap();
2667
2668        // Deserialized config should have RealFileSystem (default)
2669        // because fs is marked with #[serde(skip)]
2670        let fs = deserialized.fs();
2671        // RealFileSystem can see Cargo.toml, MockFileSystem cannot
2672        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        // Coerce to trait object and set
2685        let fs_arc: Arc<dyn FileSystem> = mock_fs;
2686        config.set_fs(fs_arc);
2687
2688        // Get fs reference
2689        let fs = Arc::clone(config.fs());
2690
2691        // Spawn a thread that uses the filesystem
2692        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        // fs() returns &Arc<dyn FileSystem>
2706        let fs1 = config.fs();
2707        let fs2 = config.fs();
2708
2709        // Both should point to the same Arc
2710        assert!(Arc::ptr_eq(fs1, fs2));
2711    }
2712
2713    // ===== RuntimeContext Tests =====
2714    //
2715    // These tests verify the internal RuntimeContext type works correctly.
2716    // RuntimeContext is private, but we test it through LintConfig's public API.
2717
2718    #[test]
2719    fn test_runtime_context_default_values() {
2720        let config = LintConfig::default();
2721
2722        // Default RuntimeContext should have:
2723        // - root_dir: None
2724        // - import_cache: None
2725        // - fs: RealFileSystem
2726        assert!(config.root_dir().is_none());
2727        assert!(config.import_cache().is_none());
2728        // fs should work with real files
2729        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        // Clone the config
2753        let cloned = config.clone();
2754
2755        // Both should share the same filesystem Arc
2756        assert!(Arc::ptr_eq(config.fs(), cloned.fs()));
2757
2758        // Both can access the same file
2759        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        // Serialize
2769        let serialized = toml::to_string(&config).unwrap();
2770
2771        // The serialized TOML should NOT contain root_dir
2772        assert!(!serialized.contains("root_dir"));
2773        assert!(!serialized.contains("/test/root"));
2774
2775        // Deserialize
2776        let deserialized: LintConfig = toml::from_str(&serialized).unwrap();
2777
2778        // Deserialized config should have default RuntimeContext (root_dir = None)
2779        assert!(deserialized.root_dir().is_none());
2780    }
2781
2782    // ===== DefaultRuleFilter Tests =====
2783    //
2784    // These tests verify the internal DefaultRuleFilter logic through
2785    // LintConfig's public is_rule_enabled() method.
2786
2787    #[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        // Rule should be disabled regardless of category or target
2793        assert!(!config.is_rule_enabled("AS-001"));
2794
2795        // Other AS-* rules should still be enabled
2796        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        // CC-* rules should be disabled for Cursor target
2805        assert!(!config.is_rule_enabled("CC-SK-001"));
2806
2807        // But AS-* rules (generic) should still work
2808        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        // Skills category disabled
2817        assert!(!config.is_rule_enabled("AS-001"));
2818        assert!(!config.is_rule_enabled("CC-SK-001"));
2819
2820        // Other categories still enabled
2821        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        // disabled_rules takes precedence over everything
2833        assert!(!config.is_rule_enabled("CC-SK-001"));
2834
2835        // Other CC-SK-* rules are enabled (category enabled + target matches)
2836        assert!(config.is_rule_enabled("CC-SK-002"));
2837    }
2838
2839    #[test]
2840    fn test_rule_filter_is_tool_alias_works_through_config() {
2841        // Test that is_tool_alias is properly exposed
2842        assert!(LintConfig::is_tool_alias("copilot", "github-copilot"));
2843        assert!(!LintConfig::is_tool_alias("unknown", "github-copilot"));
2844    }
2845
2846    // ===== Serde Round-Trip Tests =====
2847
2848    #[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        // Also set runtime values (should NOT be serialized)
2862        config.set_root_dir(PathBuf::from("/test/root"));
2863
2864        // Serialize
2865        let serialized = toml::to_string(&config).unwrap();
2866
2867        // Deserialize
2868        let deserialized: LintConfig = toml::from_str(&serialized).unwrap();
2869
2870        // All public fields should be preserved
2871        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        // Runtime values should be reset to defaults
2891        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        // Runtime fields should not appear in serialized output
2905        assert!(!serialized.contains("runtime"));
2906        assert!(!serialized.contains("root_dir"));
2907        assert!(!serialized.contains("import_cache"));
2908        assert!(!serialized.contains("fs"));
2909    }
2910
2911    // ===== JSON Schema Generation Tests =====
2912
2913    #[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        // Verify it's valid JSON by parsing it back
2919        let _: serde_json::Value = serde_json::from_str(&json).unwrap();
2920
2921        // Verify basic schema structure
2922        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        // Check main config fields
2933        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        // Check runtime fields are NOT included
2942        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        // Check definitions for nested types
2953        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        // Check that descriptions are present
2966        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    // ===== Config Validation Tests =====
2973
2974    #[test]
2975    fn test_validate_empty_config_no_warnings() {
2976        let config = LintConfig::default();
2977        let warnings = config.validate();
2978
2979        // Default config should have no warnings
2980        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        // All these are valid rule IDs
3003        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        // Regression test for #233
3022        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        // All these are valid tool names
3045        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(),    // Valid
3085            "INVALID-1".to_string(), // Invalid
3086            "CC-SK-001".to_string(), // Valid
3087        ];
3088        config.tools = vec![
3089            "claude-code".to_string(), // Valid
3090            "bad-tool".to_string(),    // Invalid
3091        ];
3092
3093        let warnings = config.validate();
3094
3095        // Should have exactly 2 warnings: one for invalid rule, one for invalid tool
3096        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        // Tools should be validated case-insensitively
3113        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        // All should be valid (case-insensitive)
3123        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        // Test that multiple invalid items of the same type are all reported
3133        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        // Should have 3 warnings, one for each invalid rule
3143        assert_eq!(warnings.len(), 3, "Expected 3 warnings for 3 invalid rules");
3144
3145        // Verify each invalid rule is mentioned
3146        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(), // This one is valid
3159        ];
3160
3161        let warnings = config.validate();
3162
3163        // Should have 2 warnings for the 2 invalid tools
3164        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        // Empty strings should be flagged as invalid
3170        let mut config = LintConfig::default();
3171        config.tools = vec!["".to_string(), "claude-code".to_string()];
3172
3173        let warnings = config.validate();
3174
3175        // Empty string is not a valid tool
3176        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        // tools is empty, so target deprecation warning should fire
3185
3186        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        // When both target and tools are set, don't warn about target
3197        // because tools takes precedence
3198        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        // No warning because tools is set
3205        assert!(warnings.is_empty());
3206    }
3207}