1use agnix_core::LintConfig;
17use agnix_core::config::{
18 FilesConfig, RuleConfig, SeverityLevel, SpecRevisions, TargetTool, ToolVersions,
19};
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Default, Clone, Deserialize, Serialize)]
27#[serde(default)]
28pub struct VsCodeConfig {
29 #[serde(default)]
31 pub severity: Option<String>,
32
33 #[serde(default)]
35 pub target: Option<String>,
36
37 #[serde(default)]
39 pub tools: Option<Vec<String>>,
40
41 #[serde(default)]
43 pub rules: Option<VsCodeRules>,
44
45 #[serde(default)]
47 pub versions: Option<VsCodeVersions>,
48
49 #[serde(default)]
51 pub specs: Option<VsCodeSpecs>,
52
53 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub locale: Option<Option<String>>,
60
61 #[serde(default)]
63 pub files: Option<VsCodeFiles>,
64}
65
66#[derive(Debug, Default, Clone, Deserialize, Serialize)]
70#[serde(default)]
71pub struct VsCodeRules {
72 #[serde(default)]
74 pub skills: Option<bool>,
75
76 #[serde(default)]
78 pub hooks: Option<bool>,
79
80 #[serde(default)]
82 pub agents: Option<bool>,
83
84 #[serde(default)]
86 pub memory: Option<bool>,
87
88 #[serde(default)]
90 pub plugins: Option<bool>,
91
92 #[serde(default)]
94 pub xml: Option<bool>,
95
96 #[serde(default)]
98 pub mcp: Option<bool>,
99
100 #[serde(default)]
102 pub imports: Option<bool>,
103
104 #[serde(default)]
106 pub cross_platform: Option<bool>,
107
108 #[serde(default)]
110 pub agents_md: Option<bool>,
111
112 #[serde(default)]
114 pub copilot: Option<bool>,
115
116 #[serde(default)]
118 pub cursor: Option<bool>,
119
120 #[serde(default)]
122 pub prompt_engineering: Option<bool>,
123
124 #[serde(default)]
126 pub disabled_rules: Option<Vec<String>>,
127}
128
129#[derive(Debug, Default, Clone, Deserialize, Serialize)]
137#[serde(default)]
138pub struct VsCodeVersions {
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub claude_code: Option<Option<String>>,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub codex: Option<Option<String>>,
146
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub cursor: Option<Option<String>>,
150
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub copilot: Option<Option<String>>,
154}
155
156#[derive(Debug, Default, Clone, Deserialize, Serialize)]
164#[serde(default)]
165pub struct VsCodeSpecs {
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub mcp_protocol: Option<Option<String>>,
169
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub agent_skills_spec: Option<Option<String>>,
173
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub agents_md_spec: Option<Option<String>>,
177}
178
179#[derive(Debug, Default, Clone, Deserialize, Serialize)]
183#[serde(default)]
184pub struct VsCodeFiles {
185 #[serde(default)]
187 pub include_as_memory: Option<Vec<String>>,
188
189 #[serde(default)]
191 pub include_as_generic: Option<Vec<String>>,
192
193 #[serde(default)]
195 pub exclude: Option<Vec<String>>,
196}
197
198impl VsCodeConfig {
199 pub fn merge_into_lint_config(&self, config: &mut LintConfig) {
209 if let Some(ref severity) = self.severity {
211 if let Some(level) = parse_severity(severity) {
212 config.set_severity(level);
213 }
214 }
215
216 if let Some(ref target) = self.target {
218 if let Some(tool) = parse_target(target) {
219 config.set_target(tool);
220 }
221 }
222
223 if let Some(ref tools) = self.tools {
225 config.set_tools(tools.clone());
226 }
227
228 if let Some(ref rules) = self.rules {
230 rules.merge_into_rule_config(config.rules_mut());
231 }
232
233 if let Some(ref versions) = self.versions {
235 versions.merge_into_tool_versions(config.tool_versions_mut());
236 }
237
238 if let Some(ref specs) = self.specs {
240 specs.merge_into_spec_revisions(config.spec_revisions_mut());
241 }
242
243 if let Some(ref files) = self.files {
245 files.merge_into_files_config(config.files_mut());
246 }
247
248 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 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 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 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 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
371fn 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
381fn 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 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()); }
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()); }
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 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 assert_eq!(lint_config.severity(), SeverityLevel::Error);
513 assert!(!lint_config.rules().skills);
514
515 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 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 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()); }
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 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 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 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 rust_i18n::set_locale("es");
676
677 let mut lint_config = LintConfig::default();
678 lint_config.set_locale(Some("es".to_string()));
679
680 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 assert!(lint_config.locale().is_none());
690
691 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 assert_eq!(lint_config.locale(), Some("zh-CN"));
709 }
710}
711
712#[test]
713fn test_version_pin_clearing_with_null() {
714 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 let vscode_config = VsCodeConfig {
722 versions: Some(VsCodeVersions {
723 claude_code: Some(None), codex: None, ..Default::default()
726 }),
727 ..Default::default()
728 };
729
730 vscode_config.merge_into_lint_config(&mut lint_config);
731
732 assert!(lint_config.tool_versions().claude_code.is_none());
734 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 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 let vscode_config = VsCodeConfig {
748 specs: Some(VsCodeSpecs {
749 mcp_protocol: Some(None), agent_skills_spec: None, ..Default::default()
752 }),
753 ..Default::default()
754 };
755
756 vscode_config.merge_into_lint_config(&mut lint_config);
757
758 assert!(lint_config.spec_revisions().mcp_protocol.is_none());
760 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 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 assert_eq!(
820 lint_config.files_config().include_as_memory,
821 vec!["existing.md".to_string()]
822 );
823}
824
825#[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, exclude: Some(vec!["drafts/**".to_string()]),
838 }),
839 ..Default::default()
840 };
841
842 vscode_config.merge_into_lint_config(&mut lint_config);
843
844 assert_eq!(
846 lint_config.files_config().include_as_memory,
847 vec!["new.md".to_string()]
848 );
849 assert_eq!(
851 lint_config.files_config().include_as_generic,
852 vec!["old-generic.md".to_string()]
853 );
854 assert_eq!(
856 lint_config.files_config().exclude,
857 vec!["drafts/**".to_string()]
858 );
859}