use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
use serde_json::Value;
use sha2::{Digest, Sha256};
use super::payload;
const DEDUP_CAPACITY: usize = 32;
const EDIT_TOOLS: &[&str] = &[
"str_replace_editor",
"edit_file",
"StrReplace",
"MultiEdit",
"str_replace",
"replace_in_file",
];
const WRITE_TOOLS: &[&str] = &["Write", "write_to_file", "create_file", "write_file"];
static RECENT_EDITS: Mutex<RecentEdits> = Mutex::new(RecentEdits::new());
struct RecentEdits {
hashes: [u64; DEDUP_CAPACITY],
cursor: usize,
count: usize,
}
impl RecentEdits {
const fn new() -> Self {
Self {
hashes: [0; DEDUP_CAPACITY],
cursor: 0,
count: 0,
}
}
fn contains(&self, hash: u64) -> bool {
let len = self.count.min(DEDUP_CAPACITY);
self.hashes[..len].contains(&hash)
}
fn insert(&mut self, hash: u64) {
self.hashes[self.cursor] = hash;
self.cursor = (self.cursor + 1) % DEDUP_CAPACITY;
if self.count < DEDUP_CAPACITY {
self.count += 1;
}
}
}
pub fn maybe_capture(input: &str) {
let Ok(v) = serde_json::from_str::<Value>(input) else {
return;
};
let Some(tool) = payload::resolve_tool_name(&v) else {
return;
};
if tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__") {
return;
}
let Some(args) = payload::resolve_tool_args(&v) else {
return;
};
let root = resolve_root(&v);
if root.is_empty() {
return;
}
let provenance_config = crate::core::config::Config::load().provenance;
let capture_provenance = provenance_config.enabled && provenance_config.capture_native_edits;
if is_edit_tool(&tool) {
handle_edit_tool(&v, &args, &tool, &root, capture_provenance);
} else if is_write_tool(&tool) {
handle_write_tool(&v, &args, &tool, &root, capture_provenance);
}
}
pub fn handle_post_commit() {
let config = crate::core::config::Config::load();
if !config.provenance.enabled || !config.provenance.checkpoint_on_commit {
return;
}
let Ok(root) = std::env::current_dir() else {
return;
};
let Ok(output) = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&root)
.output()
else {
return;
};
if !output.status.success() {
return;
}
let commit_sha = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if commit_sha.is_empty() {
return;
}
let root = root.to_string_lossy().into_owned();
let session_id = crate::core::session::SessionState::load_latest_for_project_root(&root)
.map(|session| session.id)
.unwrap_or_else(|| "git-hook".to_owned());
let _ = crate::core::provenance::ProvenanceTracker::new(&root)
.and_then(|tracker| tracker.observe_commit(&commit_sha, &session_id));
}
fn is_edit_tool(tool: &str) -> bool {
EDIT_TOOLS.iter().any(|&t| tool.contains(t))
}
fn is_write_tool(tool: &str) -> bool {
WRITE_TOOLS.iter().any(|&t| tool.contains(t))
}
fn handle_edit_tool(v: &Value, args: &Value, tool: &str, root: &str, capture_provenance: bool) {
let Some((_field, path)) = payload::resolve_path_field(Some(args), payload::READ_PATH_FIELDS)
else {
return;
};
let edits = collect_edits(args);
if edits.is_empty() {
return;
}
for edit in &edits {
let hash = compute_edit_hash(&path, &edit.old, &edit.new);
let mut dedup = RECENT_EDITS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if dedup.contains(hash) {
continue;
}
dedup.insert(hash);
drop(dedup);
crate::core::solution_auto_capture::capture_edit_decisions(
root, &path, &edit.old, &edit.new,
);
let old_lines = edit.old.lines().count() as u64;
let new_lines = edit.new.lines().count() as u64;
let added = new_lines.saturating_sub(old_lines);
let removed = old_lines.saturating_sub(new_lines);
if added > 0 || removed > 0 {
crate::core::edit_metering::record_loc_change(Some(&path), added, removed);
}
if capture_provenance {
observe_native_edit(
v, root, &path, tool, &edit.old, &edit.new, new_lines, old_lines,
);
}
}
if let Some(result) = v.get("toolResult").and_then(|r| r.get("textResultForLlm")) {
let _ = result; }
}
fn handle_write_tool(v: &Value, args: &Value, tool: &str, root: &str, capture_provenance: bool) {
let content = args
.get("contents")
.or_else(|| args.get("content"))
.or_else(|| args.get("file_text"))
.and_then(Value::as_str)
.unwrap_or("");
if content.is_empty() {
return;
}
let lines = content.lines().count() as u64;
if lines > 0 {
crate::core::edit_metering::record_loc_change(None, lines, 0);
if let Some((_, ref fp)) =
payload::resolve_path_field(Some(args), payload::READ_PATH_FIELDS)
{
crate::core::solution_auto_capture::capture_edit_decisions(root, fp, "", content);
}
if let Some((_, file_path)) =
payload::resolve_path_field(Some(args), payload::READ_PATH_FIELDS)
{
if capture_provenance {
crate::core::savings_ledger::record_edit_event(&file_path, lines, 0);
observe_native_edit(v, root, &file_path, tool, "", content, lines, 0);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn observe_native_edit(
event: &Value,
root: &str,
path: &str,
tool: &str,
before: &str,
after: &str,
lines_added: u64,
lines_removed: u64,
) {
let tracked_path = Path::new(path)
.strip_prefix(root)
.ok()
.filter(|relative| !relative.as_os_str().is_empty())
.map(|relative| relative.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_owned());
let session_id = event
.get("session_id")
.and_then(Value::as_str)
.unwrap_or("native-hook");
let agent_id = event
.get("agent_id")
.or_else(|| event.get("client_name"))
.and_then(Value::as_str)
.unwrap_or("native");
let _ = crate::core::provenance::ProvenanceTracker::new(root).and_then(|tracker| {
tracker.observe_edit(
tracked_path,
tool,
sha256_hex(before),
sha256_hex(after),
lines_added,
lines_removed,
session_id,
agent_id,
)
});
}
fn sha256_hex(content: &str) -> String {
let hash = Sha256::digest(content.as_bytes());
let mut hex = String::with_capacity(64);
for b in &hash {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
struct EditReplacement {
old: String,
new: String,
}
fn collect_edits(args: &Value) -> Vec<EditReplacement> {
if let Some(arr) = args.get("edits").and_then(Value::as_array) {
return arr.iter().filter_map(replacement_from).collect();
}
replacement_from(args).into_iter().collect()
}
fn replacement_from(obj: &Value) -> Option<EditReplacement> {
let old = obj
.get("old_string")
.or_else(|| obj.get("old_str"))
.and_then(Value::as_str)?;
let new = obj
.get("new_string")
.or_else(|| obj.get("new_str"))
.or_else(|| obj.get("code_edit"))
.and_then(Value::as_str)
.unwrap_or("");
Some(EditReplacement {
old: old.to_string(),
new: new.to_string(),
})
}
fn compute_edit_hash(path: &str, old: &str, new: &str) -> u64 {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
old.hash(&mut hasher);
new.hash(&mut hasher);
hasher.finish()
}
pub(crate) fn sanitize_json_control_chars(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 32);
let mut in_string = false;
let mut prev_backslash = false;
for ch in input.chars() {
if in_string {
if prev_backslash {
prev_backslash = false;
out.push(ch);
continue;
}
match ch {
'\\' => {
prev_backslash = true;
out.push(ch);
}
'"' => {
in_string = false;
out.push(ch);
}
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ =
std::fmt::Write::write_fmt(&mut out, format_args!("\\u{:04x}", c as u32));
}
_ => out.push(ch),
}
} else {
if ch == '"' && !prev_backslash {
in_string = true;
}
prev_backslash = ch == '\\';
out.push(ch);
}
}
out
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn skips_ctx_tools() {
let input = json!({
"tool_name": "ctx_edit",
"tool_input": { "path": "foo.rs", "old_string": "a", "new_string": "b" },
"cwd": "/tmp/proj"
});
maybe_capture(&input.to_string());
}
#[test]
fn skips_mcp_lean_ctx_tools() {
let input = json!({
"tool_name": "mcp__lean-ctx__ctx_edit",
"tool_input": { "path": "foo.rs", "old_string": "a", "new_string": "b" },
"cwd": "/tmp/proj"
});
maybe_capture(&input.to_string());
}
#[test]
fn captures_cursor_str_replace() {
let input = json!({
"tool_name": "str_replace_editor",
"tool_input": {
"path": "/tmp/proj/src/main.rs",
"old_string": "use serde;",
"new_string": "use std::collections::HashMap;"
},
"cwd": "/tmp/proj"
});
maybe_capture(&input.to_string());
}
#[test]
fn captures_claude_edit_file() {
let input = json!({
"tool_name": "edit_file",
"tool_input": {
"file_path": "/tmp/proj/lib.rs",
"old_string": "fn old() {}",
"new_string": "fn new() {}"
},
"cwd": "/tmp/proj"
});
maybe_capture(&input.to_string());
}
#[test]
fn captures_codex_str_replace() {
let input = json!({
"toolName": "str_replace",
"toolArgs": {
"path": "/tmp/proj/mod.rs",
"old_string": "let x = 1;",
"new_string": "let x = 2;"
},
"cwd": "/tmp/proj"
});
maybe_capture(&input.to_string());
}
#[test]
fn captures_write_tool_loc_only() {
let input = json!({
"tool_name": "Write",
"tool_input": {
"path": "/tmp/proj/new.rs",
"contents": "fn main() {\n println!(\"hello\");\n}\n"
},
"cwd": "/tmp/proj"
});
maybe_capture(&input.to_string());
}
#[test]
fn dedup_prevents_double_count() {
let input = json!({
"tool_name": "StrReplace",
"tool_input": {
"path": "/tmp/proj/dedup_test.rs",
"old_string": "UNIQUE_DEDUP_TEST_STRING_XYZ",
"new_string": "UNIQUE_DEDUP_TEST_REPLACEMENT_XYZ"
},
"cwd": "/tmp/proj"
});
let s = input.to_string();
maybe_capture(&s);
maybe_capture(&s);
}
#[test]
fn no_crash_on_missing_fields() {
let inputs = [
json!({}),
json!({"tool_name": "StrReplace"}),
json!({"tool_name": "StrReplace", "tool_input": {}}),
json!({"tool_name": "StrReplace", "tool_input": {"path": "x.rs"}}),
json!({"random": "data"}),
];
for input in &inputs {
maybe_capture(&input.to_string());
}
}
#[test]
fn multi_edit_extracts_all() {
let edits = collect_edits(&json!({
"edits": [
{ "old_string": "a", "new_string": "b" },
{ "old_string": "c", "new_string": "d" }
]
}));
assert_eq!(edits.len(), 2);
assert_eq!(edits[0].old, "a");
assert_eq!(edits[1].old, "c");
}
#[test]
fn recent_edits_ring_buffer() {
let mut ring = RecentEdits::new();
for i in 0..DEDUP_CAPACITY {
ring.insert(i as u64 + 100);
}
assert!(ring.contains(100));
assert!(ring.contains(100 + DEDUP_CAPACITY as u64 - 1));
ring.insert(999);
assert!(!ring.contains(100));
assert!(ring.contains(999));
}
}