Skip to content

Commit fcf837b

Browse files
brynaryclaude[bot]
andauthored
feat(config): Reject invalid configuration combinations (#2077)
Fixes #1946 This PR implements validation to reject invalid configuration combinations in qlty.toml files. ## Changes - Add validation to `EnabledPlugin` to detect mutually exclusive options - `package_file` and `extra_packages` cannot be used together - Provide clear error message when both options are specified - Add comprehensive unit tests and integration tests - Validation occurs during configuration parsing/post-processing ## Testing The implementation includes tests that verify: - Valid configurations with only `package_file` - Valid configurations with only `extra_packages` - Valid configurations with neither option - Error handling when both options are specified Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: brynary <[email protected]>
1 parent 31a65c3 commit fcf837b

File tree

2 files changed

+261
-1
lines changed

2 files changed

+261
-1
lines changed

qlty-config/src/config/builder.rs

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ impl Builder {
252252
}
253253
}
254254

255-
fn toml_to_config(toml: Value) -> Result<QltyConfig> {
255+
pub fn toml_to_config(toml: Value) -> Result<QltyConfig> {
256256
let mut config: QltyConfig = Self::parse_toml_as_config(toml)?;
257257
Self::insert_ignores_from_exclude_patterns(&mut config);
258258
let config = Self::post_process_config(config);
@@ -275,6 +275,9 @@ impl Builder {
275275
let mut config = config.clone();
276276

277277
for enabled_plugin in &mut config.plugin {
278+
enabled_plugin.validate().with_context(|| {
279+
format!("Configuration error for plugin '{}'", enabled_plugin.name)
280+
})?;
278281
let plugin_definition =
279282
config
280283
.plugins
@@ -386,4 +389,120 @@ mod test {
386389
let result = Builder::extract_sources(Table(input));
387390
assert!(result.is_err());
388391
}
392+
393+
#[test]
394+
fn test_validate_plugin_with_mutually_exclusive_options() {
395+
let invalid_config = toml! {
396+
config_version = "0"
397+
398+
[[plugin]]
399+
name = "rubocop"
400+
version = "1.56.3"
401+
extra_packages = ["[email protected]"]
402+
package_file = "Gemfile"
403+
404+
[plugins.definitions.rubocop]
405+
runtime = "ruby"
406+
};
407+
408+
let result = Builder::toml_to_config(Table(invalid_config));
409+
assert!(result.is_err());
410+
411+
let error = result.unwrap_err();
412+
let error_chain = error
413+
.chain()
414+
.map(|e| e.to_string())
415+
.collect::<Vec<_>>()
416+
.join(" ");
417+
418+
assert!(error_chain.contains("rubocop"));
419+
assert!(error_chain.contains("package_file"));
420+
assert!(error_chain.contains("extra_packages"));
421+
assert!(error_chain.contains("mutually exclusive"));
422+
}
423+
424+
#[test]
425+
fn test_validate_plugin_with_package_file_only() {
426+
let valid_config = toml! {
427+
config_version = "0"
428+
429+
[[plugin]]
430+
name = "rubocop"
431+
version = "1.56.3"
432+
package_file = "Gemfile"
433+
434+
[plugins.definitions.rubocop]
435+
runtime = "ruby"
436+
};
437+
438+
let result = Builder::toml_to_config(Table(valid_config));
439+
assert!(result.is_ok());
440+
}
441+
442+
#[test]
443+
fn test_validate_plugin_with_extra_packages_only() {
444+
let valid_config = toml! {
445+
config_version = "0"
446+
447+
[[plugin]]
448+
name = "rubocop"
449+
version = "1.56.3"
450+
extra_packages = ["[email protected]"]
451+
452+
[plugins.definitions.rubocop]
453+
runtime = "ruby"
454+
};
455+
456+
let result = Builder::toml_to_config(Table(valid_config));
457+
assert!(result.is_ok());
458+
}
459+
460+
#[test]
461+
fn test_validate_plugin_with_package_filters_but_no_package_file() {
462+
let invalid_config = toml! {
463+
config_version = "0"
464+
465+
[[plugin]]
466+
name = "rubocop"
467+
version = "1.56.3"
468+
package_filters = ["some-filter"]
469+
470+
[plugins.definitions.rubocop]
471+
runtime = "ruby"
472+
};
473+
474+
let result = Builder::toml_to_config(Table(invalid_config));
475+
assert!(result.is_err());
476+
477+
let error = result.unwrap_err();
478+
let error_chain = error
479+
.chain()
480+
.map(|e| e.to_string())
481+
.collect::<Vec<_>>()
482+
.join(" ");
483+
484+
assert!(error_chain.contains("rubocop"));
485+
assert!(error_chain.contains("package_filters"));
486+
assert!(error_chain.contains("package_file"));
487+
assert!(error_chain.contains("requires"));
488+
}
489+
490+
#[test]
491+
fn test_validate_plugin_with_package_filters_and_package_file() {
492+
let valid_config = toml! {
493+
config_version = "0"
494+
495+
[[plugin]]
496+
name = "rubocop"
497+
version = "1.56.3"
498+
package_file = "Gemfile"
499+
package_filters = ["some-filter"]
500+
501+
[plugins.definitions.rubocop]
502+
runtime = "ruby"
503+
};
504+
505+
let result = Builder::toml_to_config(Table(valid_config));
506+
assert!(result.is_ok());
507+
}
389508
}

qlty-config/src/config/plugin.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,26 @@ pub struct PluginFetch {
699699
pub path: String,
700700
}
701701

702+
impl EnabledPlugin {
703+
pub fn validate(&self) -> Result<()> {
704+
if self.package_file.is_some() && !self.extra_packages.is_empty() {
705+
return Err(anyhow::anyhow!(
706+
"Plugin '{}' has both 'package_file' and 'extra_packages' configured. These options are mutually exclusive.",
707+
self.name
708+
));
709+
}
710+
711+
if !self.package_filters.is_empty() && self.package_file.is_none() {
712+
return Err(anyhow::anyhow!(
713+
"Plugin '{}' has 'package_filters' configured but no 'package_file'. The 'package_filters' option requires 'package_file' to be specified.",
714+
self.name
715+
));
716+
}
717+
718+
Ok(())
719+
}
720+
}
721+
702722
impl PluginFetch {
703723
pub fn download_file_to(&self, directories: &[PathBuf]) -> Result<()> {
704724
let response = ureq::get(&self.url)
@@ -881,3 +901,124 @@ impl fmt::Display for Platform {
881901
write!(f, "{}", name)
882902
}
883903
}
904+
905+
#[cfg(test)]
906+
mod tests {
907+
use super::*;
908+
909+
#[test]
910+
fn test_enabled_plugin_validate_success_with_package_file() {
911+
let plugin = EnabledPlugin {
912+
name: "test-plugin".to_string(),
913+
package_file: Some("Gemfile".to_string()),
914+
extra_packages: vec![],
915+
..Default::default()
916+
};
917+
918+
assert!(plugin.validate().is_ok());
919+
}
920+
921+
#[test]
922+
fn test_enabled_plugin_validate_success_with_extra_packages() {
923+
let plugin = EnabledPlugin {
924+
name: "test-plugin".to_string(),
925+
package_file: None,
926+
extra_packages: vec![ExtraPackage {
927+
name: "some-package".to_string(),
928+
version: "1.0.0".to_string(),
929+
}],
930+
..Default::default()
931+
};
932+
933+
assert!(plugin.validate().is_ok());
934+
}
935+
936+
#[test]
937+
fn test_enabled_plugin_validate_success_with_neither() {
938+
let plugin = EnabledPlugin {
939+
name: "test-plugin".to_string(),
940+
package_file: None,
941+
extra_packages: vec![],
942+
..Default::default()
943+
};
944+
945+
assert!(plugin.validate().is_ok());
946+
}
947+
948+
#[test]
949+
fn test_enabled_plugin_validate_failure_with_both() {
950+
let plugin = EnabledPlugin {
951+
name: "test-plugin".to_string(),
952+
package_file: Some("Gemfile".to_string()),
953+
extra_packages: vec![ExtraPackage {
954+
name: "some-package".to_string(),
955+
version: "1.0.0".to_string(),
956+
}],
957+
..Default::default()
958+
};
959+
960+
let result = plugin.validate();
961+
assert!(result.is_err());
962+
963+
let error_message = result.unwrap_err().to_string();
964+
assert!(error_message.contains("test-plugin"));
965+
assert!(error_message.contains("package_file"));
966+
assert!(error_message.contains("extra_packages"));
967+
assert!(error_message.contains("mutually exclusive"));
968+
}
969+
970+
#[test]
971+
fn test_enabled_plugin_validate_success_with_package_file_and_filters() {
972+
let plugin = EnabledPlugin {
973+
name: "test-plugin".to_string(),
974+
package_file: Some("Gemfile".to_string()),
975+
package_filters: vec!["some-filter".to_string()],
976+
..Default::default()
977+
};
978+
979+
assert!(plugin.validate().is_ok());
980+
}
981+
982+
#[test]
983+
fn test_enabled_plugin_validate_success_with_package_file_no_filters() {
984+
let plugin = EnabledPlugin {
985+
name: "test-plugin".to_string(),
986+
package_file: Some("Gemfile".to_string()),
987+
package_filters: vec![],
988+
..Default::default()
989+
};
990+
991+
assert!(plugin.validate().is_ok());
992+
}
993+
994+
#[test]
995+
fn test_enabled_plugin_validate_success_without_package_file_or_filters() {
996+
let plugin = EnabledPlugin {
997+
name: "test-plugin".to_string(),
998+
package_file: None,
999+
package_filters: vec![],
1000+
..Default::default()
1001+
};
1002+
1003+
assert!(plugin.validate().is_ok());
1004+
}
1005+
1006+
#[test]
1007+
fn test_enabled_plugin_validate_failure_with_filters_but_no_package_file() {
1008+
let plugin = EnabledPlugin {
1009+
name: "test-plugin".to_string(),
1010+
package_file: None,
1011+
package_filters: vec!["some-filter".to_string()],
1012+
..Default::default()
1013+
};
1014+
1015+
let result = plugin.validate();
1016+
assert!(result.is_err());
1017+
1018+
let error_message = result.unwrap_err().to_string();
1019+
assert!(error_message.contains("test-plugin"));
1020+
assert!(error_message.contains("package_filters"));
1021+
assert!(error_message.contains("package_file"));
1022+
assert!(error_message.contains("requires"));
1023+
}
1024+
}

0 commit comments

Comments
 (0)