use serde::Deserialize;
use crate::{coverage::lcov::CoverageReport, models::Verdict, pipeline::letter_grade::Grade};
pub const DEFAULT_MIN_NEW_CODE_PCT: f64 = 80.0;
pub const DEFAULT_MAX_NET_DROP_PCT: f64 = 1.0;
#[derive(Debug, Default, Deserialize)]
pub struct CoverageFileConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub min_new_code_pct: Option<f64>,
#[serde(default)]
pub max_net_drop_pct: Option<f64>,
#[serde(default)]
pub lcov_path: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CoveragePolicy {
pub enabled: bool,
pub min_new_code_pct: f64,
pub max_net_drop_pct: f64,
pub lcov_path: Option<std::path::PathBuf>,
}
impl Default for CoveragePolicy {
fn default() -> Self {
Self {
enabled: false,
min_new_code_pct: DEFAULT_MIN_NEW_CODE_PCT,
max_net_drop_pct: DEFAULT_MAX_NET_DROP_PCT,
lcov_path: None,
}
}
}
impl CoveragePolicy {
pub fn from_env_and_file(file: Option<&CoverageFileConfig>) -> Self {
let enabled = load_env_bool("TRUSTY_REVIEW_COVERAGE_ENABLED")
.or_else(|| file.and_then(|f| f.enabled))
.unwrap_or(false);
let min_new_code_pct = load_env_f64("TRUSTY_REVIEW_MIN_NEW_CODE_PCT")
.or_else(|| file.and_then(|f| f.min_new_code_pct))
.unwrap_or(DEFAULT_MIN_NEW_CODE_PCT)
.clamp(0.0, 100.0);
let max_net_drop_pct = load_env_f64("TRUSTY_REVIEW_MAX_NET_DROP_PCT")
.or_else(|| file.and_then(|f| f.max_net_drop_pct))
.unwrap_or(DEFAULT_MAX_NET_DROP_PCT)
.clamp(0.0, 100.0);
let lcov_path = std::env::var("TRUSTY_REVIEW_LCOV_PATH")
.ok()
.filter(|s| !s.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| {
file.and_then(|f| f.lcov_path.as_ref())
.filter(|s| !s.trim().is_empty())
.map(std::path::PathBuf::from)
});
Self {
enabled,
min_new_code_pct,
max_net_drop_pct,
lcov_path,
}
}
}
#[derive(Debug, Clone)]
pub struct CoverageVerdictContrib {
pub floor: Option<Verdict>,
pub grade_ceiling: Option<Grade>,
pub summary: String,
}
pub fn evaluate_coverage(
policy: &CoveragePolicy,
report: &CoverageReport,
new_code_pct: Option<f64>,
baseline_net_pct: Option<f64>,
) -> CoverageVerdictContrib {
if !policy.enabled {
return CoverageVerdictContrib {
floor: None,
grade_ceiling: None,
summary: format!(
"Coverage: {:.1}% ({}/{} lines hit) — gating disabled (opt-in).",
report.net_pct, report.lines_hit, report.lines_instrumented
),
};
}
let mut reasons: Vec<String> = Vec::new();
let mut needs_floor = false;
if let Some(nc_pct) = new_code_pct
&& nc_pct < policy.min_new_code_pct
{
needs_floor = true;
if nc_pct < 0.1 {
reasons.push(format!(
"new-code coverage is 0% (threshold: {:.0}%)",
policy.min_new_code_pct
));
} else {
reasons.push(format!(
"new-code coverage is {nc_pct:.1}% (below threshold: {:.0}%)",
policy.min_new_code_pct
));
}
}
if let Some(baseline) = baseline_net_pct {
let drop = baseline - report.net_pct;
if drop > policy.max_net_drop_pct {
needs_floor = true;
reasons.push(format!(
"net coverage dropped {drop:.1}pp ({:.1}% → {:.1}%; max allowed: {:.1}pp)",
baseline, report.net_pct, policy.max_net_drop_pct
));
}
}
let new_code_part = new_code_pct
.map(|p| format!(", new-code: {p:.1}%"))
.unwrap_or_default();
let summary_base = format!(
"Coverage: {:.1}%{new_code_part} ({}/{} lines hit).",
report.net_pct, report.lines_hit, report.lines_instrumented
);
if needs_floor {
let reason_text = reasons.join("; ");
CoverageVerdictContrib {
floor: Some(Verdict::RequestChanges),
grade_ceiling: Some(Grade::DPlus),
summary: format!("{summary_base} COVERAGE GATE TRIGGERED: {reason_text}."),
}
} else {
let pass_note = if new_code_pct.is_some() || baseline_net_pct.is_some() {
" Coverage thresholds met."
} else {
""
};
CoverageVerdictContrib {
floor: None,
grade_ceiling: None,
summary: format!("{summary_base}{pass_note}"),
}
}
}
fn load_env_bool(var: &str) -> Option<bool> {
let val = std::env::var(var).ok()?;
match val.trim().to_lowercase().as_str() {
"true" | "1" | "yes" => Some(true),
"false" | "0" | "no" => Some(false),
_ => None,
}
}
fn load_env_f64(var: &str) -> Option<f64> {
std::env::var(var).ok()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coverage::lcov::parse_lcov;
fn make_policy(enabled: bool) -> CoveragePolicy {
CoveragePolicy {
enabled,
min_new_code_pct: 80.0,
max_net_drop_pct: 1.0,
lcov_path: None,
}
}
fn zero_report() -> CoverageReport {
parse_lcov("").expect("empty")
}
fn report_with_pct(pct: f64) -> CoverageReport {
let mut report = zero_report();
report.lines_instrumented = 100;
report.lines_hit = pct.round() as u64;
report.net_pct = pct;
report
}
#[test]
fn coverage_policy_off_is_noop() {
let policy = make_policy(false);
let report = report_with_pct(0.0);
let contrib = evaluate_coverage(&policy, &report, Some(0.0), Some(90.0));
assert!(
contrib.floor.is_none(),
"disabled policy must never produce a floor"
);
assert!(
contrib.grade_ceiling.is_none(),
"disabled policy must never produce a grade ceiling"
);
}
#[test]
fn coverage_policy_zero_new_code() {
let policy = make_policy(true);
let report = report_with_pct(85.0); let contrib = evaluate_coverage(&policy, &report, Some(0.0), None);
assert_eq!(
contrib.floor,
Some(Verdict::RequestChanges),
"0% new-code coverage must floor to REQUEST_CHANGES"
);
assert_eq!(contrib.grade_ceiling, Some(Grade::DPlus));
assert!(
contrib.summary.contains("0%"),
"summary must mention 0%: {}",
contrib.summary
);
}
#[test]
fn coverage_policy_below_threshold() {
let policy = make_policy(true);
let report = report_with_pct(90.0);
let contrib = evaluate_coverage(&policy, &report, Some(50.0), None);
assert_eq!(
contrib.floor,
Some(Verdict::RequestChanges),
"50% new-code coverage below 80% threshold must floor"
);
assert!(
contrib.summary.contains("50.0%"),
"summary must mention 50.0%: {}",
contrib.summary
);
}
#[test]
fn coverage_policy_net_drop() {
let policy = make_policy(true);
let report = report_with_pct(80.0);
let contrib = evaluate_coverage(&policy, &report, None, Some(85.0));
assert_eq!(
contrib.floor,
Some(Verdict::RequestChanges),
"5pp drop exceeding 1pp threshold must floor"
);
assert!(
contrib.summary.contains("COVERAGE GATE TRIGGERED"),
"summary must contain trigger marker: {}",
contrib.summary
);
}
#[test]
fn coverage_policy_pass() {
let policy = make_policy(true);
let report = report_with_pct(90.0);
let contrib = evaluate_coverage(&policy, &report, Some(90.0), Some(89.5));
assert!(
contrib.floor.is_none(),
"all conditions pass → no floor: {:?}",
contrib
);
assert!(contrib.grade_ceiling.is_none());
assert!(
contrib.summary.contains("Coverage thresholds met"),
"summary should confirm pass: {}",
contrib.summary
);
}
#[test]
fn coverage_policy_no_data_no_floor() {
let policy = make_policy(true);
let report = report_with_pct(80.0);
let contrib = evaluate_coverage(&policy, &report, None, None);
assert!(
contrib.floor.is_none(),
"no new-code data and no baseline → no floor"
);
}
#[test]
fn coverage_policy_default_is_off() {
let policy = CoveragePolicy::default();
assert!(!policy.enabled, "default policy must be disabled (opt-in)");
}
}