use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::{CodeLoreError, Result};
pub const THRESHOLDS_FILENAME: &str = ".codelore-thresholds.toml";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Thresholds {
#[serde(default)]
pub gates: Gates,
#[serde(default)]
pub diff: DiffGates,
#[serde(default)]
pub calibration: CalibrationConfig,
#[serde(default)]
pub new_code: Option<NewCodeGates>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(clippy::struct_excessive_bools)] pub struct Gates {
pub cognitive_max: Option<f64>,
pub code_health_min: Option<f64>,
pub hotspot_score_max: Option<f64>,
pub hotspot_anchored_max: Option<f64>,
#[serde(default)]
pub disallow_clone_type_1: bool,
pub max_dependency_cycles: Option<u32>,
pub max_propagation_cost: Option<f64>,
pub max_red_effort_pct: Option<f64>,
#[serde(default)]
pub red_effort_exempt_improving: bool,
pub code_familiarity_min: Option<f64>,
pub max_findings_in_hot_files: Option<u32>,
pub corpus_percentile_max: Option<f64>,
#[serde(default = "default_fail_on_degraded")]
pub fail_on_degraded: bool,
#[serde(default)]
pub fail_on_skipped: bool,
}
fn default_fail_on_degraded() -> bool {
true
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DiffGates {
pub delta_code_health_min: Option<f64>,
pub new_hotspot_max: Option<u32>,
#[serde(default)]
pub no_new_cycles: bool,
pub delta_health_min: Option<f64>,
#[serde(default)]
pub deny_degrading_verdict: bool,
pub delta_code_health_min_per_file: Option<f64>,
pub new_file_health_min: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NewCodeGates {
#[serde(default = "default_new_code_window_days")]
pub window_days: u32,
pub born_health_min: Option<f64>,
#[serde(default = "default_touched_no_degradation")]
pub touched_no_degradation: bool,
}
fn default_new_code_window_days() -> u32 {
crate::constants::DEFAULT_WINDOW_DAYS
}
fn default_touched_no_degradation() -> bool {
true
}
impl Default for NewCodeGates {
fn default() -> Self {
Self {
window_days: default_new_code_window_days(),
born_health_min: None,
touched_no_degradation: default_touched_no_degradation(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CalibrationConfig {
pub defect_artifact: Option<PathBuf>,
}
fn finite_in(problems: &mut Vec<String>, key: &str, value: Option<f64>, lo: f64, hi: f64) {
let Some(v) = value else { return };
if !v.is_finite() {
problems.push(format!("{key} = {v} must be a finite number"));
} else if !(lo..=hi).contains(&v) {
problems.push(format!(
"{key} = {v} is outside the accepted range [{lo}, {hi}]"
));
}
}
fn finite_min(problems: &mut Vec<String>, key: &str, value: Option<f64>, min: f64) {
let Some(v) = value else { return };
if !v.is_finite() {
problems.push(format!("{key} = {v} must be a finite number"));
} else if v < min {
problems.push(format!("{key} = {v} must be >= {min}"));
}
}
impl Thresholds {
pub fn discover(repo_root: &Path) -> Result<Self> {
let path = repo_root.join(THRESHOLDS_FILENAME);
if !path.exists() {
return Ok(Self::default());
}
Self::from_path(&path)
}
pub fn from_path(path: &Path) -> Result<Self> {
let raw = fs::read_to_string(path).map_err(|e| {
CodeLoreError::RepoIo(std::io::Error::new(
e.kind(),
format!("read thresholds {}: {e}", path.display()),
))
})?;
let thresholds = Self::from_text(&raw).map_err(|e| {
CodeLoreError::Analysis(format!("parse thresholds {}: {e}", path.display()))
})?;
thresholds.validate().map_err(|e| {
CodeLoreError::InvalidOptions(format!("thresholds {}: {e}", path.display()))
})?;
Ok(thresholds)
}
pub fn from_text(raw: &str) -> std::result::Result<Self, String> {
toml::from_str(raw).map_err(|e| e.to_string())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.gates.cognitive_max.is_none()
&& self.gates.code_health_min.is_none()
&& self.gates.hotspot_score_max.is_none()
&& self.gates.hotspot_anchored_max.is_none()
&& !self.gates.disallow_clone_type_1
&& self.gates.max_dependency_cycles.is_none()
&& self.gates.max_propagation_cost.is_none()
&& self.gates.max_red_effort_pct.is_none()
&& self.gates.code_familiarity_min.is_none()
&& self.gates.max_findings_in_hot_files.is_none()
&& self.gates.corpus_percentile_max.is_none()
&& self.diff.delta_code_health_min.is_none()
&& self.diff.new_hotspot_max.is_none()
&& !self.diff.no_new_cycles
&& self.diff.delta_health_min.is_none()
&& !self.diff.deny_degrading_verdict
&& self.diff.delta_code_health_min_per_file.is_none()
&& self.diff.new_file_health_min.is_none()
&& self.new_code.is_none()
}
pub fn validate(&self) -> std::result::Result<(), String> {
let mut problems = Vec::new();
let g = &self.gates;
finite_min(&mut problems, "cognitive_max", g.cognitive_max, 0.0);
finite_in(
&mut problems,
"code_health_min",
g.code_health_min,
0.0,
100.0,
);
finite_min(&mut problems, "hotspot_score_max", g.hotspot_score_max, 0.0);
finite_min(
&mut problems,
"hotspot_anchored_max",
g.hotspot_anchored_max,
0.0,
);
finite_in(
&mut problems,
"max_propagation_cost",
g.max_propagation_cost,
0.0,
1.0,
);
finite_in(
&mut problems,
"max_red_effort_pct",
g.max_red_effort_pct,
0.0,
100.0,
);
finite_in(
&mut problems,
"code_familiarity_min",
g.code_familiarity_min,
0.0,
100.0,
);
finite_in(
&mut problems,
"corpus_percentile_max",
g.corpus_percentile_max,
0.0,
1.0,
);
let d = &self.diff;
finite_in(
&mut problems,
"delta_code_health_min",
d.delta_code_health_min,
-100.0,
100.0,
);
finite_in(
&mut problems,
"delta_health_min",
d.delta_health_min,
0.0,
100.0,
);
finite_in(
&mut problems,
"delta_code_health_min_per_file",
d.delta_code_health_min_per_file,
-100.0,
100.0,
);
finite_in(
&mut problems,
"new_file_health_min",
d.new_file_health_min,
0.0,
100.0,
);
if let Some(nc) = &self.new_code {
if !(7..=365).contains(&nc.window_days) {
problems.push(format!(
"window_days = {} is outside the accepted range [7, 365]",
nc.window_days
));
}
finite_in(
&mut problems,
"born_health_min",
nc.born_health_min,
0.0,
100.0,
);
}
if problems.is_empty() {
Ok(())
} else {
Err(problems.join("; "))
}
}
}
pub fn resolve_defect_calibration(
cli_flag: Option<PathBuf>,
repo_root: &Path,
) -> Result<Option<PathBuf>> {
if cli_flag.is_some() {
return Ok(cli_flag);
}
let thresholds = Thresholds::discover(repo_root)?;
Ok(thresholds.calibration.defect_artifact.map(|p| {
if p.is_absolute() {
p
} else {
repo_root.join(p)
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_text_yields_default() {
let t = Thresholds::from_text("").unwrap();
assert!(t.is_empty());
}
#[test]
fn unknown_key_at_root_is_rejected() {
let raw = "[gate]\ncognitive_max = 30\n";
let err = Thresholds::from_text(raw).expect_err("typo'd table should reject");
assert!(
err.contains("unknown field") || err.contains("gate"),
"expected 'unknown field' in error, got: {err}"
);
}
#[test]
fn unknown_key_in_gates_is_rejected() {
let raw = "[gates]\ncognative_max = 30\n";
let err = Thresholds::from_text(raw).expect_err("typo'd gate key should reject");
assert!(
err.contains("unknown field") || err.contains("cognative"),
"expected 'unknown field' in error, got: {err}"
);
}
#[test]
fn unknown_key_in_diff_is_rejected() {
let raw = "[diff]\nnew_hotspot_maximum = 5\n";
let err = Thresholds::from_text(raw).expect_err("typo'd diff key should reject");
assert!(
err.contains("unknown field") || err.contains("new_hotspot_maximum"),
"expected 'unknown field' in error, got: {err}"
);
}
#[test]
fn parses_full_gate_set() {
let raw = r"
[gates]
cognitive_max = 30
code_health_min = 60
hotspot_score_max = 8.0
disallow_clone_type_1 = true
[diff]
delta_code_health_min = -5
new_hotspot_max = 0
";
let t = Thresholds::from_text(raw).unwrap();
assert_eq!(t.gates.cognitive_max, Some(30.0));
assert_eq!(t.gates.code_health_min, Some(60.0));
assert_eq!(t.gates.hotspot_score_max, Some(8.0));
assert!(t.gates.disallow_clone_type_1);
assert_eq!(t.diff.delta_code_health_min, Some(-5.0));
assert_eq!(t.diff.new_hotspot_max, Some(0));
assert!(!t.is_empty());
}
#[test]
fn parses_corpus_percentile_max_gate() {
let raw = "[gates]\ncorpus_percentile_max = 0.9\n";
let t = Thresholds::from_text(raw).unwrap();
assert_eq!(t.gates.corpus_percentile_max, Some(0.9));
assert!(!t.is_empty());
}
#[test]
fn unknown_corpus_gate_key_is_rejected() {
let raw = "[gates]\ncorpus_percentile_maximum = 0.9\n";
let err = Thresholds::from_text(raw).expect_err("typo'd corpus gate key should reject");
assert!(
err.contains("unknown field") || err.contains("corpus_percentile_maximum"),
"expected 'unknown field' in error, got: {err}"
);
}
#[test]
fn is_empty_accounts_for_delta_health_keys() {
let t = Thresholds::from_text("[diff]\ndelta_health_min = 50.0\n").unwrap();
assert!(!t.is_empty());
let t = Thresholds::from_text("[diff]\ndeny_degrading_verdict = true\n").unwrap();
assert!(!t.is_empty());
}
#[test]
fn per_file_floor_key_parses_and_makes_thresholds_non_empty() {
let t = Thresholds::from_text("[diff]\ndelta_code_health_min_per_file = 0.0\n").unwrap();
assert_eq!(t.diff.delta_code_health_min_per_file, Some(0.0));
assert!(!t.is_empty());
}
#[test]
fn per_file_floor_unknown_key_rejected() {
let raw = "[diff]\ndelta_code_health_min_per_flie = 0.0\n";
let err = Thresholds::from_text(raw).expect_err("typo'd key should reject");
assert!(
err.contains("unknown field") || err.contains("delta_code_health_min_per_flie"),
"expected 'unknown field' in error: {err}"
);
}
#[test]
fn new_file_floor_key_parses_and_makes_thresholds_non_empty() {
let t = Thresholds::from_text("[diff]\nnew_file_health_min = 50.0\n").unwrap();
assert_eq!(t.diff.new_file_health_min, Some(50.0));
assert!(!t.is_empty());
}
#[test]
fn new_file_floor_unknown_key_rejected() {
let raw = "[diff]\nnew_file_health_minimum = 50.0\n";
let err = Thresholds::from_text(raw).expect_err("typo'd key should reject");
assert!(
err.contains("unknown field") || err.contains("new_file_health_minimum"),
"expected 'unknown field' in error: {err}"
);
}
#[test]
fn effort_exposure_unknown_key_rejected_by_deny_unknown_fields() {
let raw = "[gates]\nmax_red_effort_percentage = 30.0\n";
let err = Thresholds::from_text(raw).expect_err("typo'd key should reject");
assert!(
err.contains("unknown field") || err.contains("max_red_effort"),
"expected 'unknown field' in error: {err}"
);
}
#[test]
fn red_effort_exempt_improving_parses_and_defaults_false() {
let on = Thresholds::from_text("[gates]\nred_effort_exempt_improving = true\n").unwrap();
assert!(on.gates.red_effort_exempt_improving);
let off = Thresholds::from_text("[gates]\nmax_red_effort_pct = 15.0\n").unwrap();
assert!(!off.gates.red_effort_exempt_improving);
}
#[test]
fn red_effort_exempt_improving_alone_does_not_configure_a_gate() {
let t = Thresholds::from_text("[gates]\nred_effort_exempt_improving = true\n").unwrap();
assert!(t.is_empty(), "the modifier alone configures no gate");
t.validate().expect("bool modifier is always valid");
}
#[test]
fn red_effort_exempt_improving_unknown_key_rejected() {
let raw = "[gates]\nred_effort_exempt_improveing = true\n";
let err = Thresholds::from_text(raw).expect_err("typo'd key should reject");
assert!(
err.contains("unknown field") || err.contains("red_effort_exempt_improveing"),
"expected 'unknown field' in error: {err}"
);
}
#[test]
fn finding_overlap_gate_toml_key_parses() {
let t = Thresholds::from_text("[gates]\nmax_findings_in_hot_files = 3\n").unwrap();
assert_eq!(t.gates.max_findings_in_hot_files, Some(3));
assert!(!t.is_empty());
}
#[test]
fn finding_overlap_gate_unknown_key_rejected() {
let raw = "[gates]\nmax_findings_in_hot_file = 3\n";
let err = Thresholds::from_text(raw).expect_err("typo'd key should reject");
assert!(
err.contains("unknown field") || err.contains("max_findings_in_hot_file"),
"expected 'unknown field' in error: {err}"
);
}
#[test]
fn fail_on_degraded_false_parses() {
let t = Thresholds::from_text("[gates]\nfail_on_degraded = false\n").unwrap();
assert!(!t.gates.fail_on_degraded);
}
#[test]
fn fail_on_degraded_defaults_to_true_when_omitted() {
let t = Thresholds::from_text("[gates]\ncode_health_min = 50.0\n").unwrap();
assert!(t.gates.fail_on_degraded);
}
#[test]
fn fail_on_skipped_parses_and_defaults_false() {
let on = Thresholds::from_text("[gates]\nfail_on_skipped = true\n").unwrap();
assert!(on.gates.fail_on_skipped);
let off = Thresholds::from_text("[gates]\ncode_health_min = 50.0\n").unwrap();
assert!(!off.gates.fail_on_skipped);
}
#[test]
fn fail_on_skipped_alone_does_not_configure_a_gate() {
let t = Thresholds::from_text("[gates]\nfail_on_skipped = true\n").unwrap();
assert!(t.is_empty(), "the modifier alone configures no gate");
t.validate().expect("bool modifier is always valid");
}
#[test]
fn fail_on_skipped_unknown_key_rejected() {
let raw = "[gates]\nfail_on_skiped = true\n";
let err = Thresholds::from_text(raw).expect_err("typo'd key should reject");
assert!(
err.contains("unknown field") || err.contains("fail_on_skiped"),
"expected 'unknown field' in error: {err}"
);
}
#[test]
fn calibration_section_parses_and_does_not_make_thresholds_non_empty() {
let t = Thresholds::from_text(
"[calibration]\ndefect_artifact = \"artifacts/defects.calib.json\"\n",
)
.expect("parse");
assert_eq!(
t.calibration.defect_artifact.as_deref(),
Some(std::path::Path::new("artifacts/defects.calib.json"))
);
assert!(t.is_empty(), "calibration alone must not enable gates");
}
#[test]
fn calibration_section_rejects_unknown_keys() {
let err = Thresholds::from_text("[calibration]\ndefect_artefact = \"x\"\n");
assert!(err.is_err(), "deny_unknown_fields must reject the typo");
}
#[cfg(feature = "test-support")]
#[test]
fn resolve_defect_calibration_prefers_cli_flag_over_section() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(THRESHOLDS_FILENAME),
"[calibration]\ndefect_artifact = \"from-section.json\"\n",
)
.expect("write thresholds");
let cli = Some(PathBuf::from("/explicit/flag.json"));
let resolved = resolve_defect_calibration(cli.clone(), dir.path()).expect("resolve");
assert_eq!(resolved, cli, "CLI flag wins");
let fallback = resolve_defect_calibration(None, dir.path()).expect("resolve");
assert_eq!(
fallback,
Some(dir.path().join("from-section.json")),
"section fills None, relative path joined to repo root"
);
}
#[cfg(feature = "test-support")]
#[test]
fn resolve_defect_calibration_without_section_is_none() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
resolve_defect_calibration(None, dir.path()).expect("resolve"),
None
);
}
#[test]
fn validate_accepts_default_and_empty_config() {
Thresholds::default().validate().expect("default is valid");
Thresholds::from_text("")
.unwrap()
.validate()
.expect("empty config is valid");
}
#[test]
fn validate_accepts_repo_own_threshold_shape() {
let raw = "\
[gates]
code_health_min = 35.0
cognitive_max = 150.0
hotspot_score_max = 4.0
max_dependency_cycles = 1
max_propagation_cost = 0.10
max_red_effort_pct = 15.0
[diff]
no_new_cycles = true
";
Thresholds::from_text(raw)
.expect("parse")
.validate()
.expect("the repo's own thresholds must validate");
}
#[test]
fn validate_accepts_negative_delta_floor() {
Thresholds::from_text("[diff]\ndelta_code_health_min = -5\n")
.unwrap()
.validate()
.expect("negative delta floor is valid");
}
#[test]
fn validate_rejects_nan() {
let err = Thresholds::from_text("[gates]\ncode_health_min = nan\n")
.unwrap()
.validate()
.expect_err("nan must be rejected");
assert!(
err.contains("code_health_min") && err.contains("finite"),
"message names the key and the finiteness requirement: {err}"
);
}
#[test]
fn validate_rejects_inf() {
let err = Thresholds::from_text("[gates]\nhotspot_score_max = inf\n")
.unwrap()
.validate()
.expect_err("inf must be rejected");
assert!(
err.contains("hotspot_score_max") && err.contains("finite"),
"message names the key and the finiteness requirement: {err}"
);
}
#[test]
fn validate_rejects_negative_hotspot_anchored_max() {
let err = Thresholds::from_text("[gates]\nhotspot_anchored_max = -1.0\n")
.unwrap()
.validate()
.expect_err("negative anchored ceiling must be rejected");
assert!(
err.contains("hotspot_anchored_max") && err.contains(">= 0"),
"message names the key and the non-negativity requirement: {err}"
);
}
#[test]
fn is_empty_false_when_only_hotspot_anchored_max_set() {
let t = Thresholds::from_text("[gates]\nhotspot_anchored_max = 9.9\n").unwrap();
assert!(!t.is_empty());
}
#[test]
fn validate_rejects_negative_floor() {
let err = Thresholds::from_text("[gates]\ncode_health_min = -5.0\n")
.unwrap()
.validate()
.expect_err("negative score floor must be rejected");
assert!(
err.contains("code_health_min") && err.contains("[0, 100]"),
"message names the key and its domain: {err}"
);
}
#[test]
fn validate_rejects_out_of_range_ratio() {
let err = Thresholds::from_text("[gates]\nmax_propagation_cost = 2.0\n")
.unwrap()
.validate()
.expect_err("out-of-range ratio must be rejected");
assert!(
err.contains("max_propagation_cost") && err.contains("[0, 1]"),
"message names the key and its [0, 1] domain: {err}"
);
}
#[test]
fn validate_reports_all_offenders_at_once() {
let raw = "\
[gates]
code_health_min = nan
max_propagation_cost = 5.0
hotspot_score_max = -1.0
[diff]
new_file_health_min = 200.0
";
let err = Thresholds::from_text(raw)
.unwrap()
.validate()
.expect_err("multiple offenders must be rejected");
for key in [
"code_health_min",
"max_propagation_cost",
"hotspot_score_max",
"new_file_health_min",
] {
assert!(
err.contains(key),
"every offender named ({key} missing): {err}"
);
}
}
#[test]
fn from_path_degenerate_value_is_config_error_exit_2() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(THRESHOLDS_FILENAME);
std::fs::write(&path, "[gates]\nhotspot_score_max = inf\n").expect("write");
let err = Thresholds::from_path(&path).expect_err("degenerate value must error");
assert_eq!(err.exit_code(), 2, "config-error taxonomy (exit 2): {err}");
assert!(
err.to_string().contains("hotspot_score_max"),
"error names the offending key: {err}"
);
}
#[test]
fn from_path_valid_config_is_byte_identical_ok() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(THRESHOLDS_FILENAME);
std::fs::write(&path, "[gates]\ncode_health_min = 60.0\n").expect("write");
let t = Thresholds::from_path(&path).expect("valid config must load");
assert_eq!(t.gates.code_health_min, Some(60.0));
}
#[test]
fn new_code_absent_is_none_and_empty() {
let t = Thresholds::from_text("[gates]\ncode_health_min = 60.0\n").unwrap();
assert!(t.new_code.is_none());
let bare = Thresholds::from_text("").unwrap();
assert!(bare.new_code.is_none());
assert!(bare.is_empty());
}
#[test]
fn new_code_empty_section_takes_defaults_and_is_non_empty() {
let t = Thresholds::from_text("[new_code]\n").unwrap();
let nc = t.new_code.as_ref().expect("section present");
assert_eq!(nc.window_days, 90);
assert_eq!(nc.born_health_min, None);
assert!(nc.touched_no_degradation);
assert!(
!t.is_empty(),
"presence of [new_code] makes the file non-empty"
);
t.validate().expect("defaults are in range");
}
#[test]
fn new_code_parses_explicit_values() {
let t = Thresholds::from_text(
"[new_code]\nwindow_days = 30\nborn_health_min = 60.0\ntouched_no_degradation = false\n",
)
.unwrap();
let nc = t.new_code.as_ref().expect("section present");
assert_eq!(nc.window_days, 30);
assert_eq!(nc.born_health_min, Some(60.0));
assert!(!nc.touched_no_degradation);
assert!(!t.is_empty());
}
#[test]
fn new_code_unknown_key_rejected() {
let err = Thresholds::from_text("[new_code]\nwindow_dayz = 90\n")
.expect_err("typo'd key should reject");
assert!(
err.contains("unknown field") || err.contains("window_dayz"),
"expected 'unknown field' in error: {err}"
);
}
#[test]
fn new_code_validate_rejects_out_of_range_window() {
for bad in [
"[new_code]\nwindow_days = 6\n",
"[new_code]\nwindow_days = 366\n",
] {
let err = Thresholds::from_text(bad)
.unwrap()
.validate()
.expect_err("out-of-range window must be rejected");
assert!(
err.contains("window_days") && err.contains("[7, 365]"),
"message names the key and its domain: {err}"
);
}
Thresholds::from_text("[new_code]\nwindow_days = 7\n")
.unwrap()
.validate()
.expect("7 is in range");
Thresholds::from_text("[new_code]\nwindow_days = 365\n")
.unwrap()
.validate()
.expect("365 is in range");
}
#[test]
fn new_code_validate_rejects_out_of_range_born_floor() {
let err = Thresholds::from_text("[new_code]\nborn_health_min = 150.0\n")
.unwrap()
.validate()
.expect_err("born floor above 100 must be rejected");
assert!(
err.contains("born_health_min") && err.contains("[0, 100]"),
"message names the key and its domain: {err}"
);
let err = Thresholds::from_text("[new_code]\nborn_health_min = nan\n")
.unwrap()
.validate()
.expect_err("nan born floor must be rejected");
assert!(
err.contains("born_health_min") && err.contains("finite"),
"message names the key and the finiteness requirement: {err}"
);
}
}