#![allow(
clippy::print_stderr,
reason = "health baseline save/load preserves existing human stderr notes"
)]
use crate::baseline::{
BaselineStalenessWarning, HealthBaselineData, HealthBaselineMode, filter_new_health_findings,
};
use super::HealthError;
pub(super) struct HealthBaselineSaveInput<'a> {
pub(super) save_path: &'a std::path::Path,
pub(super) findings: &'a [fallow_output::ComplexityViolation],
pub(super) runtime_coverage_findings: &'a [fallow_output::RuntimeCoverageFinding],
pub(super) targets: &'a [fallow_output::RefactoringTarget],
pub(super) config_root: &'a std::path::Path,
pub(super) quiet: bool,
pub(super) mode: HealthBaselineMode,
pub(super) mode_explicit: bool,
}
fn check_kind_overwrite(save_path: &std::path::Path) -> Result<(), HealthError> {
match crate::baseline::refuse_baseline_kind_overwrite(
save_path,
crate::baseline::BaselineKind::Health,
) {
Some(refusal) => Err(HealthError::message(refusal, 2)),
None => Ok(()),
}
}
fn check_identity_overwrite(
save_path: &std::path::Path,
mode: HealthBaselineMode,
mode_explicit: bool,
) -> Result<(), HealthError> {
if mode != HealthBaselineMode::Count || mode_explicit {
return Ok(());
}
let Ok(existing_json) = std::fs::read_to_string(save_path) else {
return Ok(());
};
let Ok(existing) = serde_json::from_str::<HealthBaselineData>(&existing_json) else {
return Ok(());
};
if existing.lacks_identity_data() {
return Ok(());
}
Err(HealthError::message(
format!(
"refusing to overwrite health baseline {}: it carries per-function \
identities (saved with --baseline-mode identity), and this count-mode \
save would drop them, breaking later --baseline-mode identity runs. \
Re-save with --baseline-mode identity to keep them, or pass \
--baseline-mode count explicitly to downgrade the baseline",
save_path.display()
),
2,
))
}
pub(super) fn save_health_baseline(input: &HealthBaselineSaveInput<'_>) -> Result<(), HealthError> {
let HealthBaselineSaveInput {
save_path,
findings,
runtime_coverage_findings,
targets,
config_root,
quiet,
mode,
mode_explicit,
} = *input;
check_kind_overwrite(save_path)?;
check_identity_overwrite(save_path, mode, mode_explicit)?;
let baseline = HealthBaselineData::from_findings(
findings,
runtime_coverage_findings,
targets,
config_root,
);
let baseline = match mode {
HealthBaselineMode::Count => baseline,
HealthBaselineMode::Identity => baseline.with_identity(findings, config_root),
};
match serde_json::to_string_pretty(&baseline) {
Ok(json) => {
if let Some(parent) = save_path.parent()
&& !parent.as_os_str().is_empty()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Err(HealthError::message(
format!("failed to create health baseline directory: {e}"),
2,
));
}
if let Err(e) = std::fs::write(save_path, json) {
return Err(HealthError::message(
format!("failed to save health baseline: {e}"),
2,
));
}
if !quiet {
eprintln!("Saved health baseline to {}", save_path.display());
}
Ok(())
}
Err(e) => Err(HealthError::message(
format!("failed to serialize health baseline: {e}"),
2,
)),
}
}
pub(super) struct LoadedHealthBaseline {
pub(super) data: HealthBaselineData,
pub(super) staleness: fallow_output::BaselineStaleness,
}
pub(super) fn load_health_baseline(
baseline_path: &std::path::Path,
findings: &mut Vec<fallow_output::ComplexityViolation>,
root: &std::path::Path,
quiet: bool,
mode: HealthBaselineMode,
scope_reasons: fallow_output::BaselineScopeReasons,
) -> Result<LoadedHealthBaseline, HealthError> {
let json = std::fs::read_to_string(baseline_path)
.map_err(|e| HealthError::message(format!("failed to read health baseline: {e}"), 2))?;
let baseline: HealthBaselineData = serde_json::from_str(&json)
.map_err(|e| HealthError::message(format!("failed to parse health baseline: {e}"), 2))?;
let unrecognised_format = !matches!(
crate::baseline::classify_baseline_file(&json, crate::baseline::BaselineKind::Health),
crate::baseline::BaselineFileKind::Own
);
if !unrecognised_format
&& mode == HealthBaselineMode::Identity
&& baseline.lacks_identity_data()
{
return Err(HealthError::message(
format!(
"health baseline {} carries no finding identities, so --baseline-mode identity \
cannot compare against it. Re-save it with: --save-baseline {} \
--baseline-mode identity",
baseline_path.display(),
baseline_path.display()
),
2,
));
}
let baseline_entries = baseline.finding_entry_count();
let before = findings.len();
let overlap = baseline.overlap_entries(findings, root, mode);
*findings = filter_new_health_findings(std::mem::take(findings), &baseline, root, mode);
if !quiet {
eprintln!(
"Comparing against health baseline: {}",
baseline_path.display()
);
}
let counts = StalenessCounts {
baseline_entries,
matched_entries: overlap.matched_entries,
moved_entries: overlap.moved_entries,
current_findings: before,
unrecognised_format,
scope_reasons,
};
let staleness = staleness_from_counts(&counts);
if !quiet {
warn_on_staleness(&counts, baseline_path);
if counts.moved_entries > 0 {
eprintln!(
"Note: {} baseline entr{} matched through a followed file move.",
counts.moved_entries,
if counts.moved_entries == 1 {
"y"
} else {
"ies"
},
);
}
}
Ok(LoadedHealthBaseline {
data: baseline,
staleness,
})
}
fn warn_on_staleness(counts: &StalenessCounts, baseline_path: &std::path::Path) {
let baseline_entries = counts.baseline_entries;
let stale_entries = baseline_entries.saturating_sub(counts.matched_entries);
match staleness_decision(counts).warning() {
BaselineStalenessWarning::None => {}
BaselineStalenessWarning::ZeroOverlap => eprintln!(
"Warning: health baseline has {baseline_entries} entries but matched \
0 current findings. Your paths may have changed, or the baseline \
was saved on a different machine. Re-save with: \
--save-baseline {}",
baseline_path.display(),
),
BaselineStalenessWarning::Partial => eprintln!(
"Warning: health baseline is partially stale: {stale_entries} of \
{baseline_entries} entries matched no current finding, so the \
gate protects less than what was saved. Re-save with: \
--save-baseline {}",
baseline_path.display(),
),
}
}
const fn staleness_decision(counts: &StalenessCounts) -> crate::baseline::BaselineStaleness {
crate::baseline::BaselineStaleness {
entries: counts.baseline_entries,
matched: counts.matched_entries,
current_findings: counts.current_findings,
change_scoped: !counts.scope_reasons.is_empty(),
}
}
struct StalenessCounts {
baseline_entries: usize,
matched_entries: usize,
moved_entries: usize,
current_findings: usize,
unrecognised_format: bool,
scope_reasons: fallow_output::BaselineScopeReasons,
}
fn staleness_from_counts(counts: &StalenessCounts) -> fallow_output::BaselineStaleness {
staleness_decision(counts).to_envelope(
counts.moved_entries,
counts.scope_reasons,
counts.unrecognised_format,
)
}
#[cfg(test)]
mod tests {
use super::{StalenessCounts, staleness_from_counts};
fn counts(baseline_entries: usize, matched_entries: usize) -> StalenessCounts {
StalenessCounts {
baseline_entries,
matched_entries,
moved_entries: 0,
current_findings: baseline_entries.max(1),
unrecognised_format: false,
scope_reasons: fallow_output::BaselineScopeReasons::empty(),
}
}
#[test]
fn staleness_below_threshold_is_not_flagged() {
let staleness = staleness_from_counts(&counts(100, 76));
assert_eq!(staleness.stale_entries, 24);
assert!(!staleness.stale);
}
#[test]
fn staleness_at_threshold_is_flagged() {
let staleness = staleness_from_counts(&counts(100, 75));
assert_eq!(staleness.stale_entries, 25);
assert!(staleness.stale);
}
#[test]
fn zero_overlap_is_flagged_as_fully_stale() {
let staleness = staleness_from_counts(&counts(8, 0));
assert_eq!(staleness.stale_entries, 8);
assert!(staleness.stale);
}
#[test]
fn empty_baseline_is_never_stale() {
let staleness = staleness_from_counts(&counts(0, 0));
assert_eq!(staleness.stale_entries, 0);
assert!(!staleness.stale);
}
#[test]
fn small_baselines_flag_meaningful_drift() {
assert!(staleness_from_counts(&counts(4, 3)).stale);
assert!(!staleness_from_counts(&counts(5, 4)).stale);
}
#[test]
fn change_scoped_run_is_never_stale() {
let staleness = staleness_from_counts(&StalenessCounts {
scope_reasons: fallow_output::BaselineScopeReasons::empty()
.with(fallow_output::ScopeReason::ChangedFiles),
..counts(8, 2)
});
assert_eq!(staleness.stale_entries, 6);
assert!(staleness.change_scoped);
assert!(!staleness.stale);
assert_eq!(
staleness.scope_reasons,
fallow_output::BaselineScopeReasons::empty()
.with(fallow_output::ScopeReason::ChangedFiles)
);
}
#[test]
fn an_unscoped_run_reports_no_scope_reasons() {
let staleness = staleness_from_counts(&counts(8, 2));
assert!(!staleness.change_scoped);
assert!(staleness.scope_reasons.is_empty());
}
#[test]
fn run_without_current_findings_is_never_stale() {
let staleness = staleness_from_counts(&StalenessCounts {
current_findings: 0,
..counts(8, 0)
});
assert_eq!(staleness.stale_entries, 8);
assert!(!staleness.stale);
}
#[test]
fn moved_entries_are_carried_through() {
let staleness = staleness_from_counts(&StalenessCounts {
moved_entries: 2,
..counts(10, 9)
});
assert_eq!(staleness.moved_entries, 2);
assert!(!staleness.stale);
}
}