use std::io::{IsTerminal, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use crate::core::accepted::{decorate_findings, read_accepted};
use crate::core::calibration::Calibration;
use crate::core::config::{load_from_project, Config};
use crate::core::finding::Finding;
use crate::core::severity::Severity;
use crate::core::term::{ansi_wrap, ANSI_CYAN, ANSI_GREEN, ANSI_RED, ANSI_YELLOW};
use crate::core::HealPaths;
use crate::observers::{classify, run_all, ObserverReports};
use anyhow::Result;
use crate::cli::HookEvent;
pub fn run(project: &Path, event: HookEvent) -> Result<()> {
let paths = HealPaths::new(project);
if !paths.root().exists() {
return Ok(());
}
match event {
HookEvent::Commit => run_commit(project, &paths)?,
HookEvent::Edit | HookEvent::Stop => {}
}
Ok(())
}
fn run_commit(project: &Path, paths: &HealPaths) -> Result<()> {
let cfg = match load_from_project(project) {
Ok(c) => c,
Err(crate::core::Error::ConfigMissing(_)) => return Ok(()),
Err(e) => return Err(e.into()),
};
let reports = run_all(project, &cfg, None, None);
let (calibration, mut findings) = classify_with_calibration(paths, &cfg, &reports);
let accepted_map = read_accepted(&paths.findings_accepted()).unwrap_or_default();
if !accepted_map.is_empty() {
decorate_findings(&mut findings, &accepted_map);
}
write_nudge(
calibration.as_ref(),
&cfg,
&findings,
&mut std::io::stdout(),
)
.ok();
let _ = spawn_coverage_refresh(project, &cfg);
Ok(())
}
fn spawn_coverage_refresh(project: &Path, cfg: &Config) -> bool {
if !(cfg.features.test.enabled && cfg.features.test.coverage.enabled) {
return false;
}
let Some(cmd) = cfg.features.test.coverage.post_commit_refresh.as_deref() else {
return false;
};
if cmd.trim().is_empty() {
return false;
}
Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(project)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
}
pub(crate) fn classify_with_calibration(
paths: &HealPaths,
cfg: &Config,
reports: &ObserverReports,
) -> (Option<Calibration>, Vec<Finding>) {
let calibration = Calibration::load(&paths.calibration())
.ok()
.map(|c| c.with_overrides(cfg));
let findings = calibration
.as_ref()
.map(|c| classify(reports, c, cfg))
.unwrap_or_default();
(calibration, findings)
}
fn write_nudge(
calibration: Option<&Calibration>,
cfg: &Config,
findings: &[Finding],
out: &mut impl Write,
) -> Result<()> {
if calibration.is_none() {
return Ok(());
}
let critical = findings
.iter()
.filter(|f| !f.accepted && matches!(f.severity, Severity::Critical))
.count();
let high = findings
.iter()
.filter(|f| !f.accepted && matches!(f.severity, Severity::High))
.count();
let colorize = std::io::stdout().is_terminal();
if critical == 0 && high == 0 {
writeln!(
out,
"heal: recorded · {}",
ansi_wrap(ANSI_GREEN, "clean", colorize),
)?;
return Ok(());
}
let mut counts: Vec<String> = Vec::with_capacity(2);
if critical > 0 {
counts.push(ansi_wrap(
ANSI_RED,
&format!("{critical} critical"),
colorize,
));
}
if high > 0 {
counts.push(ansi_wrap(ANSI_YELLOW, &format!("{high} high"), colorize));
}
writeln!(
out,
"heal: recorded · {} · {}",
counts.join(", "),
ansi_wrap(ANSI_CYAN, "heal status", colorize),
)?;
if cfg.features.test.enabled && cfg.features.test.coverage.enabled {
let uncovered_hotspots = findings
.iter()
.filter(|f| {
!f.accepted
&& f.metric == Finding::METRIC_COVERAGE_PCT
&& f.hotspot
&& matches!(f.severity, Severity::High | Severity::Critical)
})
.count();
if uncovered_hotspots > 0 {
writeln!(
out,
" · {}",
ansi_wrap(
ANSI_YELLOW,
&format!("{uncovered_hotspots} uncovered hotspot"),
colorize,
),
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::Config;
use crate::test_support::init_project_with_config;
use tempfile::TempDir;
fn nudge_output(dir: &Path, paths: &HealPaths, cfg: &Config) -> String {
let reports = run_all(dir, cfg, None, None);
let (calibration, findings) = classify_with_calibration(paths, cfg, &reports);
let mut buf: Vec<u8> = Vec::new();
write_nudge(calibration.as_ref(), cfg, &findings, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn commit_runs_observers_without_writing_snapshots() {
let dir = TempDir::new().unwrap();
let paths = init_project_with_config(dir.path(), "fn ok() {}\n");
std::fs::write(paths.config(), "").unwrap();
run(dir.path(), HookEvent::Commit).unwrap();
}
#[test]
fn edit_is_noop() {
let dir = TempDir::new().unwrap();
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
run(dir.path(), HookEvent::Edit).unwrap();
}
#[test]
fn stop_is_noop() {
let dir = TempDir::new().unwrap();
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
run(dir.path(), HookEvent::Stop).unwrap();
}
#[test]
fn nudge_is_silent_without_calibration() {
let dir = TempDir::new().unwrap();
let paths = init_project_with_config(dir.path(), "fn ok(){}\n");
let cfg = Config::default();
let out = nudge_output(dir.path(), &paths, &cfg);
assert!(out.is_empty(), "no calibration → no nudge, got: {out}");
}
#[cfg(feature = "lang-rust")]
#[test]
fn nudge_summarises_critical_count_in_one_line() {
let mut src = String::from("fn busy(x: i32) -> i32 {\n");
for _ in 0..30 {
src.push_str(" if x > 0 { return x; }\n");
}
src.push_str(" 0\n}\n");
let dir = TempDir::new().unwrap();
let paths = init_project_with_config(dir.path(), &src);
let cfg = Config::default();
crate::commands::calibrate::run(dir.path(), false, false).unwrap();
let out = nudge_output(dir.path(), &paths, &cfg);
assert_eq!(
out.lines().count(),
1,
"post-commit summary must be a single line, got: {out}",
);
assert!(
out.starts_with("heal: recorded · "),
"unexpected prefix: {out}"
);
assert!(
out.contains("critical") || out.contains("high"),
"expected a critical/high count, got: {out}",
);
assert!(
out.contains("heal status"),
"missing nudge to heal status: {out}"
);
}
#[test]
fn coverage_refresh_spawns_when_configured_and_enabled() {
let dir = TempDir::new().unwrap();
let project = dir.path();
let sentinel = project.join("refreshed.txt");
let mut cfg = Config::default();
cfg.features.test.enabled = true;
cfg.features.test.coverage.enabled = true;
cfg.features.test.coverage.post_commit_refresh =
Some(format!("touch {}", sentinel.display()));
assert!(
spawn_coverage_refresh(project, &cfg),
"configured + enabled must spawn",
);
for _ in 0..50 {
if sentinel.exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(
sentinel.exists(),
"post_commit_refresh command must have run",
);
}
#[test]
fn coverage_refresh_skipped_when_feature_disabled() {
let dir = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.features.test.enabled = false; cfg.features.test.coverage.enabled = true;
cfg.features.test.coverage.post_commit_refresh = Some("touch /tmp/should-not-run".into());
assert!(
!spawn_coverage_refresh(dir.path(), &cfg),
"disabled family must short-circuit before spawn",
);
}
#[test]
fn coverage_refresh_skipped_when_unset() {
let dir = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.features.test.enabled = true;
cfg.features.test.coverage.enabled = true;
assert!(
!spawn_coverage_refresh(dir.path(), &cfg),
"unset command must short-circuit before spawn",
);
}
#[test]
fn coverage_refresh_skipped_when_blank() {
let dir = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.features.test.enabled = true;
cfg.features.test.coverage.enabled = true;
cfg.features.test.coverage.post_commit_refresh = Some(" ".into());
assert!(
!spawn_coverage_refresh(dir.path(), &cfg),
"whitespace-only command must short-circuit before spawn",
);
}
#[test]
fn nudge_says_clean_when_no_critical_or_high() {
let dir = TempDir::new().unwrap();
let paths = init_project_with_config(dir.path(), "fn ok() {}\n");
let cfg = Config::default();
crate::commands::calibrate::run(dir.path(), false, false).unwrap();
let out = nudge_output(dir.path(), &paths, &cfg);
assert_eq!(out.trim_end(), "heal: recorded · clean");
}
}