use std::io::{IsTerminal, Write};
use std::path::Path;
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(), &findings, &mut std::io::stdout()).ok();
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>,
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),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{commit, init_repo};
use tempfile::TempDir;
#[test]
fn commit_runs_observers_without_writing_snapshots() {
let dir = TempDir::new().unwrap();
init_repo(dir.path());
commit(
dir.path(),
"lib.rs",
"fn ok() {}\n",
"alice@example.com",
"feat: add ok",
);
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
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();
init_repo(dir.path());
commit(dir.path(), "lib.rs", "fn ok(){}\n", "a@b.c", "init");
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
let cfg = crate::core::config::Config::default();
cfg.save(&paths.config()).unwrap();
let reports = run_all(dir.path(), &cfg, None, None);
let (calibration, findings) = classify_with_calibration(&paths, &cfg, &reports);
let mut buf: Vec<u8> = Vec::new();
write_nudge(calibration.as_ref(), &findings, &mut buf).unwrap();
assert!(
buf.is_empty(),
"no calibration → no nudge, got: {}",
String::from_utf8_lossy(&buf),
);
}
#[cfg(feature = "lang-rust")]
#[test]
fn nudge_summarises_critical_count_in_one_line() {
let dir = TempDir::new().unwrap();
init_repo(dir.path());
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");
commit(dir.path(), "lib.rs", &src, "a@b.c", "init");
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
let cfg = crate::core::config::Config::default();
cfg.save(&paths.config()).unwrap();
crate::commands::calibrate::run(dir.path(), false, false).unwrap();
let reports = run_all(dir.path(), &cfg, None, None);
let (calibration, findings) = classify_with_calibration(&paths, &cfg, &reports);
let mut buf: Vec<u8> = Vec::new();
write_nudge(calibration.as_ref(), &findings, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
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 nudge_says_clean_when_no_critical_or_high() {
let dir = TempDir::new().unwrap();
init_repo(dir.path());
commit(dir.path(), "lib.rs", "fn ok() {}\n", "a@b.c", "init");
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
let cfg = crate::core::config::Config::default();
cfg.save(&paths.config()).unwrap();
crate::commands::calibrate::run(dir.path(), false, false).unwrap();
let reports = run_all(dir.path(), &cfg, None, None);
let (calibration, findings) = classify_with_calibration(&paths, &cfg, &reports);
let mut buf: Vec<u8> = Vec::new();
write_nudge(calibration.as_ref(), &findings, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert_eq!(out.trim_end(), "heal: recorded · clean");
}
}