use super::payload;
use crate::core::code_health::GateMode;
use crate::core::code_health::gate::{self, GateOutcome};
use crate::core::config::Config;
use serde_json::Value;
const MAX_EDIT_BYTES: usize = 1_000_000;
struct Replacement {
old: String,
new: String,
replace_all: bool,
}
pub(super) fn maybe_emit(input: &str) {
let Ok(v) = serde_json::from_str::<Value>(input) else {
return;
};
let root = resolve_root(&v);
if let Some(notice) = edit_health_notice(&v, &root) {
persist_notice_to_knowledge(¬ice, &v, &root);
if inject_context_enabled() {
emit_post_tool_use_context(¬ice);
}
}
}
fn inject_context_enabled() -> bool {
std::env::var("LEAN_CTX_INJECT_CONTEXT").is_ok() || Config::load().code_health.inject_context
}
fn persist_notice_to_knowledge(notice: &str, payload: &Value, root: &str) {
let session_id = payload
.get("session_id")
.and_then(|s| s.as_str())
.unwrap_or("unknown");
let file_path = super::payload::resolve_path_field(
super::payload::resolve_tool_args(payload).as_ref(),
super::payload::READ_PATH_FIELDS,
)
.map(|(_, p)| p)
.unwrap_or_default();
let key = if file_path.is_empty() {
"edit_regression".to_string()
} else {
format!("edit_regression:{file_path}")
};
let policy = crate::core::memory_policy::MemoryPolicy::default();
let _ = crate::core::knowledge::ProjectKnowledge::mutate_locked(root, |pk| {
pk.remember("code_health", &key, notice, session_id, 0.9, &policy);
});
crate::core::context_os::emit_event(
root,
"code_health",
&crate::core::context_os::ContextEventKindV1::KnowledgeRemembered,
Some("edit_health_hook"),
serde_json::json!({
"category": "code_health",
"key": key,
"notice": notice,
"file": file_path,
}),
);
}
fn resolve_root(v: &Value) -> String {
v.get("cwd")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
.or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned())
})
.unwrap_or_default()
}
pub(super) fn edit_health_notice(v: &Value, root: &str) -> Option<String> {
let cfg = Config::load();
notice_with(
v,
root,
GateMode::parse(&cfg.code_health.gate),
cfg.code_health.cognitive_threshold,
)
}
fn notice_with(v: &Value, root: &str, mode: GateMode, threshold: u32) -> Option<String> {
if matches!(mode, GateMode::Off) {
return None;
}
let tool = payload::resolve_tool_name(v)?;
if tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__") {
return None;
}
let args = payload::resolve_tool_args(v)?;
let (_field, file) = payload::resolve_path_field(Some(&args), payload::READ_PATH_FIELDS)?;
let edits = collect_edits(&args)?;
let after = read_jailed(&file, root)?;
if after.len() > MAX_EDIT_BYTES {
return None;
}
let before = reverse_edits(&after, &edits)?;
if before == after {
return None;
}
let ext = std::path::Path::new(&file)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match gate::evaluate_with(&before, &after, ext, mode, threshold) {
GateOutcome::Allow(Some(notice)) | GateOutcome::Block(notice) => Some(notice),
GateOutcome::Allow(None) => None,
}
}
fn collect_edits(args: &Value) -> Option<Vec<Replacement>> {
if let Some(arr) = args.get("edits").and_then(Value::as_array) {
let edits: Vec<Replacement> = arr.iter().filter_map(replacement_from).collect();
return (!edits.is_empty()).then_some(edits);
}
replacement_from(args).map(|r| vec![r])
}
fn replacement_from(obj: &Value) -> Option<Replacement> {
let old = obj.get("old_string").and_then(Value::as_str)?.to_string();
let new = obj.get("new_string").and_then(Value::as_str)?.to_string();
let replace_all = obj
.get("replace_all")
.and_then(Value::as_bool)
.unwrap_or(false);
Some(Replacement {
old,
new,
replace_all,
})
}
fn reverse_edits(after: &str, edits: &[Replacement]) -> Option<String> {
let mut content = after.to_string();
for e in edits.iter().rev() {
if e.new.is_empty() {
return None; }
if !content.contains(&e.new) {
return None; }
content = if e.replace_all {
content.replace(&e.new, &e.old)
} else {
content.replacen(&e.new, &e.old, 1)
};
}
Some(content)
}
fn read_jailed(file: &str, root: &str) -> Option<String> {
let p = std::path::Path::new(file);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
std::path::Path::new(root).join(file)
};
crate::core::pathjail::jail_path(&abs, std::path::Path::new(root)).ok()?;
std::fs::read_to_string(&abs).ok()
}
fn emit_post_tool_use_context(notice: &str) {
let payload = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": notice,
}
});
println!("{payload}");
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const FLAT: &str = "fn f(a: bool) { if a {} }";
const DEEP: &str = "fn f(a: bool) { if a { if a { if a { if a { if a { if a {} } } } } } }";
#[test]
fn reverse_single_edit_reconstructs_before() {
let after = format!("{DEEP}\n");
let edits = vec![Replacement {
old: FLAT.into(),
new: DEEP.into(),
replace_all: false,
}];
assert_eq!(reverse_edits(&after, &edits).unwrap(), format!("{FLAT}\n"));
}
#[test]
fn reverse_insertion_removes_new_text() {
let edits = vec![Replacement {
old: String::new(),
new: "fn extra() {}\n".into(),
replace_all: false,
}];
let after = "fn extra() {}\nfn keep() {}\n";
assert_eq!(reverse_edits(after, &edits).unwrap(), "fn keep() {}\n");
}
#[test]
fn reverse_deletion_bails() {
let edits = vec![Replacement {
old: "fn gone() {}\n".into(),
new: String::new(),
replace_all: false,
}];
assert!(reverse_edits("fn keep() {}\n", &edits).is_none());
}
#[test]
fn reverse_missing_new_text_bails() {
let edits = vec![Replacement {
old: "a".into(),
new: "NOT_PRESENT".into(),
replace_all: false,
}];
assert!(reverse_edits("some other content", &edits).is_none());
}
#[test]
fn collect_edits_single_and_multi() {
let single = json!({ "old_string": "a", "new_string": "b" });
assert_eq!(collect_edits(&single).unwrap().len(), 1);
let multi = json!({ "edits": [
{ "old_string": "a", "new_string": "b" },
{ "old_string": "c", "new_string": "d", "replace_all": true },
]});
let edits = collect_edits(&multi).unwrap();
assert_eq!(edits.len(), 2);
assert!(edits[1].replace_all);
let write = json!({ "content": "whole file" });
assert!(collect_edits(&write).is_none());
}
#[test]
fn notice_for_native_edit_that_regresses() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap();
std::fs::write(dir.path().join("f.rs"), format!("{DEEP}\n")).unwrap();
let v = json!({
"tool_name": "Edit",
"cwd": root,
"tool_input": {
"file_path": "f.rs",
"old_string": FLAT,
"new_string": DEEP,
}
});
let notice = notice_with(&v, root, GateMode::Warn, 15).expect("notice");
assert!(notice.contains("[CODE HEALTH]"));
}
#[test]
fn no_notice_for_ctx_edit_tool() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap();
std::fs::write(dir.path().join("f.rs"), format!("{DEEP}\n")).unwrap();
let v = json!({
"tool_name": "ctx_edit",
"cwd": root,
"tool_input": { "file_path": "f.rs", "old_string": FLAT, "new_string": DEEP }
});
assert!(notice_with(&v, root, GateMode::Warn, 15).is_none());
}
#[test]
fn no_notice_in_off_mode() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap();
std::fs::write(dir.path().join("f.rs"), format!("{DEEP}\n")).unwrap();
let v = json!({
"tool_name": "Edit",
"cwd": root,
"tool_input": { "file_path": "f.rs", "old_string": FLAT, "new_string": DEEP }
});
assert!(notice_with(&v, root, GateMode::Off, 15).is_none());
}
#[test]
fn no_notice_for_non_edit_event() {
let v = json!({ "tool_name": "Read", "tool_input": { "file_path": "f.rs" } });
assert!(notice_with(&v, "/tmp", GateMode::Warn, 15).is_none());
}
#[test]
fn inject_context_disabled_by_default() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::remove_var("LEAN_CTX_INJECT_CONTEXT");
assert!(!inject_context_enabled());
}
#[test]
fn inject_context_enabled_via_env() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::set_var("LEAN_CTX_INJECT_CONTEXT", "1");
assert!(inject_context_enabled());
crate::test_env::remove_var("LEAN_CTX_INJECT_CONTEXT");
}
}