use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use ryo_suggest::SuggestStrategy;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RyoConfig {
pub project: ProjectConfig,
#[serde(default)]
pub modules: HashMap<String, ModuleConfig>,
#[serde(default)]
pub import: ImportConfig,
#[serde(default)]
pub mutations: MutationConfig,
#[serde(default)]
pub suggest: SuggestConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
pub name: Option<String>,
pub description: Option<String>,
#[serde(default = "default_edition")]
pub edition: String,
pub workspace_root: Option<PathBuf>,
pub manifest_path: Option<PathBuf>,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
name: None,
description: None,
edition: default_edition(),
workspace_root: None,
manifest_path: None,
}
}
}
fn default_edition() -> String {
"2021".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ModuleConfig {
pub skip_lint: bool,
pub skip_refactor: bool,
pub allow_unsafe: bool,
pub skip_format_check: bool,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub disabled_rules: Vec<String>,
#[serde(default)]
pub enabled_rules: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ImportConfig {
pub preserve_comments: bool,
pub auto_format: bool,
pub validate_syntax: bool,
}
impl Default for ImportConfig {
fn default() -> Self {
Self {
preserve_comments: true,
auto_format: false,
validate_syntax: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MutationConfig {
pub auto_organize_imports: bool,
pub check_compile: bool,
pub parallel: bool,
}
impl Default for MutationConfig {
fn default() -> Self {
Self {
auto_organize_imports: false,
check_compile: false,
parallel: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SuggestConfig {
pub strategy: String,
pub auto_detect_after_run: bool,
pub auto_apply: bool,
#[serde(default)]
pub enabled_patterns: Vec<String>,
#[serde(default)]
pub disabled_patterns: Vec<String>,
#[serde(default)]
pub disabled_rules: Vec<String>,
#[serde(default)]
pub enabled_rules: Vec<String>,
#[serde(default)]
pub severity_overrides: std::collections::HashMap<String, String>,
}
impl Default for SuggestConfig {
fn default() -> Self {
Self {
strategy: "interactive".to_string(),
auto_detect_after_run: true,
auto_apply: false,
enabled_patterns: vec![],
disabled_patterns: vec![],
disabled_rules: vec![],
enabled_rules: vec![],
severity_overrides: std::collections::HashMap::new(),
}
}
}
impl SuggestConfig {
pub fn to_strategy(&self) -> SuggestStrategy {
match self.strategy.as_str() {
"high_perf" => SuggestStrategy::high_perf(),
"batch" | "manual" => SuggestStrategy::batch(),
_ => SuggestStrategy::interactive(),
}
}
pub fn is_pattern_enabled(&self, name: &str) -> bool {
if self.disabled_patterns.iter().any(|p| p == name) {
return false;
}
if self.enabled_patterns.is_empty() {
return true;
}
self.enabled_patterns.iter().any(|p| p == name)
}
pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
if self
.disabled_rules
.iter()
.any(|p| Self::matches_pattern(p, rule_id))
{
return false;
}
if self.enabled_rules.is_empty() {
return true;
}
self.enabled_rules
.iter()
.any(|p| Self::matches_pattern(p, rule_id))
}
pub fn get_severity_override(&self, rule_id: &str) -> Option<&str> {
self.severity_overrides.get(rule_id).map(|s| s.as_str())
}
fn matches_pattern(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return value.starts_with(prefix);
}
if let Some(suffix) = pattern.strip_prefix('*') {
return value.ends_with(suffix);
}
pattern == value
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
Toml(#[from] toml::de::Error),
#[error("Config not found: {0}")]
NotFound(PathBuf),
}
impl RyoConfig {
pub const FILE_NAME: &'static str = "ryo.toml";
pub fn load(dir: impl AsRef<Path>) -> Result<Self, ConfigError> {
let path = dir.as_ref().join(Self::FILE_NAME);
Self::load_from_path(&path)
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let path = path.as_ref();
if !path.exists() {
return Err(ConfigError::NotFound(path.to_path_buf()));
}
let content = std::fs::read_to_string(path)?;
let config: RyoConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn load_or_default(dir: impl AsRef<Path>) -> Self {
Self::load(dir).unwrap_or_default()
}
pub fn save(&self, dir: impl AsRef<Path>) -> Result<(), ConfigError> {
let path = dir.as_ref().join(Self::FILE_NAME);
self.save_to_path(&path)
}
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
let content = toml::to_string_pretty(self).map_err(std::io::Error::other)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn get_module_config(&self, path: &str) -> Option<&ModuleConfig> {
if let Some(config) = self.modules.get(path) {
if !path.contains("::") {
return Some(config);
}
}
for (key, config) in &self.modules {
if key.contains("::") {
continue; }
if path.starts_with(key) {
return Some(config);
}
}
None
}
pub fn get_module_config_for_symbol(&self, symbol_path: &str) -> Option<&ModuleConfig> {
if let Some(config) = self.modules.get(symbol_path) {
return Some(config);
}
for (pattern, config) in &self.modules {
if !pattern.contains("::") {
continue; }
if Self::matches_symbol_path_pattern(pattern, symbol_path) {
return Some(config);
}
}
None
}
fn matches_symbol_path_pattern(pattern: &str, symbol_path: &str) -> bool {
let pattern_parts: Vec<&str> = pattern.split("::").collect();
let path_parts: Vec<&str> = symbol_path.split("::").collect();
let ends_with_wildcard = pattern_parts.last() == Some(&"*");
let pattern_parts = if ends_with_wildcard {
&pattern_parts[..pattern_parts.len() - 1]
} else {
&pattern_parts[..]
};
if !ends_with_wildcard && pattern_parts.len() != path_parts.len() {
return false;
}
if pattern_parts.len() > path_parts.len() {
return false;
}
for (i, pattern_part) in pattern_parts.iter().enumerate() {
if *pattern_part == "*" {
continue; }
if path_parts.get(i) != Some(pattern_part) {
return false;
}
}
true
}
pub fn should_skip_lint(&self, path: &str) -> bool {
self.get_module_config(path)
.map(|c| c.skip_lint)
.unwrap_or(false)
}
pub fn should_skip_refactor(&self, path: &str) -> bool {
self.get_module_config(path)
.map(|c| c.skip_refactor)
.unwrap_or(false)
}
pub fn is_rule_enabled_for_symbol(&self, symbol_path: &str, rule_id: &str) -> bool {
if let Some(module_config) = self.get_module_config_for_symbol(symbol_path) {
if module_config
.disabled_rules
.iter()
.any(|p| SuggestConfig::matches_pattern(p, rule_id))
{
return false;
}
if !module_config.enabled_rules.is_empty()
&& !module_config
.enabled_rules
.iter()
.any(|p| SuggestConfig::matches_pattern(p, rule_id))
{
return false;
}
}
self.suggest.is_rule_enabled(rule_id)
}
pub fn is_rule_enabled_for_file(&self, file_path: &str, rule_id: &str) -> bool {
if let Some(module_config) = self.get_module_config(file_path) {
if module_config
.disabled_rules
.iter()
.any(|p| SuggestConfig::matches_pattern(p, rule_id))
{
return false;
}
if !module_config.enabled_rules.is_empty()
&& !module_config
.enabled_rules
.iter()
.any(|p| SuggestConfig::matches_pattern(p, rule_id))
{
return false;
}
}
self.suggest.is_rule_enabled(rule_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_config() {
let toml = r#"
[project]
name = "my-app"
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
assert_eq!(config.project.name, Some("my-app".to_string()));
}
#[test]
fn test_parse_full_config() {
let toml = r#"
[project]
name = "my-app"
description = "A sample project"
edition = "2021"
[modules."src/generated"]
skip_lint = true
allow_unsafe = true
[modules."src/legacy"]
skip_refactor = true
tags = ["deprecated"]
[import]
preserve_comments = true
auto_format = false
[mutations]
auto_organize_imports = true
check_compile = true
parallel = true
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
assert_eq!(config.project.name, Some("my-app".to_string()));
let gen_config = config.modules.get("src/generated").unwrap();
assert!(gen_config.skip_lint);
assert!(gen_config.allow_unsafe);
let legacy_config = config.modules.get("src/legacy").unwrap();
assert!(legacy_config.skip_refactor);
assert_eq!(legacy_config.tags, vec!["deprecated"]);
assert!(config.import.preserve_comments);
assert!(config.mutations.auto_organize_imports);
}
#[test]
fn test_default_config() {
let config = RyoConfig::default();
assert_eq!(config.project.edition, "2021");
assert!(config.import.preserve_comments);
assert!(config.mutations.parallel);
}
#[test]
fn test_module_config_prefix_match() {
let toml = r#"
[modules."src/generated"]
skip_lint = true
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
assert!(config.should_skip_lint("src/generated"));
assert!(config.should_skip_lint("src/generated/foo.rs"));
assert!(config.should_skip_lint("src/generated/bar/baz.rs"));
assert!(!config.should_skip_lint("src/main.rs"));
}
#[test]
fn test_suggest_config_default() {
let config = SuggestConfig::default();
assert_eq!(config.strategy, "interactive");
assert!(config.auto_detect_after_run);
assert!(!config.auto_apply);
assert!(config.enabled_patterns.is_empty());
assert!(config.disabled_patterns.is_empty());
}
#[test]
fn test_suggest_config_parse() {
let toml = r#"
[suggest]
strategy = "high_perf"
auto_detect_after_run = false
auto_apply = false
disabled_patterns = ["Builder"]
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
assert_eq!(config.suggest.strategy, "high_perf");
assert!(!config.suggest.auto_detect_after_run);
assert_eq!(config.suggest.disabled_patterns, vec!["Builder"]);
}
#[test]
fn test_suggest_config_to_strategy() {
let config = SuggestConfig {
strategy: "interactive".to_string(),
..Default::default()
};
let _ = config.to_strategy();
let config = SuggestConfig {
strategy: "high_perf".to_string(),
..Default::default()
};
let _ = config.to_strategy();
let config = SuggestConfig {
strategy: "batch".to_string(),
..Default::default()
};
let _ = config.to_strategy();
}
#[test]
fn test_suggest_config_pattern_enabled() {
let config = SuggestConfig::default();
assert!(config.is_pattern_enabled("Default"));
assert!(config.is_pattern_enabled("Builder"));
let config = SuggestConfig {
enabled_patterns: vec!["Default".to_string()],
..Default::default()
};
assert!(config.is_pattern_enabled("Default"));
assert!(!config.is_pattern_enabled("Builder"));
let config = SuggestConfig {
disabled_patterns: vec!["Builder".to_string()],
..Default::default()
};
assert!(config.is_pattern_enabled("Default"));
assert!(!config.is_pattern_enabled("Builder"));
let config = SuggestConfig {
enabled_patterns: vec!["Default".to_string(), "Builder".to_string()],
disabled_patterns: vec!["Builder".to_string()],
..Default::default()
};
assert!(config.is_pattern_enabled("Default"));
assert!(!config.is_pattern_enabled("Builder"));
}
#[test]
fn test_suggest_config_rule_enabled() {
let config = SuggestConfig::default();
assert!(config.is_rule_enabled("RL001"));
assert!(config.is_rule_enabled("RL090"));
let config = SuggestConfig {
disabled_rules: vec!["RL001".to_string()],
..Default::default()
};
assert!(!config.is_rule_enabled("RL001"));
assert!(config.is_rule_enabled("RL002"));
let config = SuggestConfig {
disabled_rules: vec!["RL09*".to_string()],
..Default::default()
};
assert!(config.is_rule_enabled("RL001"));
assert!(!config.is_rule_enabled("RL090"));
assert!(!config.is_rule_enabled("RL091"));
let config = SuggestConfig {
enabled_rules: vec!["RL00*".to_string()],
..Default::default()
};
assert!(config.is_rule_enabled("RL001"));
assert!(config.is_rule_enabled("RL002"));
assert!(!config.is_rule_enabled("RL010"));
assert!(!config.is_rule_enabled("RL090"));
let config = SuggestConfig {
enabled_rules: vec!["RL00*".to_string()],
disabled_rules: vec!["RL002".to_string()],
..Default::default()
};
assert!(config.is_rule_enabled("RL001"));
assert!(!config.is_rule_enabled("RL002"));
assert!(!config.is_rule_enabled("RL010"));
}
#[test]
fn test_suggest_config_matches_pattern() {
assert!(SuggestConfig::matches_pattern("*", "anything"));
assert!(SuggestConfig::matches_pattern("RL*", "RL001"));
assert!(SuggestConfig::matches_pattern("RL*", "RL999"));
assert!(!SuggestConfig::matches_pattern("RL*", "XX001"));
assert!(SuggestConfig::matches_pattern("*001", "RL001"));
assert!(!SuggestConfig::matches_pattern("*001", "RL002"));
assert!(SuggestConfig::matches_pattern("RL001", "RL001"));
assert!(!SuggestConfig::matches_pattern("RL001", "RL002"));
}
#[test]
fn test_suggest_config_severity_overrides() {
let mut overrides = std::collections::HashMap::new();
overrides.insert("RL001".to_string(), "Error".to_string());
overrides.insert("RL090".to_string(), "Warning".to_string());
let config = SuggestConfig {
severity_overrides: overrides,
..Default::default()
};
assert_eq!(config.get_severity_override("RL001"), Some("Error"));
assert_eq!(config.get_severity_override("RL090"), Some("Warning"));
assert_eq!(config.get_severity_override("RL002"), None);
}
#[test]
fn test_suggest_config_severity_parse() {
let toml = r#"
[suggest]
severity_overrides = { "RL001" = "Error", "RL021" = "Info" }
"#;
let config: crate::config::RyoConfig = toml::from_str(toml).unwrap();
assert_eq!(config.suggest.get_severity_override("RL001"), Some("Error"));
assert_eq!(config.suggest.get_severity_override("RL021"), Some("Info"));
assert_eq!(config.suggest.get_severity_override("RL999"), None);
}
#[test]
fn test_matches_symbol_path_pattern_exact() {
assert!(RyoConfig::matches_symbol_path_pattern(
"my_crate::module::Symbol",
"my_crate::module::Symbol"
));
assert!(!RyoConfig::matches_symbol_path_pattern(
"my_crate::module::Symbol",
"my_crate::module::Other"
));
assert!(!RyoConfig::matches_symbol_path_pattern(
"my_crate::module",
"my_crate::module::Symbol"
));
}
#[test]
fn test_matches_symbol_path_pattern_trailing_wildcard() {
assert!(RyoConfig::matches_symbol_path_pattern(
"my_crate::generated::*",
"my_crate::generated::Foo"
));
assert!(RyoConfig::matches_symbol_path_pattern(
"my_crate::generated::*",
"my_crate::generated::sub::Bar"
));
assert!(!RyoConfig::matches_symbol_path_pattern(
"my_crate::generated::*",
"my_crate::other::Foo"
));
assert!(RyoConfig::matches_symbol_path_pattern(
"my_crate::*",
"my_crate::module::sub::Symbol"
));
}
#[test]
fn test_matches_symbol_path_pattern_segment_wildcard() {
assert!(RyoConfig::matches_symbol_path_pattern(
"*::tests::*",
"my_crate::tests::test_foo"
));
assert!(RyoConfig::matches_symbol_path_pattern(
"*::tests::*",
"other_crate::tests::test_bar"
));
assert!(!RyoConfig::matches_symbol_path_pattern(
"*::tests::*",
"my_crate::module::Symbol"
));
assert!(RyoConfig::matches_symbol_path_pattern(
"*::generated::Foo",
"any_crate::generated::Foo"
));
}
#[test]
fn test_get_module_config_for_symbol_exact() {
let toml = r#"
[modules."my_crate::generated"]
skip_lint = true
disabled_rules = ["RL001"]
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
let module_config = config.get_module_config_for_symbol("my_crate::generated");
assert!(module_config.is_some());
assert!(module_config.unwrap().skip_lint);
let no_match = config.get_module_config_for_symbol("my_crate::generated::Foo");
assert!(no_match.is_none());
}
#[test]
fn test_get_module_config_for_symbol_with_wildcard() {
let toml = r#"
[modules."my_crate::generated::*"]
skip_lint = true
disabled_rules = ["RL090", "RL091"]
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
let module_config = config.get_module_config_for_symbol("my_crate::generated::Foo");
assert!(module_config.is_some());
assert!(module_config.unwrap().skip_lint);
let deep = config.get_module_config_for_symbol("my_crate::generated::sub::Bar");
assert!(deep.is_some());
let no_match = config.get_module_config_for_symbol("my_crate::other::Foo");
assert!(no_match.is_none());
}
#[test]
fn test_get_module_config_file_path_vs_symbol_path() {
let toml = r#"
[modules."src/generated"]
skip_lint = true
[modules."my_crate::generated::*"]
skip_refactor = true
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
let file_config = config.get_module_config("src/generated/foo.rs");
assert!(file_config.is_some());
assert!(file_config.unwrap().skip_lint);
assert!(!file_config.unwrap().skip_refactor);
let symbol_config = config.get_module_config_for_symbol("my_crate::generated::Foo");
assert!(symbol_config.is_some());
assert!(symbol_config.unwrap().skip_refactor);
assert!(!symbol_config.unwrap().skip_lint);
let no_file_match = config.get_module_config("my_crate::generated::Foo");
assert!(no_file_match.is_none());
let no_symbol_match = config.get_module_config_for_symbol("src/generated/foo.rs");
assert!(no_symbol_match.is_none());
}
#[test]
fn test_is_rule_enabled_for_symbol_module_override() {
let toml = r#"
[suggest]
disabled_rules = ["RL001"]
[modules."my_crate::generated::*"]
disabled_rules = ["RL090", "RL091"]
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
assert!(!config.is_rule_enabled_for_symbol("any::path", "RL001"));
assert!(!config.is_rule_enabled_for_symbol("my_crate::generated::Foo", "RL090"));
assert!(!config.is_rule_enabled_for_symbol("my_crate::generated::Foo", "RL091"));
assert!(config.is_rule_enabled_for_symbol("my_crate::generated::Foo", "RL002"));
assert!(config.is_rule_enabled_for_symbol("my_crate::other::Bar", "RL090"));
}
#[test]
fn test_is_rule_enabled_for_symbol_with_enabled_rules() {
let toml = r#"
[modules."my_crate::special::*"]
enabled_rules = ["RL001", "RL002"]
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
assert!(config.is_rule_enabled_for_symbol("my_crate::special::Foo", "RL001"));
assert!(config.is_rule_enabled_for_symbol("my_crate::special::Foo", "RL002"));
assert!(!config.is_rule_enabled_for_symbol("my_crate::special::Foo", "RL003"));
assert!(config.is_rule_enabled_for_symbol("my_crate::other::Bar", "RL003"));
}
#[test]
fn test_module_config_disabled_rules_with_wildcard() {
let toml = r#"
[modules."*::tests::*"]
disabled_rules = ["RL*"]
"#;
let config: RyoConfig = toml::from_str(toml).unwrap();
assert!(!config.is_rule_enabled_for_symbol("my_crate::tests::test_foo", "RL001"));
assert!(!config.is_rule_enabled_for_symbol("other::tests::test_bar", "RL999"));
assert!(config.is_rule_enabled_for_symbol("my_crate::lib::Foo", "RL001"));
}
}