use std::fs;
use serde_json::{json, Value};
use super::{parse_json_input, validate_edit_path};
#[derive(Debug, Clone, PartialEq, Eq)]
enum FuzzyError {
NotFound,
EmptyBefore,
Ambiguous,
}
pub(super) fn schemas() -> Vec<Value> {
vec![json!({
"type": "function",
"function": {
"name": "apply_diff",
"description": "Replace a `before` block with an `after` block inside `path`. Whitespace-drift tolerant: exact match first, then a line-trim fallback that ignores indentation / trailing-whitespace / CRLF differences. Prefer this over `apply_patch` for targeted edits — it succeeds where a strict unified diff fails on context drift. The `before` block must be unique in the file (widen it with more surrounding lines if the call reports it is ambiguous).",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File to edit (inside the sandbox / active mission)." },
"before": { "type": "string", "description": "The exact block to find and replace. Must occur exactly once." },
"after": { "type": "string", "description": "The replacement block." }
},
"required": ["path", "before", "after"]
}
}
})]
}
pub(super) fn dispatch(name: &str, input: &str) -> Option<Result<String, String>> {
let result = match name {
"apply_diff" => run_apply_diff(input),
_ => return None,
};
Some(result)
}
fn run_apply_diff(input: &str) -> Result<String, String> {
let v = parse_json_input(input, "apply_diff")?;
let raw_path = v
.get("path")
.and_then(Value::as_str)
.ok_or("apply_diff: missing 'path'")?;
let before = v
.get("before")
.and_then(Value::as_str)
.ok_or("apply_diff: missing 'before'")?;
let after = v
.get("after")
.and_then(Value::as_str)
.ok_or("apply_diff: missing 'after'")?;
let path = validate_edit_path(raw_path).map_err(|e| format!("apply_diff: {raw_path}: {e}"))?;
let original = fs::read_to_string(&path)
.map_err(|e| format!("apply_diff: read {} failed: {e}", path.display()))?;
match fuzzy_replace(&original, before, after) {
Ok(new_content) => {
let tmp = path.with_extension("claudette-diff.tmp");
fs::write(&tmp, &new_content)
.map_err(|e| format!("apply_diff: write tmp {} failed: {e}", tmp.display()))?;
fs::rename(&tmp, &path).map_err(|e| {
let _ = fs::remove_file(&tmp);
format!("apply_diff: rename to {} failed: {e}", path.display())
})?;
eprintln!(
" {} {}",
crate::theme::dim("▸"),
crate::theme::dim(&format!(
"apply_diff: {raw_path} ({} → {} bytes)",
original.len(),
new_content.len()
)),
);
Ok(json!({
"ok": true,
"path": raw_path,
"bytes_before": original.len(),
"bytes_after": new_content.len(),
})
.to_string())
}
Err(e) => {
let msg = match e {
FuzzyError::NotFound => format!(
"apply_diff: 'before' block not found in {raw_path} (tried exact + line-trim \
match). Re-read the file and copy the block exactly, or widen the context."
),
FuzzyError::Ambiguous => format!(
"apply_diff: 'before' block matched in multiple places in {raw_path} — \
ambiguous. Add more surrounding lines so the block is unique."
),
FuzzyError::EmptyBefore => {
"apply_diff: 'before' is empty (nothing to find)".to_string()
}
};
eprintln!(
" {} {}",
crate::theme::dim("▸"),
crate::theme::dim(&format!("apply_diff: {raw_path} failed — {msg}")),
);
Err(msg)
}
}
}
fn line_anchored(content: &str, idx: usize, len: usize) -> bool {
let b = content.as_bytes();
let start_ok = idx == 0 || b.get(idx.wrapping_sub(1)) == Some(&b'\n');
let end = idx + len;
let end_ok = end == content.len() || b.get(end - 1) == Some(&b'\n');
start_ok && end_ok
}
fn normalize_eol(text: &str, crlf: bool) -> String {
let lf = text.replace("\r\n", "\n");
if crlf {
lf.replace('\n', "\r\n")
} else {
lf
}
}
fn fuzzy_replace(content: &str, before: &str, after: &str) -> Result<String, FuzzyError> {
if before.is_empty() {
return Err(FuzzyError::EmptyBefore);
}
let mut hits: Vec<usize> = Vec::new();
let mut from = 0usize;
while let Some(rel) = content[from..].find(before) {
let idx = from + rel;
if line_anchored(content, idx, before.len()) {
hits.push(idx);
}
from = idx + 1;
}
match hits.len() {
0 => {} 1 => {
let idx = hits[0];
let crlf = if before.contains("\r\n") {
true
} else if before.contains('\n') {
false
} else {
content.contains("\r\n")
};
let mut after_norm = normalize_eol(after, crlf);
if before.ends_with('\n') && !after_norm.is_empty() && !after_norm.ends_with('\n') {
after_norm.push_str(if crlf { "\r\n" } else { "\n" });
}
return Ok(splice(content, idx, before.len(), &after_norm));
}
_ => return Err(FuzzyError::Ambiguous),
}
let content_lines: Vec<&str> = content.split_inclusive('\n').collect();
let before_lines: Vec<&str> = before.split_inclusive('\n').collect();
let m = before_lines.len();
let n = content_lines.len();
if m == 0 || n < m {
return Err(FuzzyError::NotFound);
}
let before_trim: Vec<&str> = before_lines.iter().map(|l| l.trim()).collect();
let mut first_hit: Option<usize> = None;
let mut hit_count = 0;
for i in 0..=(n - m) {
let window_matches = (0..m).all(|j| content_lines[i + j].trim() == before_trim[j]);
if window_matches {
if first_hit.is_none() {
first_hit = Some(i);
}
hit_count += 1;
if hit_count > 1 {
return Err(FuzzyError::Ambiguous);
}
}
}
let Some(i) = first_hit else {
return Err(FuzzyError::NotFound);
};
let window_crlf = content_lines[i..i + m].iter().any(|l| l.ends_with("\r\n"));
let mut after_norm = normalize_eol(after, window_crlf);
if !after_norm.is_empty()
&& !after_norm.ends_with('\n')
&& content_lines[i..i + m]
.last()
.is_some_and(|l| l.ends_with('\n'))
{
after_norm.push_str(if window_crlf { "\r\n" } else { "\n" });
}
let mut out = String::with_capacity(content.len() + after_norm.len());
for line in &content_lines[..i] {
out.push_str(line);
}
out.push_str(&after_norm);
for line in &content_lines[i + m..] {
out.push_str(line);
}
Ok(out)
}
fn splice(content: &str, start: usize, len: usize, insert: &str) -> String {
let mut out = String::with_capacity(content.len() - len + insert.len());
out.push_str(&content[..start]);
out.push_str(insert);
out.push_str(&content[start + len..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schemas_lists_one_tool() {
let schemas = schemas();
assert_eq!(schemas.len(), 1);
let names: Vec<&str> = schemas
.iter()
.filter_map(|v| v.pointer("/function/name").and_then(Value::as_str))
.collect();
assert_eq!(names, ["apply_diff"]);
}
#[test]
fn exact_match_replaces() {
let content = "fn main() {\n println!(\"hello\");\n}\n";
let before = " println!(\"hello\");\n";
let after = " println!(\"world\");\n";
let got = fuzzy_replace(content, before, after).unwrap();
assert!(got.contains("world"));
assert!(!got.contains("hello"));
}
#[test]
fn exact_multiple_matches_is_ambiguous() {
let content = "alpha\nalpha\nalpha\n";
let err = fuzzy_replace(content, "alpha\n", "beta\n").unwrap_err();
assert_eq!(err, FuzzyError::Ambiguous);
}
#[test]
fn whitespace_drift_falls_back_to_line_trim() {
let content = "fn foo() {\n let x = 1;\n let y = 2;\n}\n";
let before = " let x = 1;\n let y = 2;\n";
let after = " let x = 99;\n let y = 100;\n";
let got = fuzzy_replace(content, before, after).unwrap();
assert!(got.contains("let x = 99"));
assert!(got.contains("let y = 100"));
assert!(!got.contains("let x = 1"));
}
#[test]
fn crlf_in_content_lf_in_diff_falls_back() {
let content = "alpha\r\nbeta\r\ngamma\r\n";
let before = "alpha\nbeta\n";
let after = "ALPHA\nBETA\n";
let got = fuzzy_replace(content, before, after).unwrap();
assert_eq!(got, "ALPHA\r\nBETA\r\ngamma\r\n", "got: {got:?}");
}
#[test]
fn exact_pass_does_not_edit_a_substring_inside_a_comment() {
let content = "// TODO: set rate = 0.05 properly\nrate = 0.10\n";
let before = "rate = 0.05";
let after = "rate = 0.20";
let err = fuzzy_replace(content, before, after).unwrap_err();
assert_eq!(err, FuzzyError::NotFound, "must not edit the comment");
}
#[test]
fn exact_pass_does_not_edit_mid_token() {
let content = "max=10\n";
let err = fuzzy_replace(content, "ax=10", "ax=99").unwrap_err();
assert_eq!(err, FuzzyError::NotFound);
}
#[test]
fn overlapping_repeats_are_ambiguous() {
let content = "ab\nab\nab\n";
let err = fuzzy_replace(content, "ab\nab\n", "X\n").unwrap_err();
assert_eq!(err, FuzzyError::Ambiguous);
}
#[test]
fn whole_line_before_without_trailing_newline_still_matches_via_trim() {
let content = "alpha\nfoo\nbeta\n";
let got = fuzzy_replace(content, "foo", "bar").unwrap();
assert_eq!(got, "alpha\nbar\nbeta\n");
}
#[test]
fn empty_before_errors() {
let err = fuzzy_replace("anything", "", "x").unwrap_err();
assert_eq!(err, FuzzyError::EmptyBefore);
}
#[test]
fn missing_before_returns_not_found() {
let err = fuzzy_replace("alpha\nbeta\n", "delta\n", "x").unwrap_err();
assert_eq!(err, FuzzyError::NotFound);
}
#[test]
fn line_trim_ambiguity_detected() {
let content = " x = 1\n x = 1\n";
let err = fuzzy_replace(content, "x = 1\n", "x = 2\n").unwrap_err();
assert_eq!(err, FuzzyError::Ambiguous);
}
#[test]
fn replacement_preserves_surrounding_content() {
let content = "preamble\nold body\npostamble\n";
let got = fuzzy_replace(content, "old body\n", "new body\n").unwrap();
assert_eq!(got, "preamble\nnew body\npostamble\n");
}
#[test]
fn after_without_trailing_newline_gets_one() {
let content = "alpha\nold\nbeta\n";
let got = fuzzy_replace(content, "old\n", "new").unwrap();
assert!(got.contains("\nnew\n"), "got: {got:?}");
}
#[test]
fn last_block_in_file_with_no_trailing_newline() {
let content = "alpha\nbeta\nfinal";
let before = "final";
let after = "FINAL";
let got = fuzzy_replace(content, before, after).unwrap();
assert!(got.ends_with("FINAL"));
}
#[test]
fn run_apply_diff_rejects_missing_before() {
let err = run_apply_diff(r#"{"path":"x","after":"y"}"#).unwrap_err();
assert!(err.contains("missing 'before'"), "got: {err}");
}
}