use std::io::{IsTerminal, Read, Write};
use std::path::Path;
use crate::core::calibration::Calibration;
use crate::core::config::load_from_project;
use crate::core::eventlog::{Event, EventLog};
use crate::core::finding::Finding;
use crate::core::severity::Severity;
use crate::core::snapshot::{
ansi_wrap, MetricsSnapshot, ANSI_CYAN, ANSI_GREEN, ANSI_RED, ANSI_YELLOW,
};
use crate::core::HealPaths;
use crate::observers::run_all;
use anyhow::Result;
use crate::cli::HookEvent;
use crate::snapshot;
pub fn run(project: &Path, event: HookEvent) -> Result<()> {
let paths = HealPaths::new(project);
let logs = EventLog::new(paths.logs_dir());
match event {
HookEvent::Commit => run_commit(project, &paths, &logs)?,
HookEvent::Edit | HookEvent::Stop => {
logs.append(&Event::new(event.as_str(), capture_stdin()?))?;
}
}
Ok(())
}
fn run_commit(project: &Path, paths: &HealPaths, logs: &EventLog) -> Result<()> {
let cfg = match load_from_project(project) {
Ok(c) => c,
Err(crate::core::Error::ConfigMissing(_)) => {
EventLog::new(paths.snapshots_dir()).append(&Event::new(
HookEvent::Commit.as_str(),
serde_json::to_value(MetricsSnapshot::default())
.expect("MetricsSnapshot serialization is infallible"),
))?;
logs.append(&Event::new(
HookEvent::Commit.as_str(),
commit_log_payload(project),
))?;
return Ok(());
}
Err(e) => return Err(e.into()),
};
let reports = run_all(project, &cfg, None);
let (calibration, findings) = snapshot::classify_with_calibration(paths, &cfg, &reports);
let snap = snapshot::pack_with_delta(project, paths, &cfg, &reports, &findings);
EventLog::new(paths.snapshots_dir()).append(&Event::new(
HookEvent::Commit.as_str(),
serde_json::to_value(&snap).expect("MetricsSnapshot serialization is infallible"),
))?;
logs.append(&Event::new(
HookEvent::Commit.as_str(),
commit_log_payload(project),
))?;
crate::core::compaction::compact_all(
paths,
&crate::core::compaction::CompactionPolicy::default(),
chrono::Utc::now(),
)
.ok();
write_nudge(calibration.as_ref(), &findings, &mut std::io::stdout()).ok();
Ok(())
}
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| matches!(f.severity, Severity::Critical))
.count();
let high = findings
.iter()
.filter(|f| 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 check", colorize),
)?;
Ok(())
}
fn commit_log_payload(project: &Path) -> serde_json::Value {
let Some(info) = crate::observer::git::head_commit_info(project) else {
eprintln!("heal: commit metadata unavailable (HEAD missing or not a git repo)");
return serde_json::Value::Null;
};
serde_json::to_value(&info).expect("CommitInfo serialization is infallible")
}
fn capture_stdin() -> Result<serde_json::Value> {
let stdin = std::io::stdin();
if stdin.is_terminal() {
return Ok(serde_json::Value::Null);
}
let mut buf = String::new();
stdin.lock().read_to_string(&mut buf)?;
if buf.trim().is_empty() {
return Ok(serde_json::Value::Null);
}
Ok(match serde_json::from_str(&buf) {
Ok(v) => v,
Err(_) => serde_json::Value::String(buf),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{commit, init_repo};
use tempfile::TempDir;
fn read_log_events(paths: &HealPaths) -> Vec<Event> {
EventLog::new(paths.logs_dir())
.try_iter()
.unwrap()
.map(|r| r.unwrap())
.collect()
}
#[test]
fn commit_writes_to_both_snapshots_and_logs() {
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();
let snap_events: Vec<Event> = EventLog::new(paths.snapshots_dir())
.try_iter()
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(snap_events.len(), 1);
assert_eq!(snap_events[0].event, HookEvent::Commit.as_str());
let log_events = read_log_events(&paths);
assert_eq!(log_events.len(), 1);
assert_eq!(log_events[0].event, HookEvent::Commit.as_str());
let info: crate::observer::git::CommitInfo =
serde_json::from_value(log_events[0].data.clone()).unwrap();
assert_eq!(info.author_email.as_deref(), Some("alice@example.com"));
assert_eq!(info.message_summary, "feat: add ok");
assert_eq!(info.files_changed, 1);
assert!(info.insertions >= 1);
}
#[test]
fn edit_only_writes_to_logs() {
let dir = TempDir::new().unwrap();
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
run(dir.path(), HookEvent::Edit).unwrap();
let snap_files: usize = std::fs::read_dir(paths.snapshots_dir()).unwrap().count();
assert_eq!(snap_files, 0);
let log_events = read_log_events(&paths);
assert_eq!(log_events.len(), 1);
assert_eq!(log_events[0].event, HookEvent::Edit.as_str());
assert!(log_events[0].data.is_null());
}
#[test]
fn stop_only_writes_to_logs() {
let dir = TempDir::new().unwrap();
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
run(dir.path(), HookEvent::Stop).unwrap();
let log_events = read_log_events(&paths);
assert_eq!(log_events.len(), 1);
assert_eq!(log_events[0].event, HookEvent::Stop.as_str());
}
#[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);
let (calibration, findings) =
crate::snapshot::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).unwrap();
let reports = run_all(dir.path(), &cfg, None);
let (calibration, findings) =
crate::snapshot::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 check"),
"missing nudge to heal check: {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).unwrap();
let reports = run_all(dir.path(), &cfg, None);
let (calibration, findings) =
crate::snapshot::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");
}
}