use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub struct KimunConfig {
#[serde(default)]
pub smells: SmellsConfig,
#[serde(default)]
pub dups: DupsConfig,
#[serde(default)]
pub score: ScoreConfig,
#[serde(default)]
pub age: AgeConfig,
#[serde(default)]
pub tc: TcConfig,
#[serde(default)]
pub hotspots: HotspotsConfig,
#[serde(default)]
pub impact: ImpactConfig,
}
#[derive(Debug, Default, Deserialize)]
pub struct SmellsConfig {
pub max_lines: Option<usize>,
pub max_params: Option<usize>,
}
impl SmellsConfig {
pub const DEFAULT_MAX_LINES: usize = 50;
pub const DEFAULT_MAX_PARAMS: usize = 4;
pub fn resolve_max_lines(&self, cli: Option<usize>) -> usize {
cli.or(self.max_lines).unwrap_or(Self::DEFAULT_MAX_LINES)
}
pub fn resolve_max_params(&self, cli: Option<usize>) -> usize {
cli.or(self.max_params).unwrap_or(Self::DEFAULT_MAX_PARAMS)
}
}
#[derive(Debug, Default, Deserialize)]
pub struct DupsConfig {
pub min_lines: Option<usize>,
pub max_duplicates: Option<usize>,
pub max_dup_ratio: Option<f64>,
}
impl DupsConfig {
pub const DEFAULT_MIN_LINES: usize = 6;
pub fn resolve_min_lines(&self, cli: Option<usize>) -> usize {
cli.or(self.min_lines).unwrap_or(Self::DEFAULT_MIN_LINES)
}
pub fn resolve_max_duplicates(&self, cli: Option<usize>) -> Option<usize> {
cli.or(self.max_duplicates)
}
pub fn resolve_max_dup_ratio(&self, cli: Option<f64>) -> Option<f64> {
cli.or(self.max_dup_ratio)
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ScoreConfig {
pub model: Option<String>,
pub fail_below: Option<String>,
}
impl ScoreConfig {
pub const DEFAULT_MODEL: &'static str = "cogcom";
pub fn resolve_model(&self, cli: Option<String>) -> String {
cli.or_else(|| self.model.clone())
.unwrap_or_else(|| Self::DEFAULT_MODEL.to_string())
}
pub fn resolve_fail_below(&self, cli: Option<String>) -> Option<String> {
cli.or_else(|| self.fail_below.clone())
}
}
#[derive(Debug, Default, Deserialize)]
pub struct AgeConfig {
pub active_days: Option<u64>,
pub frozen_days: Option<u64>,
}
impl AgeConfig {
pub const DEFAULT_ACTIVE_DAYS: u64 = 90;
pub const DEFAULT_FROZEN_DAYS: u64 = 365;
pub fn resolve_active_days(&self, cli: Option<u64>) -> u64 {
cli.or(self.active_days)
.unwrap_or(Self::DEFAULT_ACTIVE_DAYS)
}
pub fn resolve_frozen_days(&self, cli: Option<u64>) -> u64 {
cli.or(self.frozen_days)
.unwrap_or(Self::DEFAULT_FROZEN_DAYS)
}
}
#[derive(Debug, Default, Deserialize)]
pub struct TcConfig {
pub min_degree: Option<usize>,
pub min_strength: Option<f64>,
}
impl TcConfig {
pub const DEFAULT_MIN_DEGREE: usize = 3;
pub fn resolve_min_degree(&self, cli: Option<usize>) -> usize {
cli.or(self.min_degree).unwrap_or(Self::DEFAULT_MIN_DEGREE)
}
pub fn resolve_min_strength(&self, cli: Option<f64>) -> Option<f64> {
cli.or(self.min_strength)
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ImpactConfig {
#[serde(default)]
pub inert: Vec<String>,
#[serde(default)]
pub entry_points: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct HotspotsConfig {
pub complexity: Option<String>,
}
impl HotspotsConfig {
pub const DEFAULT_COMPLEXITY: &'static str = "indent";
pub fn resolve_complexity(&self, cli: Option<String>) -> String {
cli.or_else(|| self.complexity.clone())
.unwrap_or_else(|| Self::DEFAULT_COMPLEXITY.to_string())
}
}
impl KimunConfig {
pub fn load() -> Self {
Self::try_load().unwrap_or_default()
}
pub fn load_from(root: &std::path::Path) -> Self {
std::fs::read_to_string(root.join(".kimun.toml"))
.ok()
.and_then(|content| toml::from_str(&content).ok())
.unwrap_or_default()
}
fn try_load() -> Option<Self> {
let path = Self::find()?;
let content = std::fs::read_to_string(path).ok()?;
toml::from_str(&content).ok()
}
fn find() -> Option<std::path::PathBuf> {
if let Ok(repo) = git2::Repository::discover(".")
&& let Some(workdir) = repo.workdir()
{
let candidate = workdir.join(".kimun.toml");
if candidate.exists() {
return Some(candidate);
}
}
let candidate = std::path::PathBuf::from(".kimun.toml");
candidate.exists().then_some(candidate)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(toml: &str) -> KimunConfig {
toml::from_str(toml).expect("valid toml")
}
#[test]
fn defaults_apply_when_no_config() {
let cfg = KimunConfig::default();
assert_eq!(
cfg.smells.resolve_max_lines(None),
SmellsConfig::DEFAULT_MAX_LINES
);
assert_eq!(
cfg.smells.resolve_max_params(None),
SmellsConfig::DEFAULT_MAX_PARAMS
);
assert_eq!(
cfg.dups.resolve_min_lines(None),
DupsConfig::DEFAULT_MIN_LINES
);
assert_eq!(cfg.score.resolve_model(None), ScoreConfig::DEFAULT_MODEL);
assert_eq!(
cfg.age.resolve_active_days(None),
AgeConfig::DEFAULT_ACTIVE_DAYS
);
assert_eq!(
cfg.age.resolve_frozen_days(None),
AgeConfig::DEFAULT_FROZEN_DAYS
);
assert_eq!(
cfg.tc.resolve_min_degree(None),
TcConfig::DEFAULT_MIN_DEGREE
);
assert_eq!(
cfg.hotspots.resolve_complexity(None),
HotspotsConfig::DEFAULT_COMPLEXITY
);
}
#[test]
fn optional_gates_are_none_by_default() {
let cfg = KimunConfig::default();
assert!(cfg.dups.resolve_max_duplicates(None).is_none());
assert!(cfg.dups.resolve_max_dup_ratio(None).is_none());
assert!(cfg.score.resolve_fail_below(None).is_none());
assert!(cfg.tc.resolve_min_strength(None).is_none());
}
#[test]
fn smells_config_is_parsed() {
let cfg = parse("[smells]\nmax_lines = 30\nmax_params = 3\n");
assert_eq!(cfg.smells.resolve_max_lines(None), 30);
assert_eq!(cfg.smells.resolve_max_params(None), 3);
}
#[test]
fn dups_config_is_parsed() {
let cfg = parse("[dups]\nmin_lines = 8\nmax_duplicates = 10\nmax_dup_ratio = 5.0\n");
assert_eq!(cfg.dups.resolve_min_lines(None), 8);
assert_eq!(cfg.dups.resolve_max_duplicates(None), Some(10));
assert_eq!(cfg.dups.resolve_max_dup_ratio(None), Some(5.0));
}
#[test]
fn score_config_is_parsed() {
let cfg = parse("[score]\nmodel = \"legacy\"\nfail_below = \"B-\"\n");
assert_eq!(cfg.score.resolve_model(None), "legacy");
assert_eq!(cfg.score.resolve_fail_below(None).as_deref(), Some("B-"));
}
#[test]
fn age_config_is_parsed() {
let cfg = parse("[age]\nactive_days = 60\nfrozen_days = 180\n");
assert_eq!(cfg.age.resolve_active_days(None), 60);
assert_eq!(cfg.age.resolve_frozen_days(None), 180);
}
#[test]
fn tc_config_is_parsed() {
let cfg = parse("[tc]\nmin_degree = 5\nmin_strength = 0.5\n");
assert_eq!(cfg.tc.resolve_min_degree(None), 5);
assert_eq!(cfg.tc.resolve_min_strength(None), Some(0.5));
}
#[test]
fn hotspots_config_is_parsed() {
let cfg = parse("[hotspots]\ncomplexity = \"cogcom\"\n");
assert_eq!(cfg.hotspots.resolve_complexity(None), "cogcom");
}
#[test]
fn cli_overrides_smells_config() {
let cfg = parse("[smells]\nmax_lines = 30\nmax_params = 3\n");
assert_eq!(cfg.smells.resolve_max_lines(Some(100)), 100);
assert_eq!(cfg.smells.resolve_max_params(Some(10)), 10);
}
#[test]
fn cli_overrides_dups_config() {
let cfg = parse("[dups]\nmin_lines = 8\nmax_duplicates = 10\nmax_dup_ratio = 5.0\n");
assert_eq!(cfg.dups.resolve_min_lines(Some(4)), 4);
assert_eq!(cfg.dups.resolve_max_duplicates(Some(99)), Some(99));
assert_eq!(cfg.dups.resolve_max_dup_ratio(Some(1.0)), Some(1.0));
}
#[test]
fn cli_overrides_score_config() {
let cfg = parse("[score]\nmodel = \"legacy\"\nfail_below = \"B-\"\n");
assert_eq!(cfg.score.resolve_model(Some("cogcom".into())), "cogcom");
assert_eq!(
cfg.score.resolve_fail_below(Some("A".into())).as_deref(),
Some("A")
);
}
#[test]
fn cli_overrides_age_config() {
let cfg = parse("[age]\nactive_days = 60\nfrozen_days = 180\n");
assert_eq!(cfg.age.resolve_active_days(Some(30)), 30);
assert_eq!(cfg.age.resolve_frozen_days(Some(730)), 730);
}
#[test]
fn cli_overrides_tc_config() {
let cfg = parse("[tc]\nmin_degree = 5\nmin_strength = 0.5\n");
assert_eq!(cfg.tc.resolve_min_degree(Some(1)), 1);
assert_eq!(cfg.tc.resolve_min_strength(Some(0.9)), Some(0.9));
}
#[test]
fn cli_overrides_hotspots_config() {
let cfg = parse("[hotspots]\ncomplexity = \"cogcom\"\n");
assert_eq!(
cfg.hotspots.resolve_complexity(Some("cycom".into())),
"cycom"
);
}
#[test]
fn partial_smells_config_uses_defaults_for_missing_fields() {
let cfg = parse("[smells]\nmax_lines = 20\n");
assert_eq!(cfg.smells.resolve_max_lines(None), 20);
assert_eq!(
cfg.smells.resolve_max_params(None),
SmellsConfig::DEFAULT_MAX_PARAMS
);
}
#[test]
fn empty_config_uses_all_defaults() {
let cfg = parse("");
assert_eq!(
cfg.smells.resolve_max_lines(None),
SmellsConfig::DEFAULT_MAX_LINES
);
assert_eq!(
cfg.dups.resolve_min_lines(None),
DupsConfig::DEFAULT_MIN_LINES
);
assert_eq!(cfg.score.resolve_model(None), ScoreConfig::DEFAULT_MODEL);
assert!(cfg.score.resolve_fail_below(None).is_none());
}
#[test]
fn load_from_file() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".kimun.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "[smells]\nmax_lines = 25\nmax_params = 2").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let cfg: KimunConfig = toml::from_str(&content).unwrap();
assert_eq!(cfg.smells.resolve_max_lines(None), 25);
assert_eq!(cfg.smells.resolve_max_params(None), 2);
}
#[test]
fn invalid_toml_falls_back_to_defaults() {
let result: Result<KimunConfig, _> = toml::from_str("not valid toml ][");
assert!(result.is_err());
let cfg = KimunConfig::default();
assert_eq!(
cfg.smells.resolve_max_lines(None),
SmellsConfig::DEFAULT_MAX_LINES
);
}
}