use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{CodeLoreError, Result};
pub const RATCHET_FILENAME: &str = ".codelore-ratchet.toml";
const RATCHET_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
HigherBetter,
LowerBetter,
}
pub const RATCHET_METRICS: &[(&str, Direction)] = &[
("code_health_min_observed", Direction::HigherBetter),
("red_effort_pct_observed", Direction::LowerBetter),
("dependency_cycles_observed", Direction::LowerBetter),
];
#[derive(Debug, Clone, Default)]
pub struct RatchetMetrics {
pub code_health_min_observed: Option<f64>,
pub red_effort_pct_observed: Option<f64>,
pub dependency_cycles_observed: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RatchetSnapshot {
#[serde(default)]
pub ratchet_schema: u32,
#[serde(default)]
pub ratchet: RatchetTable,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RatchetTable {
pub code_health_min_observed: Option<f64>,
pub red_effort_pct_observed: Option<f64>,
pub dependency_cycles_observed: Option<f64>,
}
#[derive(Debug)]
pub enum RatchetOutcome {
Improved { tightened: Vec<String> },
Regressed { regressions: Vec<RatchetRegression> },
}
#[derive(Debug)]
pub struct RatchetRegression {
pub key: String,
pub snapshot_value: f64,
pub observed_value: f64,
pub direction: Direction,
}
#[must_use]
pub fn snapshot_from_metrics(metrics: &RatchetMetrics) -> RatchetSnapshot {
RatchetSnapshot {
ratchet_schema: RATCHET_SCHEMA,
ratchet: RatchetTable {
code_health_min_observed: metrics.code_health_min_observed,
red_effort_pct_observed: metrics.red_effort_pct_observed,
dependency_cycles_observed: metrics.dependency_cycles_observed,
},
}
}
pub fn read_snapshot(repo_root: &Path) -> Result<Option<RatchetSnapshot>> {
let path = ratchet_path(repo_root);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| {
CodeLoreError::Analysis(format!("read ratchet file {}: {e}", path.display()))
})?;
if raw.trim().is_empty() {
return Err(CodeLoreError::Analysis(format!(
"ratchet file {} is present but empty — the committed baseline was destroyed \
(a truncated write, an empty merge resolution, or manual truncation). An empty \
ratchet must not silently rebaseline the gate: restore the file from version \
control, or delete it to re-initialize the baseline from this run.",
path.display()
)));
}
let snap = toml::from_str::<RatchetSnapshot>(&raw).map_err(|e| {
CodeLoreError::Analysis(format!(
"ratchet file is not valid TOML ({}): {e}",
path.display()
))
})?;
if snap.ratchet_schema != RATCHET_SCHEMA {
tracing::warn!(
"ratchet reset: {} was written under gate-metric schema {} (current {}); gate \
metric definitions changed since that baseline was recorded, so it is discarded \
and baselines re-establish on this run.",
path.display(),
snap.ratchet_schema,
RATCHET_SCHEMA
);
return Ok(None);
}
Ok(Some(snap))
}
pub fn write_snapshot(repo_root: &Path, snap: &RatchetSnapshot) -> Result<()> {
let path = ratchet_path(repo_root);
let header = "# Generated by `codelore check --ratchet`. Commit this file.\n\
# Ratchet tightens automatically on improvement; edit manually to relax.\n\n";
let body = toml::to_string_pretty(snap)
.map_err(|e| CodeLoreError::Analysis(format!("serialize ratchet snapshot: {e}")))?;
let payload = format!("{header}{body}");
crate::output::atomic_publish(&path, |tmp| {
fs::write(tmp, payload.as_bytes()).map_err(|e| {
CodeLoreError::Analysis(format!("write ratchet file {}: {e}", path.display()))
})
})
}
#[must_use]
pub fn evaluate_ratchet(snap: &RatchetSnapshot, metrics: &RatchetMetrics) -> RatchetOutcome {
let mut regressions: Vec<RatchetRegression> = Vec::new();
let mut tightened: Vec<String> = Vec::new();
for (key, direction) in RATCHET_METRICS {
let snap_val = match *key {
"code_health_min_observed" => snap.ratchet.code_health_min_observed,
"red_effort_pct_observed" => snap.ratchet.red_effort_pct_observed,
"dependency_cycles_observed" => snap.ratchet.dependency_cycles_observed,
_ => None,
};
let obs_val = match *key {
"code_health_min_observed" => metrics.code_health_min_observed,
"red_effort_pct_observed" => metrics.red_effort_pct_observed,
"dependency_cycles_observed" => metrics.dependency_cycles_observed,
_ => None,
};
let Some(sv) = snap_val else { continue };
let Some(ov) = obs_val else {
regressions.push(RatchetRegression {
key: (*key).to_owned(),
snapshot_value: sv,
observed_value: f64::NAN,
direction: *direction,
});
continue;
};
let worse = match direction {
Direction::HigherBetter => ov < sv - f64::EPSILON,
Direction::LowerBetter => ov > sv + f64::EPSILON,
};
let strictly_better = match direction {
Direction::HigherBetter => ov > sv + f64::EPSILON,
Direction::LowerBetter => ov < sv - f64::EPSILON,
};
if worse {
regressions.push(RatchetRegression {
key: (*key).to_owned(),
snapshot_value: sv,
observed_value: ov,
direction: *direction,
});
} else if strictly_better {
tightened.push((*key).to_owned());
}
}
if regressions.is_empty() {
RatchetOutcome::Improved { tightened }
} else {
RatchetOutcome::Regressed { regressions }
}
}
#[must_use]
pub fn format_ratchet_outcome(outcome: &RatchetOutcome) -> String {
let mut out = String::new();
match outcome {
RatchetOutcome::Improved { tightened } => {
if tightened.is_empty() {
writeln!(out, "✅ ratchet: no regression (all metrics held)").unwrap();
} else {
writeln!(out, "✅ ratchet: tightened {}", tightened.join(", ")).unwrap();
}
}
RatchetOutcome::Regressed { regressions } => {
writeln!(out, "❌ ratchet: {} regression(s):", regressions.len()).unwrap();
for r in regressions {
if r.observed_value.is_nan() {
writeln!(
out,
" - {}: was {:.2} → now unavailable (analysis degraded)",
r.key, r.snapshot_value
)
.unwrap();
} else {
let dir = match r.direction {
Direction::HigherBetter => "↓ (worse)",
Direction::LowerBetter => "↑ (worse)",
};
writeln!(
out,
" - {}: was {:.2} → now {:.2} {dir}",
r.key, r.snapshot_value, r.observed_value
)
.unwrap();
}
}
}
}
out
}
#[must_use]
pub fn ratchet_path(repo_root: &Path) -> PathBuf {
repo_root.join(RATCHET_FILENAME)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
fn metrics_good() -> RatchetMetrics {
RatchetMetrics {
code_health_min_observed: Some(65.0),
red_effort_pct_observed: Some(10.0),
dependency_cycles_observed: Some(2.0),
}
}
#[test]
fn init_writes_snapshot_when_missing() {
let dir = tmp();
assert!(read_snapshot(dir.path()).unwrap().is_none());
let snap = snapshot_from_metrics(&metrics_good());
write_snapshot(dir.path(), &snap).unwrap();
let read_back = read_snapshot(dir.path()).unwrap().unwrap();
assert!((read_back.ratchet.code_health_min_observed.unwrap() - 65.0).abs() < 0.01);
assert!((read_back.ratchet.red_effort_pct_observed.unwrap() - 10.0).abs() < 0.01);
assert!((read_back.ratchet.dependency_cycles_observed.unwrap() - 2.0).abs() < 0.01);
}
#[test]
fn corrupt_toml_returns_typed_error() {
let dir = tmp();
fs::write(dir.path().join(RATCHET_FILENAME), b"not valid toml ][[[").unwrap();
let err = read_snapshot(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("not valid TOML"),
"expected TOML error, got: {msg}"
);
}
#[test]
fn empty_ratchet_file_is_corrupt_not_improved() {
let dir = tmp();
fs::write(dir.path().join(RATCHET_FILENAME), b"").unwrap();
let err = read_snapshot(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("empty") && msg.contains("destroyed"),
"expected a loud destroyed-baseline error, got: {msg}"
);
}
#[test]
fn whitespace_only_ratchet_file_is_corrupt() {
let dir = tmp();
fs::write(dir.path().join(RATCHET_FILENAME), b"\n \n").unwrap();
assert!(
read_snapshot(dir.path()).is_err(),
"a whitespace-only ratchet file must be rejected as corrupt"
);
}
#[test]
fn well_formed_empty_table_is_no_floors_not_corrupt() {
let dir = tmp();
fs::write(
dir.path().join(RATCHET_FILENAME),
format!("ratchet_schema = {RATCHET_SCHEMA}\n\n[ratchet]\n"),
)
.unwrap();
let snap = read_snapshot(dir.path())
.expect("a well-formed empty table must not be corrupt")
.expect("a present file at the current schema returns Some");
assert!(snap.ratchet.code_health_min_observed.is_none());
assert!(snap.ratchet.red_effort_pct_observed.is_none());
assert!(snap.ratchet.dependency_cycles_observed.is_none());
}
#[test]
fn ratchet_schema_round_trips_through_write_and_read() {
let dir = tmp();
let snap = snapshot_from_metrics(&metrics_good());
write_snapshot(dir.path(), &snap).unwrap();
let read_back = read_snapshot(dir.path()).unwrap().unwrap();
assert_eq!(read_back.ratchet_schema, RATCHET_SCHEMA);
}
#[test]
fn missing_ratchet_schema_key_discards_snapshot() {
let dir = tmp();
fs::write(
dir.path().join(RATCHET_FILENAME),
b"[ratchet]\ncode_health_min_observed = 65.0\n",
)
.unwrap();
assert!(
read_snapshot(dir.path()).unwrap().is_none(),
"a file with no ratchet_schema key must be discarded, not served"
);
}
#[test]
fn stale_ratchet_schema_key_discards_snapshot() {
let dir = tmp();
fs::write(
dir.path().join(RATCHET_FILENAME),
b"ratchet_schema = 999\n\n[ratchet]\ncode_health_min_observed = 65.0\n",
)
.unwrap();
assert!(
read_snapshot(dir.path()).unwrap().is_none(),
"a file with a stale ratchet_schema key must be discarded, not served"
);
}
#[test]
fn current_ratchet_schema_key_is_accepted() {
let dir = tmp();
fs::write(
dir.path().join(RATCHET_FILENAME),
format!(
"ratchet_schema = {RATCHET_SCHEMA}\n\n[ratchet]\ncode_health_min_observed = 65.0\n"
),
)
.unwrap();
let snap = read_snapshot(dir.path())
.unwrap()
.expect("a file at the current schema must be served");
assert!((snap.ratchet.code_health_min_observed.unwrap() - 65.0).abs() < 0.01);
}
#[test]
fn write_snapshot_leaves_no_temp_stray() {
let dir = tmp();
write_snapshot(dir.path(), &snapshot_from_metrics(&metrics_good())).unwrap();
let strays: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(
strays.is_empty(),
"an atomic write must not orphan a temp file: {strays:?}"
);
assert!(read_snapshot(dir.path()).unwrap().is_some());
}
#[test]
fn better_snapshot_than_current_is_regression() {
let snap = RatchetSnapshot {
ratchet: RatchetTable {
code_health_min_observed: Some(80.0),
red_effort_pct_observed: Some(5.0),
dependency_cycles_observed: Some(0.0),
},
..Default::default()
};
let outcome = evaluate_ratchet(&snap, &metrics_good());
let RatchetOutcome::Regressed { regressions } = outcome else {
panic!("expected regression");
};
assert_eq!(regressions.len(), 3);
assert_eq!(regressions[0].key, "code_health_min_observed");
assert_eq!(regressions[1].key, "red_effort_pct_observed");
assert_eq!(regressions[2].key, "dependency_cycles_observed");
}
#[test]
fn worse_snapshot_than_current_is_improvement() {
let snap = RatchetSnapshot {
ratchet: RatchetTable {
code_health_min_observed: Some(50.0),
red_effort_pct_observed: Some(20.0),
dependency_cycles_observed: Some(5.0),
},
..Default::default()
};
let outcome = evaluate_ratchet(&snap, &metrics_good());
let RatchetOutcome::Improved { tightened } = outcome else {
panic!("expected improvement");
};
assert_eq!(tightened.len(), 3);
}
#[test]
fn partial_snapshot_only_checks_present_keys() {
let snap = RatchetSnapshot {
ratchet: RatchetTable {
code_health_min_observed: Some(80.0), red_effort_pct_observed: None, dependency_cycles_observed: None, },
..Default::default()
};
let outcome = evaluate_ratchet(&snap, &metrics_good());
let RatchetOutcome::Regressed { regressions } = outcome else {
panic!("expected regression");
};
assert_eq!(regressions.len(), 1);
assert_eq!(regressions[0].key, "code_health_min_observed");
}
#[test]
fn higher_better_direction_is_correct() {
let snap = RatchetSnapshot {
ratchet: RatchetTable {
code_health_min_observed: Some(60.0),
..Default::default()
},
..Default::default()
};
let metrics = RatchetMetrics {
code_health_min_observed: Some(70.0),
..Default::default()
};
let outcome = evaluate_ratchet(&snap, &metrics);
let RatchetOutcome::Improved { tightened } = outcome else {
panic!("expected improvement");
};
assert!(tightened.contains(&"code_health_min_observed".to_owned()));
}
#[test]
fn lower_better_direction_is_correct() {
let snap = RatchetSnapshot {
ratchet: RatchetTable {
dependency_cycles_observed: Some(3.0),
..Default::default()
},
..Default::default()
};
let metrics = RatchetMetrics {
dependency_cycles_observed: Some(1.0),
..Default::default()
};
let outcome = evaluate_ratchet(&snap, &metrics);
let RatchetOutcome::Improved { tightened } = outcome else {
panic!("expected improvement");
};
assert!(tightened.contains(&"dependency_cycles_observed".to_owned()));
}
#[test]
fn absent_current_metric_with_snapshot_is_regression() {
let snap = RatchetSnapshot {
ratchet: RatchetTable {
code_health_min_observed: Some(65.0),
..Default::default()
},
..Default::default()
};
let metrics = RatchetMetrics {
code_health_min_observed: None, ..Default::default()
};
let outcome = evaluate_ratchet(&snap, &metrics);
let RatchetOutcome::Regressed { regressions } = outcome else {
panic!("expected regression");
};
assert_eq!(regressions.len(), 1);
assert!(regressions[0].observed_value.is_nan());
}
}