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/// Maximum number of file patterns per list (include_as_memory, include_as_generic, exclude).
13/// Exceeding this limit produces a configuration warning.
14const MAX_FILE_PATTERNS: usize = 100;
15
16mod builder;
17mod rule_filter;
18mod schema;
19
20pub use builder::LintConfigBuilder;
21pub use schema::{ConfigWarning, generate_schema};
22/// Tool version pinning for version-aware validation
23///
24/// When tool versions are pinned, validators can apply version-specific
25/// behavior instead of using default assumptions. When not pinned,
26/// validators will use sensible defaults and add assumption notes.
27#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
28pub struct ToolVersions {
29    /// Claude Code version (e.g., "1.0.0")
30    #[serde(default)]
31    #[schemars(description = "Claude Code version for version-aware validation (e.g., \"1.0.0\")")]
32    pub claude_code: Option<String>,
33
34    /// Codex CLI version (e.g., "0.1.0")
35    #[serde(default)]
36    #[schemars(description = "Codex CLI version for version-aware validation (e.g., \"0.1.0\")")]
37    pub codex: Option<String>,
38
39    /// Cursor version (e.g., "0.45.0")
40    #[serde(default)]
41    #[schemars(description = "Cursor version for version-aware validation (e.g., \"0.45.0\")")]
42    pub cursor: Option<String>,
43
44    /// GitHub Copilot version (e.g., "1.0.0")
45    #[serde(default)]
46    #[schemars(
47        description = "GitHub Copilot version for version-aware validation (e.g., \"1.0.0\")"
48    )]
49    pub copilot: Option<String>,
50}
51
52/// Specification revision pinning for version-aware validation
53///
54/// When spec revisions are pinned, validators can apply revision-specific
55/// rules. When not pinned, validators use the latest known revision.
56#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
57pub struct SpecRevisions {
58    /// MCP protocol version (e.g., "2025-11-25", "2024-11-05")
59    #[serde(default)]
60    #[schemars(
61        description = "MCP protocol version for revision-specific validation (e.g., \"2025-11-25\", \"2024-11-05\")"
62    )]
63    pub mcp_protocol: Option<String>,
64
65    /// Agent Skills specification revision
66    #[serde(default)]
67    #[schemars(description = "Agent Skills specification revision")]
68    pub agent_skills_spec: Option<String>,
69
70    /// AGENTS.md specification revision
71    #[serde(default)]
72    #[schemars(description = "AGENTS.md specification revision")]
73    pub agents_md_spec: Option<String>,
74}
75
76/// File inclusion/exclusion configuration for non-standard agent files.
77///
78/// By default, agnix only validates files it recognizes (CLAUDE.md, SKILL.md, etc.).
79/// Use this section to include additional files in validation or exclude files
80/// that would otherwise be validated.
81///
82/// Patterns use glob syntax (e.g., `"docs/ai-rules/*.md"`).
83/// Paths are matched relative to the project root.
84///
85/// Priority: `exclude` > `include_as_memory` > `include_as_generic` > built-in detection.
86#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
87pub struct FilesConfig {
88    /// Glob patterns for files to validate as memory/instruction files (ClaudeMd rules).
89    ///
90    /// Files matching these patterns will be treated as CLAUDE.md-like files,
91    /// receiving the full set of memory/instruction validation rules.
92    #[serde(default)]
93    #[schemars(
94        description = "Glob patterns for files to validate as memory/instruction files (ClaudeMd rules)"
95    )]
96    pub include_as_memory: Vec<String>,
97
98    /// Glob patterns for files to validate as generic markdown (XML, XP, REF rules).
99    ///
100    /// Files matching these patterns will receive generic markdown validation
101    /// (XML balance, import references, cross-platform checks).
102    #[serde(default)]
103    #[schemars(
104        description = "Glob patterns for files to validate as generic markdown (XML, XP, REF rules)"
105    )]
106    pub include_as_generic: Vec<String>,
107
108    /// Glob patterns for files to exclude from validation.
109    ///
110    /// Files matching these patterns will be skipped entirely, even if they
111    /// would otherwise be recognized by built-in detection.
112    #[serde(default)]
113    #[schemars(description = "Glob patterns for files to exclude from validation")]
114    pub exclude: Vec<String>,
115}
116
117// =============================================================================
118// Internal Composition Types (Facade Pattern)
119// =============================================================================
120//
121// LintConfig uses internal composition to separate concerns while maintaining
122// a stable public API. These types are private implementation details:
123//
124// - RuntimeContext: Groups non-serialized runtime state (root_dir, import_cache, fs)
125// - DefaultRuleFilter: Encapsulates rule filtering logic (~100 lines)
126//
127// This pattern provides:
128// 1. Better code organization without breaking changes
129// 2. Easier testing of individual components
130// 3. Clear separation between serialized config and runtime state
131// =============================================================================
132
133/// Errors that can occur when building or validating a `LintConfig`.
134///
135/// These are hard errors (not warnings) that indicate the configuration
136/// cannot be used as-is. For soft issues, see [`ConfigWarning`].
137///
138/// This enum is `#[non_exhaustive]`: match with a wildcard arm to handle
139/// future variants without breaking changes.
140#[derive(Debug, Clone)]
141#[non_exhaustive]
142pub enum ConfigError {
143    /// A glob pattern in the configuration is syntactically invalid.
144    InvalidGlobPattern {
145        /// The invalid glob pattern string.
146        pattern: String,
147        /// Description of the parse error.
148        error: String,
149    },
150    /// A glob pattern attempts path traversal (e.g. `../escape`).
151    PathTraversal {
152        /// The pattern containing path traversal.
153        pattern: String,
154    },
155    /// A glob pattern uses an absolute path (e.g. `/etc/passwd` or `C:/Windows/**`).
156    AbsolutePathPattern {
157        /// The absolute-path pattern.
158        pattern: String,
159    },
160    /// Validation produced warnings that were promoted to errors.
161    ValidationFailed(Vec<ConfigWarning>),
162}
163
164impl std::fmt::Display for ConfigError {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            ConfigError::InvalidGlobPattern { pattern, error } => {
168                write!(f, "invalid glob pattern '{}': {}", pattern, error)
169            }
170            ConfigError::PathTraversal { pattern } => {
171                write!(f, "path traversal in pattern '{}'", pattern)
172            }
173            ConfigError::AbsolutePathPattern { pattern } => {
174                write!(
175                    f,
176                    "absolute path in pattern '{}': use relative paths only",
177                    pattern
178                )
179            }
180            ConfigError::ValidationFailed(warnings) => {
181                if warnings.is_empty() {
182                    write!(f, "configuration validation failed with 0 warning(s)")
183                } else {
184                    write!(
185                        f,
186                        "configuration validation failed with {} warning(s): {}",
187                        warnings.len(),
188                        warnings[0].message
189                    )
190                }
191            }
192        }
193    }
194}
195
196impl std::error::Error for ConfigError {}
197
198/// Runtime context for validation operations (not serialized).
199///
200/// Groups non-serialized state that is set up at runtime and shared during
201/// validation. This includes the project root, import cache, and filesystem
202/// abstraction.
203///
204/// # Thread Safety
205///
206/// `RuntimeContext` is `Send + Sync` because:
207/// - `PathBuf` and `Option<T>` are `Send + Sync`
208/// - `ImportCache` uses interior mutability with thread-safe types
209/// - `Arc<dyn FileSystem>` shares the filesystem without deep-cloning
210///
211/// # Clone Behavior
212///
213/// When cloned, the `Arc<dyn FileSystem>` is shared (not deep-cloned),
214/// maintaining the same filesystem instance across clones.
215#[derive(Clone)]
216struct RuntimeContext {
217    /// Project root directory for validation.
218    ///
219    /// When set, validators can use this to resolve relative paths and
220    /// detect project-escape attempts in import validation.
221    root_dir: Option<PathBuf>,
222
223    /// Shared import cache for project-level validation.
224    ///
225    /// When set, validators can use this cache to share parsed import data
226    /// across files, avoiding redundant parsing during import chain traversal.
227    import_cache: Option<crate::parsers::ImportCache>,
228
229    /// File system abstraction for testability.
230    ///
231    /// Validators use this to perform file system operations. Defaults to
232    /// `RealFileSystem` which delegates to `std::fs` and `file_utils`.
233    fs: Arc<dyn FileSystem>,
234}
235
236impl Default for RuntimeContext {
237    fn default() -> Self {
238        Self {
239            root_dir: None,
240            import_cache: None,
241            fs: Arc::new(RealFileSystem),
242        }
243    }
244}
245
246impl std::fmt::Debug for RuntimeContext {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        f.debug_struct("RuntimeContext")
249            .field("root_dir", &self.root_dir)
250            .field(
251                "import_cache",
252                &self.import_cache.as_ref().map(|_| "ImportCache(...)"),
253            )
254            .field("fs", &"Arc<dyn FileSystem>")
255            .finish()
256    }
257}
258
259/// Shared, immutable configuration data wrapped in `Arc` for cheap cloning.
260///
261/// All serializable fields of `LintConfig` live here. When `LintConfig` is
262/// cloned (e.g., in `validate_project` / `validate_project_with_registry`),
263/// only the `Arc` refcount is bumped - no heap-allocated `Vec<String>` or
264/// nested structs are deep-copied. Mutation through setters uses
265/// `Arc::make_mut` for copy-on-write semantics.
266#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
267#[serde(default)]
268pub(in crate::config) struct ConfigData {
269    /// Severity level threshold
270    #[schemars(description = "Minimum severity level to report (Error, Warning, Info)")]
271    severity: SeverityLevel,
272
273    /// Rules to enable/disable
274    #[schemars(description = "Configuration for enabling/disabling validation rules by category")]
275    rules: RuleConfig,
276
277    /// Paths to exclude
278    #[schemars(
279        description = "Glob patterns for paths to exclude from validation (e.g., [\"node_modules/**\", \"dist/**\"])"
280    )]
281    exclude: Vec<String>,
282
283    /// Target tool for validation.
284    /// In configuration files, use PascalCase enum names
285    /// (`ClaudeCode`, `Cursor`, `Codex`, `Kiro`, `Generic`).
286    /// Deprecated: Use `tools` array instead for multi-tool support.
287    #[schemars(
288        description = "Target tool for validation. In config files, use PascalCase enum names (e.g., ClaudeCode, Cursor, Codex, Kiro, Generic). Deprecated: use 'tools' array instead."
289    )]
290    target: TargetTool,
291
292    /// Tools to validate for (e.g., ["claude-code", "cursor"])
293    /// When specified, agnix automatically enables rules for these tools
294    /// and disables rules for tools not in the list.
295    /// Valid values: "claude-code", "cursor", "codex", "kiro", "copilot",
296    /// "github-copilot", "cline", "opencode", "gemini-cli", "amp",
297    /// "roo-code", "windsurf", "generic"
298    #[serde(default)]
299    #[schemars(
300        description = "Tools to validate for. Valid values: \"claude-code\", \"cursor\", \"codex\", \"kiro\", \"copilot\", \"github-copilot\", \"cline\", \"opencode\", \"gemini-cli\", \"amp\", \"roo-code\", \"windsurf\", \"generic\""
301    )]
302    tools: Vec<String>,
303
304    /// Expected MCP protocol version for validation (MCP-008)
305    /// Deprecated: Use spec_revisions.mcp_protocol instead
306    #[schemars(
307        description = "Expected MCP protocol version (deprecated: use spec_revisions.mcp_protocol instead)"
308    )]
309    mcp_protocol_version: Option<String>,
310
311    /// Tool version pinning for version-aware validation
312    #[serde(default)]
313    #[schemars(description = "Pin specific tool versions for version-aware validation")]
314    tool_versions: ToolVersions,
315
316    /// Specification revision pinning for version-aware validation
317    #[serde(default)]
318    #[schemars(description = "Pin specific specification revisions for revision-aware validation")]
319    spec_revisions: SpecRevisions,
320
321    /// File inclusion/exclusion configuration for non-standard agent files
322    #[serde(default)]
323    #[schemars(
324        description = "File inclusion/exclusion configuration for non-standard agent files"
325    )]
326    files: FilesConfig,
327
328    /// Output locale for translated messages (e.g., "en", "es", "zh-CN").
329    /// When not set, the CLI locale detection is used.
330    #[serde(default)]
331    #[schemars(
332        description = "Output locale for translated messages (e.g., \"en\", \"es\", \"zh-CN\")"
333    )]
334    locale: Option<String>,
335
336    /// Maximum number of files to validate before stopping.
337    ///
338    /// This is a security feature to prevent DoS attacks via projects with
339    /// millions of small files. When the limit is reached, validation stops
340    /// with a `TooManyFiles` error.
341    ///
342    /// Default: 10,000 files. Set to `None` to disable the limit (not recommended).
343    #[serde(default = "default_max_files")]
344    max_files_to_validate: Option<usize>,
345}
346
347impl Default for ConfigData {
348    fn default() -> Self {
349        Self {
350            severity: SeverityLevel::Warning,
351            rules: RuleConfig::default(),
352            exclude: vec![
353                "node_modules/**".to_string(),
354                ".git/**".to_string(),
355                "target/**".to_string(),
356            ],
357            target: TargetTool::Generic,
358            tools: Vec::new(),
359            mcp_protocol_version: None,
360            tool_versions: ToolVersions::default(),
361            spec_revisions: SpecRevisions::default(),
362            files: FilesConfig::default(),
363            locale: None,
364            max_files_to_validate: Some(DEFAULT_MAX_FILES),
365        }
366    }
367}
368
369/// Configuration for the linter
370///
371/// # Cheap Cloning via `Arc<ConfigData>`
372///
373/// All serializable fields are stored in a shared `Arc<ConfigData>`.
374/// Cloning a `LintConfig` bumps the `Arc` refcount and shallow-copies the
375/// lightweight `RuntimeContext` - no heap-allocated `Vec<String>` or nested
376/// structs are deep-copied. Setters use `Arc::make_mut` for copy-on-write
377/// semantics, so mutations only allocate when the `Arc` is shared.
378#[derive(Clone)]
379pub struct LintConfig {
380    /// Shared serializable configuration data.
381    ///
382    /// Accessible within the config module for direct field access in
383    /// submodules (rule_filter, schema, builder, tests).
384    pub(in crate::config) data: Arc<ConfigData>,
385
386    /// Internal runtime context for validation operations (not serialized).
387    ///
388    /// Groups the filesystem abstraction, project root directory, and import
389    /// cache. These are non-serialized runtime state set up before validation.
390    runtime: RuntimeContext,
391}
392
393// ---------------------------------------------------------------------------
394// Serde, Debug, and JsonSchema implementations for LintConfig
395// ---------------------------------------------------------------------------
396//
397// Because LintConfig wraps its serializable fields in Arc<ConfigData>, we
398// implement Serialize/Deserialize/Debug/JsonSchema manually so that the
399// external representation is flat (identical to the old struct layout).
400// ---------------------------------------------------------------------------
401
402impl std::fmt::Debug for LintConfig {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        f.debug_struct("LintConfig")
405            .field("severity", &self.data.severity)
406            .field("rules", &self.data.rules)
407            .field("exclude", &self.data.exclude)
408            .field("target", &self.data.target)
409            .field("tools", &self.data.tools)
410            .field("mcp_protocol_version", &self.data.mcp_protocol_version)
411            .field("tool_versions", &self.data.tool_versions)
412            .field("spec_revisions", &self.data.spec_revisions)
413            .field("files", &self.data.files)
414            .field("locale", &self.data.locale)
415            .field("max_files_to_validate", &self.data.max_files_to_validate)
416            .field("runtime", &self.runtime)
417            .finish()
418    }
419}
420
421impl Serialize for LintConfig {
422    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
423        // Delegate to ConfigData - produces the same flat fields as before.
424        self.data.serialize(serializer)
425    }
426}
427
428impl<'de> Deserialize<'de> for LintConfig {
429    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
430        let data = ConfigData::deserialize(deserializer)?;
431        Ok(Self {
432            data: Arc::new(data),
433            runtime: RuntimeContext::default(),
434        })
435    }
436}
437
438impl JsonSchema for LintConfig {
439    fn schema_name() -> std::borrow::Cow<'static, str> {
440        std::borrow::Cow::Borrowed("LintConfig")
441    }
442
443    fn schema_id() -> std::borrow::Cow<'static, str> {
444        // Match ConfigData's schema_id so the generator treats them as the same
445        // schema and avoids registering two distinct definitions.
446        ConfigData::schema_id()
447    }
448
449    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
450        // Delegate to ConfigData so the schema is identical to the old flat layout.
451        ConfigData::json_schema(generator)
452    }
453}
454
455/// Default maximum files to validate (security limit)
456///
457/// **Design Decision**: 10,000 files was chosen as a balance between:
458/// - Large enough for realistic projects (Linux kernel has ~70k files, but most are not validated)
459/// - Small enough to prevent DoS from projects with millions of tiny files
460/// - Completes validation in reasonable time (seconds to low minutes on typical hardware)
461/// - Atomic counter with SeqCst ordering provides thread-safe counting during parallel validation
462///
463/// Users can override with `--max-files N` or disable with `--max-files 0` (not recommended).
464/// Set to `None` to disable the limit entirely (use with caution).
465pub const DEFAULT_MAX_FILES: usize = 10_000;
466
467/// Helper function for serde default
468fn default_max_files() -> Option<usize> {
469    Some(DEFAULT_MAX_FILES)
470}
471
472/// Check if a normalized (forward-slash) path pattern contains path traversal.
473///
474/// Catches `../`, `..` at the start, `/..` at the end, and standalone `..`.
475fn has_path_traversal(normalized: &str) -> bool {
476    normalized == ".."
477        || normalized.starts_with("../")
478        || normalized.contains("/../")
479        || normalized.ends_with("/..")
480}
481
482impl Default for LintConfig {
483    fn default() -> Self {
484        Self {
485            data: Arc::new(ConfigData::default()),
486            runtime: RuntimeContext::default(),
487        }
488    }
489}
490
491#[derive(
492    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
493)]
494#[schemars(description = "Severity level for filtering diagnostics")]
495pub enum SeverityLevel {
496    /// Only show errors
497    Error,
498    /// Show errors and warnings
499    Warning,
500    /// Show all diagnostics including info
501    Info,
502}
503
504/// Helper function for serde default
505fn default_true() -> bool {
506    true
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
510#[schemars(description = "Configuration for enabling/disabling validation rules by category")]
511pub struct RuleConfig {
512    /// Enable skills validation (AS-*, CC-SK-*)
513    #[serde(default = "default_true")]
514    #[schemars(description = "Enable Agent Skills validation rules (AS-*, CC-SK-*)")]
515    pub skills: bool,
516
517    /// Enable hooks validation (CC-HK-*)
518    #[serde(default = "default_true")]
519    #[schemars(description = "Enable Claude Code hooks validation rules (CC-HK-*)")]
520    pub hooks: bool,
521
522    /// Enable agents validation (CC-AG-*)
523    #[serde(default = "default_true")]
524    #[schemars(description = "Enable Claude Code agents validation rules (CC-AG-*)")]
525    pub agents: bool,
526
527    /// Enable memory validation (CC-MEM-*)
528    #[serde(default = "default_true")]
529    #[schemars(description = "Enable Claude Code memory validation rules (CC-MEM-*)")]
530    pub memory: bool,
531
532    /// Enable plugins validation (CC-PL-*)
533    #[serde(default = "default_true")]
534    #[schemars(description = "Enable Claude Code plugins validation rules (CC-PL-*)")]
535    pub plugins: bool,
536
537    /// Enable XML balance checking (XML-*)
538    #[serde(default = "default_true")]
539    #[schemars(description = "Enable XML tag balance validation rules (XML-*)")]
540    pub xml: bool,
541
542    /// Enable MCP validation (MCP-*)
543    #[serde(default = "default_true")]
544    #[schemars(description = "Enable Model Context Protocol validation rules (MCP-*)")]
545    pub mcp: bool,
546
547    /// Enable import reference validation (REF-*)
548    #[serde(default = "default_true")]
549    #[schemars(description = "Enable import reference validation rules (REF-*)")]
550    pub imports: bool,
551
552    /// Enable cross-platform validation (XP-*)
553    #[serde(default = "default_true")]
554    #[schemars(description = "Enable cross-platform validation rules (XP-*)")]
555    pub cross_platform: bool,
556
557    /// Enable AGENTS.md validation (AGM-*)
558    #[serde(default = "default_true")]
559    #[schemars(description = "Enable AGENTS.md validation rules (AGM-*)")]
560    pub agents_md: bool,
561
562    /// Enable GitHub Copilot validation (COP-*)
563    #[serde(default = "default_true")]
564    #[schemars(description = "Enable GitHub Copilot validation rules (COP-*)")]
565    pub copilot: bool,
566
567    /// Enable Cursor project rules validation (CUR-*)
568    #[serde(default = "default_true")]
569    #[schemars(description = "Enable Cursor project rules validation (CUR-*)")]
570    pub cursor: bool,
571
572    /// Enable Cline rules validation (CLN-*)
573    #[serde(default = "default_true")]
574    #[schemars(description = "Enable Cline rules validation (CLN-*)")]
575    pub cline: bool,
576
577    /// Enable OpenCode validation (OC-*)
578    #[serde(default = "default_true")]
579    #[schemars(description = "Enable OpenCode validation rules (OC-*)")]
580    pub opencode: bool,
581
582    /// Enable Gemini CLI validation (GM-*)
583    #[serde(default = "default_true")]
584    #[schemars(description = "Enable Gemini CLI validation rules (GM-*)")]
585    pub gemini_md: bool,
586
587    /// Enable Codex CLI validation (CDX-*)
588    #[serde(default = "default_true")]
589    #[schemars(description = "Enable Codex CLI validation rules (CDX-*)")]
590    pub codex: bool,
591
592    /// Enable Roo Code validation (ROO-*)
593    #[serde(default = "default_true")]
594    #[schemars(description = "Enable Roo Code validation rules (ROO-*)")]
595    pub roo_code: bool,
596
597    /// Enable Windsurf validation (WS-*)
598    #[serde(default = "default_true")]
599    #[schemars(description = "Enable Windsurf validation rules (WS-*)")]
600    pub windsurf: bool,
601
602    /// Enable Kiro steering validation (KIRO-*)
603    #[serde(default = "default_true")]
604    #[schemars(description = "Enable Kiro steering validation rules (KIRO-*)")]
605    pub kiro_steering: bool,
606
607    /// Enable Kiro agent validation (KR-AG-*)
608    #[serde(default = "default_true")]
609    #[schemars(description = "Enable Kiro agent validation rules (KR-AG-*)")]
610    pub kiro_agents: bool,
611
612    /// Enable Amp checks validation (AMP-*)
613    #[serde(default = "default_true")]
614    #[schemars(description = "Enable Amp checks validation rules (AMP-*)")]
615    pub amp_checks: bool,
616
617    /// Enable prompt engineering validation (PE-*)
618    #[serde(default = "default_true")]
619    #[schemars(description = "Enable prompt engineering validation rules (PE-*)")]
620    pub prompt_engineering: bool,
621
622    /// Detect generic instructions in CLAUDE.md
623    #[serde(default = "default_true")]
624    #[schemars(description = "Detect generic placeholder instructions in CLAUDE.md")]
625    pub generic_instructions: bool,
626
627    /// Validate YAML frontmatter
628    #[serde(default = "default_true")]
629    #[schemars(description = "Validate YAML frontmatter in skill files")]
630    pub frontmatter_validation: bool,
631
632    /// Check XML tag balance (legacy - use xml instead)
633    #[serde(default = "default_true")]
634    #[schemars(description = "Check XML tag balance (legacy: use 'xml' instead)")]
635    pub xml_balance: bool,
636
637    /// Validate @import references (legacy - use imports instead)
638    #[serde(default = "default_true")]
639    #[schemars(description = "Validate @import references (legacy: use 'imports' instead)")]
640    pub import_references: bool,
641
642    /// Explicitly disabled rules by ID (e.g., ["CC-AG-001", "AS-005"])
643    #[serde(default)]
644    #[schemars(
645        description = "List of rule IDs to explicitly disable (e.g., [\"CC-AG-001\", \"AS-005\"])"
646    )]
647    pub disabled_rules: Vec<String>,
648
649    /// Explicitly disabled validators by name (e.g., ["XmlValidator", "PromptValidator"])
650    #[serde(default)]
651    #[schemars(
652        description = "List of validator names to disable (e.g., [\"XmlValidator\", \"PromptValidator\"])"
653    )]
654    pub disabled_validators: Vec<String>,
655}
656
657impl Default for RuleConfig {
658    fn default() -> Self {
659        Self {
660            skills: true,
661            hooks: true,
662            agents: true,
663            memory: true,
664            plugins: true,
665            xml: true,
666            mcp: true,
667            imports: true,
668            cross_platform: true,
669            agents_md: true,
670            copilot: true,
671            cursor: true,
672            cline: true,
673            opencode: true,
674            gemini_md: true,
675            codex: true,
676            roo_code: true,
677            windsurf: true,
678            kiro_steering: true,
679            kiro_agents: true,
680            amp_checks: true,
681            prompt_engineering: true,
682            generic_instructions: true,
683            frontmatter_validation: true,
684            xml_balance: true,
685            import_references: true,
686            disabled_rules: Vec::new(),
687            disabled_validators: Vec::new(),
688        }
689    }
690}
691
692#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
693#[schemars(
694    description = "Target tool for validation (deprecated: use 'tools' array for multi-tool support)"
695)]
696pub enum TargetTool {
697    /// Generic Agent Skills standard
698    Generic,
699    /// Claude Code specific
700    ClaudeCode,
701    /// Cursor specific
702    Cursor,
703    /// Codex specific
704    Codex,
705    /// Kiro specific
706    Kiro,
707}
708
709impl LintConfig {
710    /// Load config from file
711    pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
712        let content = safe_read_file(path.as_ref())?;
713        let config = toml::from_str(&content)?;
714        Ok(config)
715    }
716
717    /// Load config or use default, returning any parse warning
718    ///
719    /// Returns a tuple of (config, optional_warning). If a config path is provided
720    /// but the file cannot be loaded or parsed, returns the default config with a
721    /// warning message describing the error. This prevents silent fallback to
722    /// defaults on config typos or missing/unreadable config files.
723    pub fn load_or_default(path: Option<&PathBuf>) -> (Self, Option<String>) {
724        match path {
725            Some(p) => match Self::load(p) {
726                Ok(config) => (config, None),
727                Err(e) => {
728                    let warning = t!(
729                        "core.config.load_warning",
730                        path = p.display().to_string(),
731                        error = e.to_string()
732                    );
733                    (Self::default(), Some(warning.to_string()))
734                }
735            },
736            None => (Self::default(), None),
737        }
738    }
739
740    // =========================================================================
741    // Runtime Context Accessors
742    // =========================================================================
743    //
744    // These methods delegate to RuntimeContext, maintaining the same public API.
745    // =========================================================================
746
747    /// Get the runtime validation root directory, if set.
748    #[inline]
749    pub fn root_dir(&self) -> Option<&PathBuf> {
750        self.runtime.root_dir.as_ref()
751    }
752
753    /// Alias for `root_dir()` for consistency with other accessors.
754    #[inline]
755    pub fn get_root_dir(&self) -> Option<&PathBuf> {
756        self.root_dir()
757    }
758
759    /// Set the runtime validation root directory (not persisted).
760    pub fn set_root_dir(&mut self, root_dir: PathBuf) {
761        self.runtime.root_dir = Some(root_dir);
762    }
763
764    /// Set the shared import cache for project-level validation (not persisted).
765    ///
766    /// When set, the ImportsValidator will use this cache to share parsed
767    /// import data across files, improving performance by avoiding redundant
768    /// parsing during import chain traversal.
769    pub fn set_import_cache(&mut self, cache: crate::parsers::ImportCache) {
770        self.runtime.import_cache = Some(cache);
771    }
772
773    /// Get the shared import cache, if one has been set.
774    ///
775    /// Returns `None` for single-file validation or when the cache hasn't
776    /// been initialized. Returns `Some(&ImportCache)` during project-level
777    /// validation where import results are shared across files.
778    #[inline]
779    pub fn import_cache(&self) -> Option<&crate::parsers::ImportCache> {
780        self.runtime.import_cache.as_ref()
781    }
782
783    /// Alias for `import_cache()` for consistency with other accessors.
784    #[inline]
785    pub fn get_import_cache(&self) -> Option<&crate::parsers::ImportCache> {
786        self.import_cache()
787    }
788
789    /// Get the file system abstraction.
790    ///
791    /// Validators should use this for file system operations instead of
792    /// directly calling `std::fs` functions. This enables unit testing
793    /// with `MockFileSystem`.
794    pub fn fs(&self) -> &Arc<dyn FileSystem> {
795        &self.runtime.fs
796    }
797
798    /// Set the file system abstraction (not persisted).
799    ///
800    /// This is primarily used for testing with `MockFileSystem`.
801    ///
802    /// # Important
803    ///
804    /// This should only be called during configuration setup, before validation
805    /// begins. Changing the filesystem during validation may cause inconsistent
806    /// results if validators have already cached file state.
807    pub fn set_fs(&mut self, fs: Arc<dyn FileSystem>) {
808        self.runtime.fs = fs;
809    }
810
811    // =========================================================================
812    // Serializable Field Getters
813    // =========================================================================
814
815    /// Get the severity level threshold.
816    #[inline]
817    pub fn severity(&self) -> SeverityLevel {
818        self.data.severity
819    }
820
821    /// Get the rules configuration.
822    #[inline]
823    pub fn rules(&self) -> &RuleConfig {
824        &self.data.rules
825    }
826
827    /// Get the exclude patterns.
828    #[inline]
829    pub fn exclude(&self) -> &[String] {
830        &self.data.exclude
831    }
832
833    /// Get the target tool.
834    #[inline]
835    pub fn target(&self) -> TargetTool {
836        self.data.target
837    }
838
839    /// Get the tools list.
840    #[inline]
841    pub fn tools(&self) -> &[String] {
842        &self.data.tools
843    }
844
845    /// Get the tool versions configuration.
846    #[inline]
847    pub fn tool_versions(&self) -> &ToolVersions {
848        &self.data.tool_versions
849    }
850
851    /// Get the spec revisions configuration.
852    #[inline]
853    pub fn spec_revisions(&self) -> &SpecRevisions {
854        &self.data.spec_revisions
855    }
856
857    /// Get the files configuration.
858    #[inline]
859    pub fn files_config(&self) -> &FilesConfig {
860        &self.data.files
861    }
862
863    /// Get the locale, if set.
864    #[inline]
865    pub fn locale(&self) -> Option<&str> {
866        self.data.locale.as_deref()
867    }
868
869    /// Get the maximum number of files to validate.
870    #[inline]
871    pub fn max_files_to_validate(&self) -> Option<usize> {
872        self.data.max_files_to_validate
873    }
874
875    /// Get the raw `mcp_protocol_version` field value (without fallback logic).
876    ///
877    /// For the resolved version with fallback, use [`get_mcp_protocol_version()`](Self::get_mcp_protocol_version).
878    #[inline]
879    pub fn mcp_protocol_version_raw(&self) -> Option<&str> {
880        self.data.mcp_protocol_version.as_deref()
881    }
882
883    // =========================================================================
884    // Serializable Field Setters
885    // =========================================================================
886    //
887    // All setters use `Arc::make_mut` for copy-on-write semantics. When the
888    // Arc is uniquely owned (refcount == 1), the data is mutated in place
889    // with no allocation. When shared, a clone is made first.
890    // =========================================================================
891
892    /// Set the severity level threshold.
893    pub fn set_severity(&mut self, severity: SeverityLevel) {
894        Arc::make_mut(&mut self.data).severity = severity;
895    }
896
897    /// Set the target tool.
898    pub fn set_target(&mut self, target: TargetTool) {
899        Arc::make_mut(&mut self.data).target = target;
900    }
901
902    /// Set the tools list.
903    pub fn set_tools(&mut self, tools: Vec<String>) {
904        Arc::make_mut(&mut self.data).tools = tools;
905    }
906
907    /// Get a mutable reference to the tools list.
908    pub fn tools_mut(&mut self) -> &mut Vec<String> {
909        &mut Arc::make_mut(&mut self.data).tools
910    }
911
912    /// Set the exclude patterns.
913    ///
914    /// Note: This does not validate the patterns. Call [`validate()`](Self::validate)
915    /// after using this if validation is needed.
916    pub fn set_exclude(&mut self, exclude: Vec<String>) {
917        Arc::make_mut(&mut self.data).exclude = exclude;
918    }
919
920    /// Set the locale.
921    pub fn set_locale(&mut self, locale: Option<String>) {
922        Arc::make_mut(&mut self.data).locale = locale;
923    }
924
925    /// Set the maximum number of files to validate.
926    pub fn set_max_files_to_validate(&mut self, max: Option<usize>) {
927        Arc::make_mut(&mut self.data).max_files_to_validate = max;
928    }
929
930    /// Set the MCP protocol version (deprecated field).
931    pub fn set_mcp_protocol_version(&mut self, version: Option<String>) {
932        Arc::make_mut(&mut self.data).mcp_protocol_version = version;
933    }
934
935    /// Get a mutable reference to the rules configuration.
936    pub fn rules_mut(&mut self) -> &mut RuleConfig {
937        &mut Arc::make_mut(&mut self.data).rules
938    }
939
940    /// Get a mutable reference to the tool versions configuration.
941    pub fn tool_versions_mut(&mut self) -> &mut ToolVersions {
942        &mut Arc::make_mut(&mut self.data).tool_versions
943    }
944
945    /// Get a mutable reference to the spec revisions configuration.
946    pub fn spec_revisions_mut(&mut self) -> &mut SpecRevisions {
947        &mut Arc::make_mut(&mut self.data).spec_revisions
948    }
949
950    /// Get a mutable reference to the files configuration.
951    ///
952    /// Note: Mutations bypass builder validation. Call [`validate()`](Self::validate)
953    /// after modifying if validation is needed.
954    pub fn files_mut(&mut self) -> &mut FilesConfig {
955        &mut Arc::make_mut(&mut self.data).files
956    }
957
958    // =========================================================================
959    // Derived / Computed Accessors
960    // =========================================================================
961
962    /// Get the expected MCP protocol version
963    ///
964    /// Priority: spec_revisions.mcp_protocol > mcp_protocol_version > default
965    #[inline]
966    pub fn get_mcp_protocol_version(&self) -> &str {
967        self.data
968            .spec_revisions
969            .mcp_protocol
970            .as_deref()
971            .or(self.data.mcp_protocol_version.as_deref())
972            .unwrap_or(DEFAULT_MCP_PROTOCOL_VERSION)
973    }
974
975    /// Check if MCP protocol revision is explicitly pinned
976    #[inline]
977    pub fn is_mcp_revision_pinned(&self) -> bool {
978        self.data.spec_revisions.mcp_protocol.is_some() || self.data.mcp_protocol_version.is_some()
979    }
980
981    /// Check if Claude Code version is explicitly pinned
982    #[inline]
983    pub fn is_claude_code_version_pinned(&self) -> bool {
984        self.data.tool_versions.claude_code.is_some()
985    }
986
987    /// Get the pinned Claude Code version, if any
988    #[inline]
989    pub fn get_claude_code_version(&self) -> Option<&str> {
990        self.data.tool_versions.claude_code.as_deref()
991    }
992}
993
994#[cfg(test)]
995mod tests;