use agnix_core::LintConfig;
use agnix_core::config::{
FilesConfig, RuleConfig, SeverityLevel, SpecRevisions, TargetTool, ToolVersions,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct VsCodeConfig {
#[serde(default)]
pub severity: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub tools: Option<Vec<String>>,
#[serde(default)]
pub rules: Option<VsCodeRules>,
#[serde(default)]
pub versions: Option<VsCodeVersions>,
#[serde(default)]
pub specs: Option<VsCodeSpecs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locale: Option<Option<String>>,
#[serde(default)]
pub files: Option<VsCodeFiles>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct VsCodeRules {
#[serde(default)]
pub skills: Option<bool>,
#[serde(default)]
pub hooks: Option<bool>,
#[serde(default)]
pub agents: Option<bool>,
#[serde(default)]
pub memory: Option<bool>,
#[serde(default)]
pub plugins: Option<bool>,
#[serde(default)]
pub xml: Option<bool>,
#[serde(default)]
pub mcp: Option<bool>,
#[serde(default)]
pub imports: Option<bool>,
#[serde(default)]
pub cross_platform: Option<bool>,
#[serde(default)]
pub agents_md: Option<bool>,
#[serde(default)]
pub copilot: Option<bool>,
#[serde(default)]
pub cursor: Option<bool>,
#[serde(default)]
pub prompt_engineering: Option<bool>,
#[serde(default)]
pub disabled_rules: Option<Vec<String>>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct VsCodeVersions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_code: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copilot: Option<Option<String>>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct VsCodeSpecs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_protocol: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_skills_spec: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agents_md_spec: Option<Option<String>>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct VsCodeFiles {
#[serde(default)]
pub include_as_memory: Option<Vec<String>>,
#[serde(default)]
pub include_as_generic: Option<Vec<String>>,
#[serde(default)]
pub exclude: Option<Vec<String>>,
}
impl VsCodeConfig {
pub fn merge_into_lint_config(&self, config: &mut LintConfig) {
if let Some(ref severity) = self.severity {
if let Some(level) = parse_severity(severity) {
config.set_severity(level);
}
}
if let Some(ref target) = self.target {
if let Some(tool) = parse_target(target) {
config.set_target(tool);
}
}
if let Some(ref tools) = self.tools {
config.set_tools(tools.clone());
}
if let Some(ref rules) = self.rules {
rules.merge_into_rule_config(config.rules_mut());
}
if let Some(ref versions) = self.versions {
versions.merge_into_tool_versions(config.tool_versions_mut());
}
if let Some(ref specs) = self.specs {
specs.merge_into_spec_revisions(config.spec_revisions_mut());
}
if let Some(ref files) = self.files {
files.merge_into_files_config(config.files_mut());
}
if let Some(ref locale_opt) = self.locale {
match locale_opt {
Some(locale) => {
config.set_locale(Some(locale.clone()));
crate::locale::init_from_config(locale);
}
None => {
config.set_locale(None);
crate::locale::init_from_env();
}
}
}
}
}
impl VsCodeFiles {
fn merge_into_files_config(&self, config: &mut FilesConfig) {
if let Some(ref v) = self.include_as_memory {
config.include_as_memory = v.clone();
}
if let Some(ref v) = self.include_as_generic {
config.include_as_generic = v.clone();
}
if let Some(ref v) = self.exclude {
config.exclude = v.clone();
}
}
}
impl VsCodeRules {
fn merge_into_rule_config(&self, config: &mut RuleConfig) {
if let Some(v) = self.skills {
config.skills = v;
}
if let Some(v) = self.hooks {
config.hooks = v;
}
if let Some(v) = self.agents {
config.agents = v;
}
if let Some(v) = self.memory {
config.memory = v;
}
if let Some(v) = self.plugins {
config.plugins = v;
}
if let Some(v) = self.xml {
config.xml = v;
}
if let Some(v) = self.mcp {
config.mcp = v;
}
if let Some(v) = self.imports {
config.imports = v;
}
if let Some(v) = self.cross_platform {
config.cross_platform = v;
}
if let Some(v) = self.agents_md {
config.agents_md = v;
}
if let Some(v) = self.copilot {
config.copilot = v;
}
if let Some(v) = self.cursor {
config.cursor = v;
}
if let Some(v) = self.prompt_engineering {
config.prompt_engineering = v;
}
if let Some(ref v) = self.disabled_rules {
config.disabled_rules = v.clone();
}
}
}
impl VsCodeVersions {
fn merge_into_tool_versions(&self, config: &mut ToolVersions) {
if let Some(ref value) = self.claude_code {
config.claude_code = value.clone();
}
if let Some(ref value) = self.codex {
config.codex = value.clone();
}
if let Some(ref value) = self.cursor {
config.cursor = value.clone();
}
if let Some(ref value) = self.copilot {
config.copilot = value.clone();
}
}
}
impl VsCodeSpecs {
fn merge_into_spec_revisions(&self, config: &mut SpecRevisions) {
if let Some(ref value) = self.mcp_protocol {
config.mcp_protocol = value.clone();
}
if let Some(ref value) = self.agent_skills_spec {
config.agent_skills_spec = value.clone();
}
if let Some(ref value) = self.agents_md_spec {
config.agents_md_spec = value.clone();
}
}
}
fn parse_severity(s: &str) -> Option<SeverityLevel> {
match s {
"Error" => Some(SeverityLevel::Error),
"Warning" => Some(SeverityLevel::Warning),
"Info" => Some(SeverityLevel::Info),
_ => None,
}
}
fn parse_target(s: &str) -> Option<TargetTool> {
match s {
"Generic" => Some(TargetTool::Generic),
"ClaudeCode" => Some(TargetTool::ClaudeCode),
"Cursor" => Some(TargetTool::Cursor),
"Codex" => Some(TargetTool::Codex),
"Kiro" => Some(TargetTool::Kiro),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vscode_config_deserialization_complete() {
let json = r#"{
"severity": "Error",
"target": "ClaudeCode",
"tools": ["claude-code", "cursor"],
"locale": "es",
"rules": {
"skills": false,
"hooks": true,
"agents": false,
"memory": true,
"plugins": false,
"xml": true,
"mcp": false,
"imports": true,
"cross_platform": false,
"agents_md": true,
"copilot": false,
"cursor": true,
"prompt_engineering": false,
"disabled_rules": ["AS-001", "PE-003"]
},
"versions": {
"claude_code": "1.0.0",
"codex": "0.1.0",
"cursor": "0.45.0",
"copilot": "1.2.0"
},
"specs": {
"mcp_protocol": "2025-11-25",
"agent_skills_spec": "1.0",
"agents_md_spec": "1.0"
}
}"#;
let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
assert_eq!(config.severity, Some("Error".to_string()));
assert_eq!(config.target, Some("ClaudeCode".to_string()));
assert_eq!(
config.tools,
Some(vec!["claude-code".to_string(), "cursor".to_string()])
);
assert_eq!(config.locale, Some(Some("es".to_string())));
let rules = config.rules.expect("rules should be present");
assert_eq!(rules.skills, Some(false));
assert_eq!(rules.hooks, Some(true));
assert_eq!(
rules.disabled_rules,
Some(vec!["AS-001".to_string(), "PE-003".to_string()])
);
let versions = config.versions.expect("versions should be present");
assert_eq!(versions.claude_code, Some(Some("1.0.0".to_string())));
let specs = config.specs.expect("specs should be present");
assert_eq!(specs.mcp_protocol, Some(Some("2025-11-25".to_string())));
}
#[test]
fn test_vscode_config_deserialization_partial() {
let json = r#"{
"severity": "Warning",
"rules": {
"skills": false
}
}"#;
let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
assert_eq!(config.severity, Some("Warning".to_string()));
assert!(config.target.is_none());
assert!(config.tools.is_none());
let rules = config.rules.expect("rules should be present");
assert_eq!(rules.skills, Some(false));
assert!(rules.hooks.is_none()); }
#[test]
fn test_vscode_config_deserialization_empty() {
let json = "{}";
let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
assert!(config.severity.is_none());
assert!(config.target.is_none());
assert!(config.tools.is_none());
assert!(config.rules.is_none());
assert!(config.versions.is_none());
assert!(config.specs.is_none());
assert!(config.locale.is_none()); }
#[test]
fn test_merge_into_lint_config_preserves_unspecified() {
let mut lint_config = LintConfig::default();
lint_config.set_severity(SeverityLevel::Error);
lint_config.rules_mut().skills = false;
let vscode_config = VsCodeConfig {
rules: Some(VsCodeRules {
hooks: Some(false),
..Default::default()
}),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(lint_config.severity(), SeverityLevel::Error);
assert!(!lint_config.rules().skills);
assert!(!lint_config.rules().hooks);
}
#[test]
fn test_merge_into_lint_config_overrides() {
let mut lint_config = LintConfig::default();
lint_config.set_severity(SeverityLevel::Warning);
lint_config.set_target(TargetTool::Generic);
lint_config.rules_mut().skills = true;
let vscode_config = VsCodeConfig {
severity: Some("Error".to_string()),
target: Some("ClaudeCode".to_string()),
rules: Some(VsCodeRules {
skills: Some(false),
..Default::default()
}),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(lint_config.severity(), SeverityLevel::Error);
assert_eq!(lint_config.target(), TargetTool::ClaudeCode);
assert!(!lint_config.rules().skills);
}
#[test]
fn test_merge_versions() {
let mut lint_config = LintConfig::default();
lint_config.tool_versions_mut().claude_code = Some("0.9.0".to_string());
let vscode_config = VsCodeConfig {
versions: Some(VsCodeVersions {
claude_code: Some(Some("1.0.0".to_string())),
codex: Some(Some("0.1.0".to_string())),
..Default::default()
}),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(
lint_config.tool_versions().claude_code,
Some("1.0.0".to_string())
);
assert_eq!(lint_config.tool_versions().codex, Some("0.1.0".to_string()));
assert!(lint_config.tool_versions().cursor.is_none()); }
#[test]
fn test_merge_specs() {
let mut lint_config = LintConfig::default();
let vscode_config = VsCodeConfig {
specs: Some(VsCodeSpecs {
mcp_protocol: Some(Some("2025-11-25".to_string())),
..Default::default()
}),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(
lint_config.spec_revisions().mcp_protocol,
Some("2025-11-25".to_string())
);
assert!(lint_config.spec_revisions().agent_skills_spec.is_none());
}
#[test]
fn test_parse_severity() {
assert_eq!(parse_severity("Error"), Some(SeverityLevel::Error));
assert_eq!(parse_severity("Warning"), Some(SeverityLevel::Warning));
assert_eq!(parse_severity("Info"), Some(SeverityLevel::Info));
assert_eq!(parse_severity("invalid"), None);
}
#[test]
fn test_parse_target() {
assert_eq!(parse_target("Generic"), Some(TargetTool::Generic));
assert_eq!(parse_target("ClaudeCode"), Some(TargetTool::ClaudeCode));
assert_eq!(parse_target("Cursor"), Some(TargetTool::Cursor));
assert_eq!(parse_target("Codex"), Some(TargetTool::Codex));
assert_eq!(parse_target("Kiro"), Some(TargetTool::Kiro));
assert_eq!(parse_target("invalid"), None);
}
#[test]
fn test_disabled_rules_merge() {
let mut lint_config = LintConfig::default();
lint_config.rules_mut().disabled_rules = vec!["AS-001".to_string()];
let vscode_config = VsCodeConfig {
rules: Some(VsCodeRules {
disabled_rules: Some(vec!["PE-003".to_string(), "MCP-001".to_string()]),
..Default::default()
}),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(
lint_config.rules().disabled_rules,
vec!["PE-003".to_string(), "MCP-001".to_string()]
);
}
#[test]
fn test_tools_array_merge() {
let mut lint_config = LintConfig::default();
lint_config.set_tools(vec!["generic".to_string()]);
let vscode_config = VsCodeConfig {
tools: Some(vec!["claude-code".to_string(), "cursor".to_string()]),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(
lint_config.tools(),
&["claude-code".to_string(), "cursor".to_string()]
);
}
#[test]
fn test_locale_merge() {
let _guard = crate::locale::LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("en");
let mut lint_config = LintConfig::default();
assert!(lint_config.locale().is_none());
let vscode_config = VsCodeConfig {
locale: Some(Some("es".to_string())),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(lint_config.locale(), Some("es"));
assert_eq!(&*rust_i18n::locale(), "es");
rust_i18n::set_locale("en");
}
#[test]
fn test_locale_null_reverts_to_auto_detect() {
let _guard = crate::locale::LOCALE_MUTEX.lock().unwrap();
rust_i18n::set_locale("es");
let mut lint_config = LintConfig::default();
lint_config.set_locale(Some("es".to_string()));
let vscode_config = VsCodeConfig {
locale: Some(None),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert!(lint_config.locale().is_none());
rust_i18n::set_locale("en");
}
#[test]
fn test_locale_not_set_preserves_existing() {
let mut lint_config = LintConfig::default();
lint_config.set_locale(Some("zh-CN".to_string()));
let vscode_config = VsCodeConfig {
severity: Some("Error".to_string()),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(lint_config.locale(), Some("zh-CN"));
}
}
#[test]
fn test_version_pin_clearing_with_null() {
let mut lint_config = LintConfig::default();
lint_config.tool_versions_mut().claude_code = Some("0.9.0".to_string());
lint_config.tool_versions_mut().codex = Some("0.5.0".to_string());
let vscode_config = VsCodeConfig {
versions: Some(VsCodeVersions {
claude_code: Some(None), codex: None, ..Default::default()
}),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert!(lint_config.tool_versions().claude_code.is_none());
assert_eq!(lint_config.tool_versions().codex, Some("0.5.0".to_string()));
}
#[test]
fn test_spec_pin_clearing_with_null() {
let mut lint_config = LintConfig::default();
lint_config.spec_revisions_mut().mcp_protocol = Some("2025-01-01".to_string());
lint_config.spec_revisions_mut().agent_skills_spec = Some("v1".to_string());
let vscode_config = VsCodeConfig {
specs: Some(VsCodeSpecs {
mcp_protocol: Some(None), agent_skills_spec: None, ..Default::default()
}),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert!(lint_config.spec_revisions().mcp_protocol.is_none());
assert_eq!(
lint_config.spec_revisions().agent_skills_spec,
Some("v1".to_string())
);
}
#[test]
fn test_vscode_files_deserialization() {
let json = r#"{
"files": {
"include_as_memory": ["docs/ai-rules/*.md"],
"include_as_generic": ["internal/*.md"],
"exclude": ["drafts/**"]
}
}"#;
let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
let files = config.files.expect("files should be present");
assert_eq!(
files.include_as_memory,
Some(vec!["docs/ai-rules/*.md".to_string()])
);
assert_eq!(
files.include_as_generic,
Some(vec!["internal/*.md".to_string()])
);
assert_eq!(files.exclude, Some(vec!["drafts/**".to_string()]));
}
#[test]
fn test_vscode_files_partial_deserialization() {
let json = r#"{
"files": {
"include_as_memory": ["custom.md"]
}
}"#;
let config: VsCodeConfig = serde_json::from_str(json).expect("should parse");
let files = config.files.expect("files should be present");
assert_eq!(files.include_as_memory, Some(vec!["custom.md".to_string()]));
assert!(files.include_as_generic.is_none());
assert!(files.exclude.is_none());
}
#[test]
fn test_vscode_files_not_set_preserves_existing() {
let mut lint_config = LintConfig::default();
lint_config.files_mut().include_as_memory = vec!["existing.md".to_string()];
let vscode_config = VsCodeConfig {
severity: Some("Error".to_string()),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(
lint_config.files_config().include_as_memory,
vec!["existing.md".to_string()]
);
}
#[test]
fn test_vscode_files_merge_overrides() {
let mut lint_config = LintConfig::default();
lint_config.files_mut().include_as_memory = vec!["old.md".to_string()];
lint_config.files_mut().include_as_generic = vec!["old-generic.md".to_string()];
let vscode_config = VsCodeConfig {
files: Some(VsCodeFiles {
include_as_memory: Some(vec!["new.md".to_string()]),
include_as_generic: None, exclude: Some(vec!["drafts/**".to_string()]),
}),
..Default::default()
};
vscode_config.merge_into_lint_config(&mut lint_config);
assert_eq!(
lint_config.files_config().include_as_memory,
vec!["new.md".to_string()]
);
assert_eq!(
lint_config.files_config().include_as_generic,
vec!["old-generic.md".to_string()]
);
assert_eq!(
lint_config.files_config().exclude,
vec!["drafts/**".to_string()]
);
}