use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Strictness {
Named(StrictnessLevel),
Custom(f64),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StrictnessLevel {
Relaxed,
Default,
Strict,
}
impl Default for Strictness {
fn default() -> Self {
Strictness::Named(StrictnessLevel::Default)
}
}
impl Strictness {
pub fn factor(&self) -> f64 {
match self {
Strictness::Named(StrictnessLevel::Relaxed) => 2.0,
Strictness::Named(StrictnessLevel::Default) => 1.0,
Strictness::Named(StrictnessLevel::Strict) => 0.5,
Strictness::Custom(v) => *v,
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"relaxed" => Some(Strictness::Named(StrictnessLevel::Relaxed)),
"default" => Some(Strictness::Named(StrictnessLevel::Default)),
"strict" => Some(Strictness::Named(StrictnessLevel::Strict)),
_ => s.parse::<f64>().ok().map(Strictness::Custom),
}
}
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct LanguageConfig {
#[serde(default)]
pub plugins: HashMap<String, PluginConfig>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub plugins: HashMap<String, PluginConfig>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub debt_weights: DebtWeights,
#[serde(default)]
pub strictness: Strictness,
#[serde(default)]
pub languages: HashMap<String, LanguageConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DebtWeights {
#[serde(default = "default_hint_debt")]
pub hint: u32,
#[serde(default = "default_warning_debt")]
pub warning: u32,
#[serde(default = "default_error_debt")]
pub error: u32,
}
fn default_hint_debt() -> u32 {
5
}
fn default_warning_debt() -> u32 {
15
}
fn default_error_debt() -> u32 {
30
}
impl Default for DebtWeights {
fn default() -> Self {
Self {
hint: 5,
warning: 15,
error: 30,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct PluginConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(flatten)]
pub options: HashMap<String, toml::Value>,
}
fn default_true() -> bool {
true
}
impl Default for PluginConfig {
fn default() -> Self {
Self {
enabled: true,
options: HashMap::new(),
}
}
}
impl Config {
pub fn load(dir: &Path) -> Self {
let path = dir.join(".cha.toml");
match std::fs::read_to_string(&path) {
Ok(content) => toml::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn load_for_file(file_path: &Path, project_root: &Path) -> Self {
let abs_file = std::fs::canonicalize(file_path).unwrap_or(file_path.to_path_buf());
let abs_root = std::fs::canonicalize(project_root).unwrap_or(project_root.to_path_buf());
let dir = abs_file.parent().unwrap_or(&abs_root);
let mut configs = collect_configs_upward(dir, &abs_root);
configs.reverse();
let mut merged = Config::default();
for cfg in configs {
merged.merge(cfg);
}
merged
}
pub fn merge(&mut self, other: Config) {
for (name, other_pc) in other.plugins {
let entry = self.plugins.entry(name).or_default();
entry.enabled = other_pc.enabled;
for (k, v) in other_pc.options {
entry.options.insert(k, v);
}
}
self.exclude.extend(other.exclude);
self.debt_weights = other.debt_weights;
self.strictness = other.strictness;
for (lang, other_lc) in other.languages {
let entry = self.languages.entry(lang).or_default();
for (name, other_pc) in other_lc.plugins {
let pe = entry.plugins.entry(name).or_default();
pe.enabled = other_pc.enabled;
for (k, v) in other_pc.options {
pe.options.insert(k, v);
}
}
}
}
pub fn resolve_for_language(&self, language: &str) -> Config {
let lang_key = language.to_lowercase();
let mut resolved = self.clone();
if let Some(builtin) = builtin_language_profile(&lang_key) {
for (name, enabled) in builtin {
let user_override = self
.languages
.get(&lang_key)
.is_some_and(|lc| lc.plugins.contains_key(name));
if !user_override {
resolved
.plugins
.entry(name.to_string())
.or_default()
.enabled = enabled;
}
}
}
if let Some(lc) = self.languages.get(&lang_key) {
for (name, lpc) in &lc.plugins {
let entry = resolved.plugins.entry(name.clone()).or_default();
entry.enabled = lpc.enabled;
for (k, v) in &lpc.options {
entry.options.insert(k.clone(), v.clone());
}
}
}
resolved
}
pub fn is_enabled(&self, name: &str) -> bool {
self.plugins.get(name).is_none_or(|c| c.enabled)
}
pub fn get_usize(&self, plugin: &str, key: &str) -> Option<usize> {
self.plugins
.get(plugin)?
.options
.get(key)?
.as_integer()
.map(|v| {
let scaled = (v as f64 * self.strictness.factor()).round() as usize;
scaled.max(1)
})
}
pub fn get_str(&self, plugin: &str, key: &str) -> Option<String> {
self.plugins
.get(plugin)?
.options
.get(key)?
.as_str()
.map(|s| s.to_string())
}
pub fn set_strictness(&mut self, s: Strictness) {
self.strictness = s;
}
}
fn collect_configs_upward(start_dir: &Path, root: &Path) -> Vec<Config> {
let mut configs = Vec::new();
let mut current = start_dir.to_path_buf();
loop {
let cfg_path = current.join(".cha.toml");
if cfg_path.is_file()
&& let Ok(content) = std::fs::read_to_string(&cfg_path)
&& let Ok(cfg) = toml::from_str::<Config>(&content)
{
configs.push(cfg);
}
if current == root {
break;
}
match current.parent() {
Some(p) if p.starts_with(root) || p == root => current = p.to_path_buf(),
_ => break,
}
}
configs
}
pub fn builtin_language_profile(language: &str) -> Option<Vec<(&'static str, bool)>> {
match language {
"c" => Some(vec![
("naming", false),
("lazy_class", false),
("data_class", false),
("builder_pattern", false),
("null_object_pattern", false),
("strategy_pattern", false),
]),
_ => None,
}
}