Skip to main content

agnix_lsp/
vscode_config.rs

1//! VS Code configuration types for LSP integration.
2//!
3//! These types mirror the VS Code settings schema defined in package.json,
4//! allowing the LSP server to receive and apply configuration updates from
5//! the VS Code extension without requiring a server restart.
6//!
7//! # Design Notes
8//!
9//! - All fields use `Option<T>` to support partial updates (only override
10//!   non-None values)
11//! - Uses `#[serde(rename_all = "snake_case")]` to match Rust convention
12//!   while accepting the snake_case JSON from the extension's buildLspConfig()
13//! - The `merge_into_lint_config` method applies VS Code settings on top of
14//!   existing config (from .agnix.toml), giving VS Code settings priority
15
16use agnix_core::LintConfig;
17use agnix_core::config::{
18    FilesConfig, RuleConfig, SeverityLevel, SpecRevisions, TargetTool, ToolVersions,
19};
20use serde::{Deserialize, Serialize};
21
22/// VS Code configuration received from workspace/didChangeConfiguration.
23///
24/// This structure matches the LspConfig interface in extension.ts.
25/// All fields are optional to support partial configuration updates.
26#[derive(Debug, Default, Clone, Deserialize, Serialize)]
27#[serde(default)]
28pub struct VsCodeConfig {
29    /// Minimum severity level for diagnostics
30    #[serde(default)]
31    pub severity: Option<String>,
32
33    /// Target tool for validation (deprecated)
34    #[serde(default)]
35    pub target: Option<String>,
36
37    /// Tools to validate for
38    #[serde(default)]
39    pub tools: Option<Vec<String>>,
40
41    /// Rule category toggles
42    #[serde(default)]
43    pub rules: Option<VsCodeRules>,
44
45    /// Tool version pins
46    #[serde(default)]
47    pub versions: Option<VsCodeVersions>,
48
49    /// Spec revision pins
50    #[serde(default)]
51    pub specs: Option<VsCodeSpecs>,
52
53    /// Output locale for translated messages (e.g., "en", "es", "zh-CN")
54    /// Uses Option<Option<String>> to distinguish:
55    /// - None = field not in JSON (preserve existing locale)
56    /// - Some(None) = field in JSON as null (revert to auto-detection)
57    /// - Some(Some(v)) = field in JSON with value (set locale to v)
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub locale: Option<Option<String>>,
60
61    /// File inclusion/exclusion configuration
62    #[serde(default)]
63    pub files: Option<VsCodeFiles>,
64}
65
66/// Rule category toggles from VS Code settings.
67///
68/// Maps to RuleConfig in agnix-core.
69#[derive(Debug, Default, Clone, Deserialize, Serialize)]
70#[serde(default)]
71pub struct VsCodeRules {
72    /// Enable skills validation (AS-*, CC-SK-*)
73    #[serde(default)]
74    pub skills: Option<bool>,
75
76    /// Enable hooks validation (CC-HK-*)
77    #[serde(default)]
78    pub hooks: Option<bool>,
79
80    /// Enable agents validation (CC-AG-*)
81    #[serde(default)]
82    pub agents: Option<bool>,
83
84    /// Enable memory validation (CC-MEM-*)
85    #[serde(default)]
86    pub memory: Option<bool>,
87
88    /// Enable plugins validation (CC-PL-*)
89    #[serde(default)]
90    pub plugins: Option<bool>,
91
92    /// Enable XML balance checking (XML-*)
93    #[serde(default)]
94    pub xml: Option<bool>,
95
96    /// Enable MCP validation (MCP-*)
97    #[serde(default)]
98    pub mcp: Option<bool>,
99
100    /// Enable import reference validation (REF-*)
101    #[serde(default)]
102    pub imports: Option<bool>,
103
104    /// Enable cross-platform validation (XP-*)
105    #[serde(default)]
106    pub cross_platform: Option<bool>,
107
108    /// Enable AGENTS.md validation (AGM-*)
109    #[serde(default)]
110    pub agents_md: Option<bool>,
111
112    /// Enable GitHub Copilot validation (COP-*)
113    #[serde(default)]
114    pub copilot: Option<bool>,
115
116    /// Enable Cursor project rules validation (CUR-*)
117    #[serde(default)]
118    pub cursor: Option<bool>,
119
120    /// Enable prompt engineering validation (PE-*)
121    #[serde(default)]
122    pub prompt_engineering: Option<bool>,
123
124    /// Explicitly disabled rules by ID
125    #[serde(default)]
126    pub disabled_rules: Option<Vec<String>>,
127}
128
129/// Tool version pins from VS Code settings.
130///
131/// Maps to ToolVersions in agnix-core.
132/// Uses Option<Option<String>> to distinguish:
133/// - None = field not in JSON (preserve .agnix.toml value)
134/// - Some(None) = field in JSON as null (clear pin)
135/// - Some(Some(v)) = field in JSON with value (set pin to v)
136#[derive(Debug, Default, Clone, Deserialize, Serialize)]
137#[serde(default)]
138pub struct VsCodeVersions {
139    /// Claude Code version (e.g., "1.0.0")
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub claude_code: Option<Option<String>>,
142
143    /// Codex CLI version (e.g., "0.1.0")
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub codex: Option<Option<String>>,
146
147    /// Cursor version (e.g., "0.45.0")
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub cursor: Option<Option<String>>,
150
151    /// GitHub Copilot version (e.g., "1.0.0")
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub copilot: Option<Option<String>>,
154}
155
156/// Spec revision pins from VS Code settings.
157///
158/// Maps to SpecRevisions in agnix-core.
159/// Uses Option<Option<String>> to distinguish:
160/// - None = field not in JSON (preserve .agnix.toml value)
161/// - Some(None) = field in JSON as null (clear pin)
162/// - Some(Some(v)) = field in JSON with value (set pin to v)
163#[derive(Debug, Default, Clone, Deserialize, Serialize)]
164#[serde(default)]
165pub struct VsCodeSpecs {
166    /// MCP protocol version (e.g., "2025-11-25")
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub mcp_protocol: Option<Option<String>>,
169
170    /// Agent Skills specification revision
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub agent_skills_spec: Option<Option<String>>,
173
174    /// AGENTS.md specification revision
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub agents_md_spec: Option<Option<String>>,
177}
178
179/// File inclusion/exclusion settings from VS Code.
180///
181/// Maps to FilesConfig in agnix-core.
182#[derive(Debug, Default, Clone, Deserialize, Serialize)]
183#[serde(default)]
184pub struct VsCodeFiles {
185    /// Glob patterns for files to validate as memory/instruction files
186    #[serde(default)]
187    pub include_as_memory: Option<Vec<String>>,
188
189    /// Glob patterns for files to validate as generic markdown
190    #[serde(default)]
191    pub include_as_generic: Option<Vec<String>>,
192
193    /// Glob patterns for files to exclude from validation
194    #[serde(default)]
195    pub exclude: Option<Vec<String>>,
196}
197
198impl VsCodeConfig {
199    /// Merge VS Code settings into a LintConfig.
200    ///
201    /// Only non-None values are applied, preserving any existing config
202    /// (e.g., from .agnix.toml). This allows VS Code settings to override
203    /// file-based config while keeping unspecified options unchanged.
204    ///
205    /// # Priority
206    ///
207    /// VS Code settings take priority over .agnix.toml values.
208    pub fn merge_into_lint_config(&self, config: &mut LintConfig) {
209        // Merge severity
210        if let Some(ref severity) = self.severity {
211            if let Some(level) = parse_severity(severity) {
212                config.set_severity(level);
213            }
214        }
215
216        // Merge target
217        if let Some(ref target) = self.target {
218            if let Some(tool) = parse_target(target) {
219                config.set_target(tool);
220            }
221        }
222
223        // Merge tools
224        if let Some(ref tools) = self.tools {
225            config.set_tools(tools.clone());
226        }
227
228        // Merge rules
229        if let Some(ref rules) = self.rules {
230            rules.merge_into_rule_config(config.rules_mut());
231        }
232
233        // Merge tool versions
234        if let Some(ref versions) = self.versions {
235            versions.merge_into_tool_versions(config.tool_versions_mut());
236        }
237
238        // Merge spec revisions
239        if let Some(ref specs) = self.specs {
240            specs.merge_into_spec_revisions(config.spec_revisions_mut());
241        }
242
243        // Merge files config
244        if let Some(ref files) = self.files {
245            files.merge_into_files_config(config.files_mut());
246        }
247
248        // Merge locale
249        // None = not in JSON (preserve existing)
250        // Some(None) = JSON null (clear locale, revert to auto-detection)
251        // Some(Some(v)) = JSON value (set locale)
252        if let Some(ref locale_opt) = self.locale {
253            match locale_opt {
254                Some(locale) => {
255                    config.set_locale(Some(locale.clone()));
256                    crate::locale::init_from_config(locale);
257                }
258                None => {
259                    config.set_locale(None);
260                    crate::locale::init_from_env();
261                }
262            }
263        }
264    }
265}
266
267impl VsCodeFiles {
268    /// Merge VS Code files settings into FilesConfig.
269    fn merge_into_files_config(&self, config: &mut FilesConfig) {
270        if let Some(ref v) = self.include_as_memory {
271            config.include_as_memory = v.clone();
272        }
273        if let Some(ref v) = self.include_as_generic {
274            config.include_as_generic = v.clone();
275        }
276        if let Some(ref v) = self.exclude {
277            config.exclude = v.clone();
278        }
279    }
280}
281
282impl VsCodeRules {
283    /// Merge VS Code rule settings into RuleConfig.
284    fn merge_into_rule_config(&self, config: &mut RuleConfig) {
285        if let Some(v) = self.skills {
286            config.skills = v;
287        }
288        if let Some(v) = self.hooks {
289            config.hooks = v;
290        }
291        if let Some(v) = self.agents {
292            config.agents = v;
293        }
294        if let Some(v) = self.memory {
295            config.memory = v;
296        }
297        if let Some(v) = self.plugins {
298            config.plugins = v;
299        }
300        if let Some(v) = self.xml {
301            config.xml = v;
302        }
303        if let Some(v) = self.mcp {
304            config.mcp = v;
305        }
306        if let Some(v) = self.imports {
307            config.imports = v;
308        }
309        if let Some(v) = self.cross_platform {
310            config.cross_platform = v;
311        }
312        if let Some(v) = self.agents_md {
313            config.agents_md = v;
314        }
315        if let Some(v) = self.copilot {
316            config.copilot = v;
317        }
318        if let Some(v) = self.cursor {
319            config.cursor = v;
320        }
321        if let Some(v) = self.prompt_engineering {
322            config.prompt_engineering = v;
323        }
324        if let Some(ref v) = self.disabled_rules {
325            config.disabled_rules = v.clone();
326        }
327    }
328}
329
330impl VsCodeVersions {
331    /// Merge VS Code version pins into ToolVersions.
332    /// Uses Option<Option<String>> pattern:
333    /// - None = not in JSON (skip, preserve .agnix.toml)
334    /// - Some(None) = in JSON as null (apply None, clear pin)
335    /// - Some(Some(v)) = in JSON with value (apply value)
336    fn merge_into_tool_versions(&self, config: &mut ToolVersions) {
337        if let Some(ref value) = self.claude_code {
338            config.claude_code = value.clone();
339        }
340        if let Some(ref value) = self.codex {
341            config.codex = value.clone();
342        }
343        if let Some(ref value) = self.cursor {
344            config.cursor = value.clone();
345        }
346        if let Some(ref value) = self.copilot {
347            config.copilot = value.clone();
348        }
349    }
350}
351
352impl VsCodeSpecs {
353    /// Merge VS Code spec pins into SpecRevisions.
354    /// Uses Option<Option<String>> pattern:
355    /// - None = not in JSON (skip, preserve .agnix.toml)
356    /// - Some(None) = in JSON as null (apply None, clear pin)
357    /// - Some(Some(v)) = in JSON with value (apply value)
358    fn merge_into_spec_revisions(&self, config: &mut SpecRevisions) {
359        if let Some(ref value) = self.mcp_protocol {
360            config.mcp_protocol = value.clone();
361        }
362        if let Some(ref value) = self.agent_skills_spec {
363            config.agent_skills_spec = value.clone();
364        }
365        if let Some(ref value) = self.agents_md_spec {
366            config.agents_md_spec = value.clone();
367        }
368    }
369}
370
371/// Parse severity level from string.
372fn parse_severity(s: &str) -> Option<SeverityLevel> {
373    match s {
374        "Error" => Some(SeverityLevel::Error),
375        "Warning" => Some(SeverityLevel::Warning),
376        "Info" => Some(SeverityLevel::Info),
377        _ => None,
378    }
379}
380
381/// Parse target tool from string.
382fn parse_target(s: &str) -> Option<TargetTool> {
383    match s {
384        "Generic" => Some(TargetTool::Generic),
385        "ClaudeCode" => Some(TargetTool::ClaudeCode),
386        "Cursor" => Some(TargetTool::Cursor),
387        "Codex" => Some(TargetTool::Codex),
388        "Kiro" => Some(TargetTool::Kiro),
389        _ => None,
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_vscode_config_deserialization_complete() {
399        let json = r#"{
400            "severity": "Error",
401            "target": "ClaudeCode",
402            "tools": ["claude-code", "cursor"],
403            "locale": "es",
404            "rules": {
405                "skills": false,
406                "hooks": true,
407                "agents": false,
408                "memory": true,
409                "plugins": false,
410                "xml": true,
411                "mcp": false,
412                "imports": true,
413                "cross_platform": false,
414                "agents_md": true,
415                "copilot": false,
416                "cursor": true,
417                "prompt_engineering": false,
418                "disabled_rules": ["AS-001", "PE-003"]
419            },
420            "versions": {
421                "claude_code": "1.0.0",
422                "codex": "0.1.0",
423                "cursor": "0.45.0",
424                "copilot": "1.2.0"
425            },
426            "specs": {
427                "mcp_protocol": "2025-11-25",
428                "agent_skills_spec": "1.0",
429                "agents_md_spec": "1.0"
430            }
431        }"#;
432
433        let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
434
435        assert_eq!(config.severity, Some("Error".to_string()));
436        assert_eq!(config.target, Some("ClaudeCode".to_string()));
437        assert_eq!(
438            config.tools,
439            Some(vec!["claude-code".to_string(), "cursor".to_string()])
440        );
441        assert_eq!(config.locale, Some(Some("es".to_string())));
442
443        let rules = config.rules.expect("rules should be present");
444        assert_eq!(rules.skills, Some(false));
445        assert_eq!(rules.hooks, Some(true));
446        assert_eq!(
447            rules.disabled_rules,
448            Some(vec!["AS-001".to_string(), "PE-003".to_string()])
449        );
450
451        let versions = config.versions.expect("versions should be present");
452        assert_eq!(versions.claude_code, Some(Some("1.0.0".to_string())));
453
454        let specs = config.specs.expect("specs should be present");
455        assert_eq!(specs.mcp_protocol, Some(Some("2025-11-25".to_string())));
456    }
457
458    #[test]
459    fn test_vscode_config_deserialization_partial() {
460        // Only severity and one rule specified
461        let json = r#"{
462            "severity": "Warning",
463            "rules": {
464                "skills": false
465            }
466        }"#;
467
468        let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
469
470        assert_eq!(config.severity, Some("Warning".to_string()));
471        assert!(config.target.is_none());
472        assert!(config.tools.is_none());
473
474        let rules = config.rules.expect("rules should be present");
475        assert_eq!(rules.skills, Some(false));
476        assert!(rules.hooks.is_none()); // Not specified
477    }
478
479    #[test]
480    fn test_vscode_config_deserialization_empty() {
481        let json = "{}";
482
483        let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
484
485        assert!(config.severity.is_none());
486        assert!(config.target.is_none());
487        assert!(config.tools.is_none());
488        assert!(config.rules.is_none());
489        assert!(config.versions.is_none());
490        assert!(config.specs.is_none());
491        assert!(config.locale.is_none()); // Option<Option<String>>: outer None = not present
492    }
493
494    #[test]
495    fn test_merge_into_lint_config_preserves_unspecified() {
496        let mut lint_config = LintConfig::default();
497        lint_config.set_severity(SeverityLevel::Error);
498        lint_config.rules_mut().skills = false;
499
500        // VS Code config only specifies hooks
501        let vscode_config = VsCodeConfig {
502            rules: Some(VsCodeRules {
503                hooks: Some(false),
504                ..Default::default()
505            }),
506            ..Default::default()
507        };
508
509        vscode_config.merge_into_lint_config(&mut lint_config);
510
511        // Original values preserved
512        assert_eq!(lint_config.severity(), SeverityLevel::Error);
513        assert!(!lint_config.rules().skills);
514
515        // New value applied
516        assert!(!lint_config.rules().hooks);
517    }
518
519    #[test]
520    fn test_merge_into_lint_config_overrides() {
521        let mut lint_config = LintConfig::default();
522        lint_config.set_severity(SeverityLevel::Warning);
523        lint_config.set_target(TargetTool::Generic);
524        lint_config.rules_mut().skills = true;
525
526        // VS Code config overrides everything
527        let vscode_config = VsCodeConfig {
528            severity: Some("Error".to_string()),
529            target: Some("ClaudeCode".to_string()),
530            rules: Some(VsCodeRules {
531                skills: Some(false),
532                ..Default::default()
533            }),
534            ..Default::default()
535        };
536
537        vscode_config.merge_into_lint_config(&mut lint_config);
538
539        // All values overridden
540        assert_eq!(lint_config.severity(), SeverityLevel::Error);
541        assert_eq!(lint_config.target(), TargetTool::ClaudeCode);
542        assert!(!lint_config.rules().skills);
543    }
544
545    #[test]
546    fn test_merge_versions() {
547        let mut lint_config = LintConfig::default();
548        lint_config.tool_versions_mut().claude_code = Some("0.9.0".to_string());
549
550        let vscode_config = VsCodeConfig {
551            versions: Some(VsCodeVersions {
552                claude_code: Some(Some("1.0.0".to_string())),
553                codex: Some(Some("0.1.0".to_string())),
554                ..Default::default()
555            }),
556            ..Default::default()
557        };
558
559        vscode_config.merge_into_lint_config(&mut lint_config);
560
561        assert_eq!(
562            lint_config.tool_versions().claude_code,
563            Some("1.0.0".to_string())
564        );
565        assert_eq!(lint_config.tool_versions().codex, Some("0.1.0".to_string()));
566        assert!(lint_config.tool_versions().cursor.is_none()); // Not specified
567    }
568
569    #[test]
570    fn test_merge_specs() {
571        let mut lint_config = LintConfig::default();
572
573        let vscode_config = VsCodeConfig {
574            specs: Some(VsCodeSpecs {
575                mcp_protocol: Some(Some("2025-11-25".to_string())),
576                ..Default::default()
577            }),
578            ..Default::default()
579        };
580
581        vscode_config.merge_into_lint_config(&mut lint_config);
582
583        assert_eq!(
584            lint_config.spec_revisions().mcp_protocol,
585            Some("2025-11-25".to_string())
586        );
587        assert!(lint_config.spec_revisions().agent_skills_spec.is_none());
588    }
589
590    #[test]
591    fn test_parse_severity() {
592        assert_eq!(parse_severity("Error"), Some(SeverityLevel::Error));
593        assert_eq!(parse_severity("Warning"), Some(SeverityLevel::Warning));
594        assert_eq!(parse_severity("Info"), Some(SeverityLevel::Info));
595        assert_eq!(parse_severity("invalid"), None);
596    }
597
598    #[test]
599    fn test_parse_target() {
600        assert_eq!(parse_target("Generic"), Some(TargetTool::Generic));
601        assert_eq!(parse_target("ClaudeCode"), Some(TargetTool::ClaudeCode));
602        assert_eq!(parse_target("Cursor"), Some(TargetTool::Cursor));
603        assert_eq!(parse_target("Codex"), Some(TargetTool::Codex));
604        assert_eq!(parse_target("Kiro"), Some(TargetTool::Kiro));
605        assert_eq!(parse_target("invalid"), None);
606    }
607
608    #[test]
609    fn test_disabled_rules_merge() {
610        let mut lint_config = LintConfig::default();
611        lint_config.rules_mut().disabled_rules = vec!["AS-001".to_string()];
612
613        let vscode_config = VsCodeConfig {
614            rules: Some(VsCodeRules {
615                disabled_rules: Some(vec!["PE-003".to_string(), "MCP-001".to_string()]),
616                ..Default::default()
617            }),
618            ..Default::default()
619        };
620
621        vscode_config.merge_into_lint_config(&mut lint_config);
622
623        // VS Code config replaces (not appends) disabled_rules
624        assert_eq!(
625            lint_config.rules().disabled_rules,
626            vec!["PE-003".to_string(), "MCP-001".to_string()]
627        );
628    }
629
630    #[test]
631    fn test_tools_array_merge() {
632        let mut lint_config = LintConfig::default();
633        lint_config.set_tools(vec!["generic".to_string()]);
634
635        let vscode_config = VsCodeConfig {
636            tools: Some(vec!["claude-code".to_string(), "cursor".to_string()]),
637            ..Default::default()
638        };
639
640        vscode_config.merge_into_lint_config(&mut lint_config);
641
642        assert_eq!(
643            lint_config.tools(),
644            &["claude-code".to_string(), "cursor".to_string()]
645        );
646    }
647
648    #[test]
649    fn test_locale_merge() {
650        let _guard = crate::locale::LOCALE_MUTEX.lock().unwrap();
651        // Pin locale to "en" for test isolation
652        rust_i18n::set_locale("en");
653
654        let mut lint_config = LintConfig::default();
655        assert!(lint_config.locale().is_none());
656
657        let vscode_config = VsCodeConfig {
658            locale: Some(Some("es".to_string())),
659            ..Default::default()
660        };
661
662        vscode_config.merge_into_lint_config(&mut lint_config);
663
664        assert_eq!(lint_config.locale(), Some("es"));
665        assert_eq!(&*rust_i18n::locale(), "es");
666
667        // Reset locale for other tests
668        rust_i18n::set_locale("en");
669    }
670
671    #[test]
672    fn test_locale_null_reverts_to_auto_detect() {
673        let _guard = crate::locale::LOCALE_MUTEX.lock().unwrap();
674        // Pin locale to "es" to simulate a previously set locale
675        rust_i18n::set_locale("es");
676
677        let mut lint_config = LintConfig::default();
678        lint_config.set_locale(Some("es".to_string()));
679
680        // User sets locale to null in VS Code (revert to auto-detection)
681        let vscode_config = VsCodeConfig {
682            locale: Some(None),
683            ..Default::default()
684        };
685
686        vscode_config.merge_into_lint_config(&mut lint_config);
687
688        // Config locale should be cleared
689        assert!(lint_config.locale().is_none());
690
691        // Reset locale for other tests
692        rust_i18n::set_locale("en");
693    }
694
695    #[test]
696    fn test_locale_not_set_preserves_existing() {
697        let mut lint_config = LintConfig::default();
698        lint_config.set_locale(Some("zh-CN".to_string()));
699
700        let vscode_config = VsCodeConfig {
701            severity: Some("Error".to_string()),
702            ..Default::default()
703        };
704
705        vscode_config.merge_into_lint_config(&mut lint_config);
706
707        // locale not in VsCodeConfig, so existing value preserved
708        assert_eq!(lint_config.locale(), Some("zh-CN"));
709    }
710}
711
712#[test]
713fn test_version_pin_clearing_with_null() {
714    // Start with a config that has version pins from .agnix.toml
715    let mut lint_config = LintConfig::default();
716    lint_config.tool_versions_mut().claude_code = Some("0.9.0".to_string());
717    lint_config.tool_versions_mut().codex = Some("0.5.0".to_string());
718
719    // User explicitly sets claude_code to null in VS Code (clears pin)
720    // but doesn't touch codex (preserves .agnix.toml value)
721    let vscode_config = VsCodeConfig {
722        versions: Some(VsCodeVersions {
723            claude_code: Some(None), // Explicitly null - clear the pin
724            codex: None,             // Not specified - preserve .agnix.toml
725            ..Default::default()
726        }),
727        ..Default::default()
728    };
729
730    vscode_config.merge_into_lint_config(&mut lint_config);
731
732    // claude_code should be cleared (None)
733    assert!(lint_config.tool_versions().claude_code.is_none());
734    // codex should still have the .agnix.toml value
735    assert_eq!(lint_config.tool_versions().codex, Some("0.5.0".to_string()));
736}
737
738#[test]
739fn test_spec_pin_clearing_with_null() {
740    // Start with a config that has spec pins from .agnix.toml
741    let mut lint_config = LintConfig::default();
742    lint_config.spec_revisions_mut().mcp_protocol = Some("2025-01-01".to_string());
743    lint_config.spec_revisions_mut().agent_skills_spec = Some("v1".to_string());
744
745    // User explicitly sets mcp_protocol to null (clears pin)
746    // but doesn't touch agent_skills_spec (preserves .agnix.toml)
747    let vscode_config = VsCodeConfig {
748        specs: Some(VsCodeSpecs {
749            mcp_protocol: Some(None), // Explicitly null - clear the pin
750            agent_skills_spec: None,  // Not specified - preserve .agnix.toml
751            ..Default::default()
752        }),
753        ..Default::default()
754    };
755
756    vscode_config.merge_into_lint_config(&mut lint_config);
757
758    // mcp_protocol should be cleared (None)
759    assert!(lint_config.spec_revisions().mcp_protocol.is_none());
760    // agent_skills_spec should still have the .agnix.toml value
761    assert_eq!(
762        lint_config.spec_revisions().agent_skills_spec,
763        Some("v1".to_string())
764    );
765}
766
767#[test]
768fn test_vscode_files_deserialization() {
769    let json = r#"{
770        "files": {
771            "include_as_memory": ["docs/ai-rules/*.md"],
772            "include_as_generic": ["internal/*.md"],
773            "exclude": ["drafts/**"]
774        }
775    }"#;
776
777    let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
778    let files = config.files.expect("files should be present");
779    assert_eq!(
780        files.include_as_memory,
781        Some(vec!["docs/ai-rules/*.md".to_string()])
782    );
783    assert_eq!(
784        files.include_as_generic,
785        Some(vec!["internal/*.md".to_string()])
786    );
787    assert_eq!(files.exclude, Some(vec!["drafts/**".to_string()]));
788}
789
790#[test]
791fn test_vscode_files_partial_deserialization() {
792    let json = r#"{
793        "files": {
794            "include_as_memory": ["custom.md"]
795        }
796    }"#;
797
798    let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
799    let files = config.files.expect("files should be present");
800    assert_eq!(files.include_as_memory, Some(vec!["custom.md".to_string()]));
801    assert!(files.include_as_generic.is_none());
802    assert!(files.exclude.is_none());
803}
804
805#[test]
806fn test_vscode_files_not_set_preserves_existing() {
807    let mut lint_config = LintConfig::default();
808    lint_config.files_mut().include_as_memory = vec!["existing.md".to_string()];
809
810    // VS Code config without files section
811    let vscode_config = VsCodeConfig {
812        severity: Some("Error".to_string()),
813        ..Default::default()
814    };
815
816    vscode_config.merge_into_lint_config(&mut lint_config);
817
818    // Files config should be preserved
819    assert_eq!(
820        lint_config.files_config().include_as_memory,
821        vec!["existing.md".to_string()]
822    );
823}
824
825// VS Code config replaces arrays entirely (not appends). If user has
826// ["a.md"] in .agnix.toml and ["b.md"] in VS Code, VS Code wins.
827#[test]
828fn test_vscode_files_merge_overrides() {
829    let mut lint_config = LintConfig::default();
830    lint_config.files_mut().include_as_memory = vec!["old.md".to_string()];
831    lint_config.files_mut().include_as_generic = vec!["old-generic.md".to_string()];
832
833    let vscode_config = VsCodeConfig {
834        files: Some(VsCodeFiles {
835            include_as_memory: Some(vec!["new.md".to_string()]),
836            include_as_generic: None, // Not specified - preserve existing
837            exclude: Some(vec!["drafts/**".to_string()]),
838        }),
839        ..Default::default()
840    };
841
842    vscode_config.merge_into_lint_config(&mut lint_config);
843
844    // include_as_memory overridden
845    assert_eq!(
846        lint_config.files_config().include_as_memory,
847        vec!["new.md".to_string()]
848    );
849    // include_as_generic preserved (not in VS Code config)
850    assert_eq!(
851        lint_config.files_config().include_as_generic,
852        vec!["old-generic.md".to_string()]
853    );
854    // exclude added
855    assert_eq!(
856        lint_config.files_config().exclude,
857        vec!["drafts/**".to_string()]
858    );
859}