use anyhow::{Context, Result, bail};
use async_trait::async_trait;
#[cfg(feature = "code-vfs")]
use navi_vfs::code::{replace_symbol_definition, symbols_for_source};
use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use super::helpers;
use crate::tool::{Tool, ToolDefinition, ToolInvocation, ToolKind, ToolResult};
const PATCH_CONTEXT_RADIUS: usize = 20;
const MAX_PATCH_CONTEXT_WINDOWS: usize = 6;
pub(crate) struct WriteTool {
project_root: PathBuf,
name: &'static str,
mode: WriteToolMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteToolMode {
Unified,
Direct,
Patch,
}
impl WriteTool {
pub(crate) fn new(project_root: PathBuf) -> Self {
Self {
project_root,
name: "write",
mode: WriteToolMode::Unified,
}
}
fn alias(project_root: PathBuf, name: &'static str, mode: WriteToolMode) -> Self {
Self {
project_root,
name,
mode,
}
}
pub(crate) fn write_file(project_root: PathBuf) -> Self {
Self::alias(project_root, "write_file", WriteToolMode::Direct)
}
pub(crate) fn apply_patch(project_root: PathBuf) -> Self {
Self::alias(project_root, "apply_patch", WriteToolMode::Patch)
}
}
#[async_trait]
impl Tool for WriteTool {
fn definition(&self) -> ToolDefinition {
helpers::definition(
self.name,
match self.mode {
WriteToolMode::Unified => {
"Write content to files or apply patches. Two modes:\n\n\
**Direct write** (use for creating new files or full replacements):\n\
Pass `path` (project-relative) and `content` (full UTF-8 text).\n\n\
**Patch mode** (use for surgical edits to existing files):\n\
Pass `patch` (one patch string) or `patches` (array of patch strings).\n\
The preferred format is the structured patch format:\n\
```\n\
*** Begin Patch\n\
*** Update File: path\n\
@@\n\
context line\n\
-old line to remove\n\
+new line to add\n\
*** End Patch\n\
```\n\
Also supports: `*** Add File: path`, `*** Delete File: path`, `*** Move to: target`.\n\
Unified diff (`--- a/`, `+++ b/`, `@@` hunks) is also accepted."
}
WriteToolMode::Direct => {
"Write full UTF-8 content to a single project file, creating parent directories when needed."
}
WriteToolMode::Patch => {
"Apply one or more structured patches or unified diffs to project files. Prefer `edit`/`multiedit` for single-file exact string replacements."
}
},
ToolKind::Write,
match self.mode {
WriteToolMode::Unified => write_json_schema(),
WriteToolMode::Direct => direct_write_json_schema(),
WriteToolMode::Patch => patch_write_json_schema(),
},
)
}
async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolResult> {
let input = &invocation.input;
let has_direct = input
.get("path")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty())
&& input.get("content").and_then(Value::as_str).is_some();
let has_patch = input
.get("patch")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty());
let has_patches = input
.get("patches")
.and_then(Value::as_array)
.map(|a| a.iter().any(|v| v.as_str().is_some_and(|s| !s.is_empty())))
.unwrap_or(false);
let has_edits = input
.get("edits")
.and_then(Value::as_array)
.map(|a| !a.is_empty())
.unwrap_or(false);
match self.mode {
WriteToolMode::Unified => {
if has_direct {
return self.invoke_direct_write(invocation).await;
}
if has_edits {
return self.invoke_edits(&invocation).await;
}
if has_patch || has_patches {
return self.invoke_patch(invocation).await;
}
}
WriteToolMode::Direct => {
if has_direct {
return self.invoke_direct_write(invocation).await;
}
}
WriteToolMode::Patch => {
if has_edits {
return self.invoke_edits(&invocation).await;
}
if has_patch || has_patches {
return self.invoke_patch(invocation).await;
}
}
}
return Ok(ToolResult {
invocation_id: invocation.id,
ok: false,
output: json!({
"error_code": "invalid_arguments",
"error": match self.mode {
WriteToolMode::Unified => "Must provide either `path`+`content` (direct write), `patch`/`patches` (patch mode), or `edits` (search/replace).",
WriteToolMode::Direct => "Must provide `path` and `content`.",
WriteToolMode::Patch => "Must provide `patch`, `patches`, or `edits`.",
}
}),
});
}
}
impl WriteTool {
async fn invoke_direct_write(&self, invocation: ToolInvocation) -> Result<ToolResult> {
let raw_path = helpers::required_string(&invocation.input, "path")?.to_string();
let path = Path::new(&raw_path);
let full_path = if path.is_absolute() {
path.to_path_buf()
} else {
self.project_root.join(path)
};
let full_path_str = full_path.to_string_lossy().to_string();
let content = invocation
.input
.get("content")
.and_then(Value::as_str)
.map(str::to_string)
.context("missing required string `content`")?;
let _path_clone = raw_path.clone();
let full_path_clone = full_path_str.clone();
let content_clone = content.clone();
let existing_content = tokio::task::spawn_blocking(move || {
let existing = fs::read_to_string(&full_path_clone).ok();
if let Some(parent) = Path::new(&full_path_clone).parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&full_path_clone, &content_clone)
.with_context(|| format!("failed to write {full_path_clone}"))?;
Ok::<_, anyhow::Error>(existing)
})
.await
.map_err(|e| anyhow::anyhow!("task join error: {}", e))??;
let display_path = raw_path.as_str();
let diff = build_write_display_diff(display_path, existing_content.as_deref(), &content);
let (lines_added, lines_removed) = count_diff_add_remove(&diff);
let total_lines = count_lines(&content);
let mut output = json!({
"path": path,
"bytes": content.len(),
"lines_added": lines_added,
"lines_removed": lines_removed,
"total_lines": total_lines,
});
if !diff.is_empty() {
if let Value::Object(ref mut obj) = output {
obj.insert("diff".to_string(), Value::String(diff));
}
}
Ok(helpers::ok(invocation.id, output))
}
}
fn count_lines(content: &str) -> usize {
if content.is_empty() {
0
} else {
content.lines().count().max(1)
}
}
const MAX_LINE_DIFF_LINES: usize = 2000;
pub(crate) fn build_write_display_diff(path: &str, old: Option<&str>, new: &str) -> String {
match old {
None => add_file_display_diff(path, new),
Some(old) if old.is_empty() && new.is_empty() => add_file_display_diff(path, new),
Some(old) if old.is_empty() => add_file_display_diff(path, new),
Some(old) if old == new => String::new(),
Some(old) => update_file_display_diff(path, old, new),
}
}
fn add_file_display_diff(path: &str, new: &str) -> String {
let line_count = if new.is_empty() {
0
} else {
new.lines().count().max(1)
};
let mut out = if line_count == 0 {
format!("*** Add File: {path}\n")
} else {
format!("*** Add File: {path}\n@@ -0,0 +1,{line_count} @@\n")
};
append_prefixed_content(&mut out, '+', new);
out
}
fn update_file_display_diff(path: &str, old: &str, new: &str) -> String {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
if old_lines.len() > MAX_LINE_DIFF_LINES || new_lines.len() > MAX_LINE_DIFF_LINES {
return full_rewrite_display_diff(path, &old_lines, &new_lines);
}
let ops = compute_line_ops(&old_lines, &new_lines);
format_ops_as_display_diff(path, &ops)
}
fn full_rewrite_display_diff(path: &str, old_lines: &[&str], new_lines: &[&str]) -> String {
let old_count = old_lines.len().max(1);
let new_count = new_lines.len().max(1);
let mut out = format!("*** Update File: {path}\n@@ -1,{old_count} +1,{new_count} @@\n");
for line in old_lines {
out.push('-');
out.push_str(line);
out.push('\n');
}
for line in new_lines {
out.push('+');
out.push_str(line);
out.push('\n');
}
out
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum DiffTag {
Equal,
Delete,
Insert,
}
fn compute_line_ops<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<(DiffTag, &'a str)> {
let n = old.len();
let m = new.len();
let mut dp = vec![vec![0u32; m + 1]; n + 1];
for i in 0..n {
for j in 0..m {
if old[i] == new[j] {
dp[i + 1][j + 1] = dp[i][j] + 1;
} else {
dp[i + 1][j + 1] = dp[i + 1][j].max(dp[i][j + 1]);
}
}
}
let mut ops = Vec::new();
let mut i = n;
let mut j = m;
while i > 0 || j > 0 {
if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
ops.push((DiffTag::Equal, old[i - 1]));
i -= 1;
j -= 1;
} else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
ops.push((DiffTag::Insert, new[j - 1]));
j -= 1;
} else if i > 0 {
ops.push((DiffTag::Delete, old[i - 1]));
i -= 1;
} else {
break;
}
}
ops.reverse();
ops
}
const DISPLAY_DIFF_CONTEXT: usize = 3;
fn format_ops_as_display_diff(path: &str, ops: &[(DiffTag, &str)]) -> String {
let has_change = ops.iter().any(|(tag, _)| *tag != DiffTag::Equal);
if !has_change {
return String::new();
}
#[derive(Clone, Copy)]
struct Ann<'a> {
old: Option<u32>,
new: Option<u32>,
tag: DiffTag,
text: &'a str,
}
let mut ann = Vec::with_capacity(ops.len());
let mut old_no = 1u32;
let mut new_no = 1u32;
for &(tag, text) in ops {
match tag {
DiffTag::Equal => {
ann.push(Ann {
old: Some(old_no),
new: Some(new_no),
tag,
text,
});
old_no += 1;
new_no += 1;
}
DiffTag::Delete => {
ann.push(Ann {
old: Some(old_no),
new: None,
tag,
text,
});
old_no += 1;
}
DiffTag::Insert => {
ann.push(Ann {
old: None,
new: Some(new_no),
tag,
text,
});
new_no += 1;
}
}
}
let n = ann.len();
let mut keep = vec![false; n];
for (i, a) in ann.iter().enumerate() {
if a.tag != DiffTag::Equal {
let start = i.saturating_sub(DISPLAY_DIFF_CONTEXT);
let end = (i + DISPLAY_DIFF_CONTEXT + 1).min(n);
for slot in keep.iter_mut().take(end).skip(start) {
*slot = true;
}
}
}
let mut hunks: Vec<(usize, usize)> = Vec::new();
let mut i = 0;
while i < n {
if !keep[i] {
i += 1;
continue;
}
let start = i;
while i < n && keep[i] {
i += 1;
}
hunks.push((start, i));
}
let mut out = format!("*** Update File: {path}\n");
for (start, end) in hunks {
let slice = &ann[start..end];
let old_start = slice.iter().find_map(|a| a.old).unwrap_or(1);
let new_start = slice.iter().find_map(|a| a.new).unwrap_or(1);
let old_count = slice
.iter()
.filter(|a| matches!(a.tag, DiffTag::Equal | DiffTag::Delete))
.count()
.max(1);
let new_count = slice
.iter()
.filter(|a| matches!(a.tag, DiffTag::Equal | DiffTag::Insert))
.count()
.max(1);
out.push_str(&format!(
"@@ -{old_start},{old_count} +{new_start},{new_count} @@\n"
));
for a in slice {
match a.tag {
DiffTag::Equal => {
out.push(' ');
out.push_str(a.text);
out.push('\n');
}
DiffTag::Delete => {
out.push('-');
out.push_str(a.text);
out.push('\n');
}
DiffTag::Insert => {
out.push('+');
out.push_str(a.text);
out.push('\n');
}
}
}
}
out
}
fn append_prefixed_content(output: &mut String, prefix: char, text: &str) {
if text.is_empty() {
return;
}
for line in text.lines() {
output.push(prefix);
output.push_str(line);
output.push('\n');
}
}
pub(crate) fn count_diff_add_remove(diff: &str) -> (usize, usize) {
let mut added = 0usize;
let mut removed = 0usize;
for line in diff.lines() {
if line.starts_with("+++") || line.starts_with("---") {
continue;
}
if line.starts_with('+') {
added += 1;
} else if line.starts_with('-') {
removed += 1;
}
}
(added, removed)
}
fn snapshot_project_files(
project_root: &Path,
paths: &[String],
) -> std::collections::BTreeMap<String, Option<String>> {
let mut out = std::collections::BTreeMap::new();
for path in paths {
let full = project_root.join(path);
let content = fs::read_to_string(&full).ok();
out.insert(path.clone(), content);
}
out
}
fn attach_patch_display_diff(
output: &mut Value,
project_root: &Path,
paths: &[String],
before: &std::collections::BTreeMap<String, Option<String>>,
) {
let mut display = String::new();
let mut total_added = 0usize;
let mut total_removed = 0usize;
for path in paths {
let old = before.get(path).and_then(|v| v.as_deref());
let new = fs::read_to_string(project_root.join(path)).ok();
let new_str = new.as_deref().unwrap_or("");
match old {
Some(old_s) if old_s == new_str => continue,
None if new_str.is_empty() => continue,
_ => {}
}
let file_diff = build_write_display_diff(path, old, new_str);
let (added, removed) = count_diff_add_remove(&file_diff);
total_added += added;
total_removed += removed;
if !file_diff.is_empty() {
if !display.is_empty() {
display.push('\n');
}
display.push_str(&file_diff);
}
}
let Value::Object(obj) = output else {
return;
};
obj.insert("lines_added".into(), json!(total_added));
obj.insert("lines_removed".into(), json!(total_removed));
if !display.is_empty() {
obj.insert("diff".into(), Value::String(display));
}
}
impl WriteTool {
async fn invoke_edits(&self, invocation: &ToolInvocation) -> Result<ToolResult> {
let Some(edits) = invocation.input.get("edits").and_then(Value::as_array) else {
return Ok(ToolResult {
invocation_id: invocation.id.clone(),
ok: false,
output: json!({
"error_code": "invalid_arguments",
"error": "`edits` must be a non-empty array of {path, search, replace} objects."
}),
});
};
if edits.is_empty() {
return Ok(ToolResult {
invocation_id: invocation.id.clone(),
ok: false,
output: json!({
"error_code": "invalid_arguments",
"error": "`edits` array must contain at least one edit."
}),
});
}
let mut parsed_edits = Vec::with_capacity(edits.len());
for (idx, edit) in edits.iter().enumerate() {
let path = edit
.get("path")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("edit {idx}: missing `path`"))?;
let search = edit
.get("search")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("edit {idx}: missing `search`"))?;
let replace = edit
.get("replace")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("edit {idx}: missing `replace`"))?;
parsed_edits.push((path.to_string(), search.to_string(), replace.to_string()));
}
let mut files_changed = Vec::new();
let mut errors = Vec::new();
let mut before_by_path: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
let mut after_by_path: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for (path, search, replace) in &parsed_edits {
let full = resolve_tool_path(&self.project_root, path);
let content = match after_by_path.get(path) {
Some(c) => c.clone(),
None => match fs::read_to_string(&full) {
Ok(c) => c,
Err(e) => {
errors.push(format!("{path}: failed to read file: {e}"));
continue;
}
},
};
if !before_by_path.contains_key(path) {
before_by_path.insert(path.clone(), content.clone());
}
let new_content = match apply_search_replace(&content, search, replace) {
Some(c) => c,
None => {
errors.push(format!(
"{path}: search block not found. Consider using `read_file` to refresh the exact content."
));
continue;
}
};
if new_content != content {
if let Some(parent) = full.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(&full, &new_content) {
errors.push(format!("{path}: failed to write file: {e}"));
continue;
}
after_by_path.insert(path.clone(), new_content);
if !files_changed.iter().any(|p| p == path) {
files_changed.push(path.clone());
}
}
}
if !errors.is_empty() && files_changed.is_empty() {
return Ok(ToolResult {
invocation_id: invocation.id.clone(),
ok: false,
output: json!({
"error_code": "edit_failed",
"error": errors.join("\n"),
"recoverable": true,
"hint": "Ensure each `search` block matches the file content exactly, including whitespace and newlines. Use `read_file` if the file may have changed."
}),
});
}
let mut display_diff = String::new();
let mut total_added = 0usize;
let mut total_removed = 0usize;
for path in &files_changed {
let old = before_by_path.get(path).map(String::as_str);
let new = after_by_path.get(path).map(String::as_str).unwrap_or("");
let file_diff = build_write_display_diff(path, old, new);
let (added, removed) = count_diff_add_remove(&file_diff);
total_added += added;
total_removed += removed;
if !file_diff.is_empty() {
if !display_diff.is_empty() {
display_diff.push('\n');
}
display_diff.push_str(&file_diff);
}
}
let mut output = json!({
"method": "search_replace",
"status": 0,
"files_changed": files_changed,
"edits_applied": files_changed.len(),
"lines_added": total_added,
"lines_removed": total_removed,
});
if !display_diff.is_empty() {
if let Value::Object(ref mut obj) = output {
obj.insert("diff".to_string(), Value::String(display_diff));
}
}
if !errors.is_empty() {
if let Value::Object(ref mut obj) = output {
obj.insert(
"warnings".to_string(),
Value::Array(errors.into_iter().map(Value::String).collect()),
);
}
}
Ok(helpers::ok(invocation.id.clone(), output))
}
}
fn apply_search_replace(content: &str, search: &str, replace: &str) -> Option<String> {
if let Some(pos) = content.find(search) {
let mut result = String::with_capacity(content.len() - search.len() + replace.len());
result.push_str(&content[..pos]);
result.push_str(replace);
result.push_str(&content[pos + search.len()..]);
return Some(result);
}
let search_normalized = search.strip_suffix('\n').unwrap_or(search);
let replace_normalized = if replace.ends_with('\n') || search.ends_with('\n') {
replace.to_string()
} else {
replace.to_string()
};
let mut cursor = 0usize;
while let Some(pos) = content[cursor..].find(search_normalized) {
let absolute = cursor + pos;
let after = absolute + search_normalized.len();
if after == content.len() || content.as_bytes()[after] == b'\n' {
let mut result = String::with_capacity(
content.len() - search_normalized.len() + replace_normalized.len() + 1,
);
result.push_str(&content[..absolute]);
result.push_str(&replace_normalized);
if after < content.len() {
result.push_str(&content[after..]);
}
return Some(result);
}
cursor = after;
}
None
}
impl WriteTool {
async fn invoke_patch(&self, invocation: ToolInvocation) -> Result<ToolResult> {
let input = &invocation.input;
let raw_patches = if let Some(single) = input.get("patch").and_then(Value::as_str) {
vec![single.to_string()]
} else if let Some(arr) = input.get("patches").and_then(Value::as_array) {
arr.iter()
.filter_map(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
} else {
return Ok(ToolResult {
invocation_id: invocation.id,
ok: false,
output: json!({
"error_code": "invalid_arguments",
"error": "Patch mode requires `patch` (string) or `patches` (non-empty array)."
}),
});
};
if raw_patches.is_empty() {
return Ok(ToolResult {
invocation_id: invocation.id,
ok: false,
output: json!({
"error_code": "invalid_arguments",
"error": "`patch` string or `patches` array must contain at least one non-empty patch."
}),
});
}
let patches: Vec<String> = raw_patches
.iter()
.map(|p| {
let patch = strip_heredoc(p).unwrap_or_else(|| p.to_string());
normalize_structured_patch_hunk_prefixes(&patch)
})
.collect();
let affected = patch_affected_files(&patches)?;
let before_snapshot = snapshot_project_files(&self.project_root, &affected);
let mut verification_errors: Vec<String> = Vec::new();
let mut patch_is_structured = false;
for patch in &patches {
if is_structured_patch(patch) {
patch_is_structured = true;
if let Err(err) = verify_structured_patch(&self.project_root, patch) {
verification_errors.push(err.to_string());
}
}
}
if !verification_errors.is_empty() {
match apply_structured_symbol_replacement_fallback(&self.project_root, &patches) {
Ok(Some(files_patched)) => {
let mut output = json!({
"method": "structured_symbol_replacement_fallback",
"status": 0,
"patches_applied": patches.len(),
"files_patched": files_patched,
"recovered_from": "verification_failed",
"warnings": verification_errors,
"affected_paths": affected,
});
attach_patch_display_diff(
&mut output,
&self.project_root,
&affected,
&before_snapshot,
);
return Ok(ToolResult {
invocation_id: invocation.id,
ok: true,
output,
});
}
Ok(None) => {}
Err(err) => {
verification_errors
.push(format!("symbol replacement fallback failed: {err:#}"));
}
}
let output = json!({
"error_code": "verification_failed",
"error": format!("Patch verification failed:\n{}", verification_errors.join("\n")),
"recoverable": true,
"hint": "Re-read the affected files and regenerate the patch with exact context.",
"context_lines": patch_failure_contexts(&self.project_root, &patches),
"context_note": "Each context window includes up to 20 lines before and 20 lines after the nearest relevant patch hunk location.",
});
return Ok(ToolResult {
invocation_id: invocation.id,
ok: false,
output,
});
}
if patch_is_structured || patches.iter().all(|p| is_structured_patch(p)) {
return match apply_structured_patches(&self.project_root, &patches) {
Ok(files_patched) => {
let mut output = json!({
"method": "structured",
"status": 0,
"patches_applied": patches.len(),
"files_patched": files_patched,
"affected_paths": affected,
});
attach_patch_display_diff(
&mut output,
&self.project_root,
&affected,
&before_snapshot,
);
Ok(ToolResult {
invocation_id: invocation.id,
ok: true,
output,
})
}
Err(err) => Ok(ToolResult {
invocation_id: invocation.id,
ok: false,
output: patch_failed_output(
"patch_failed",
format!("structured patch failed: {err:#}"),
"Rebuild the structured patch with exact context from `context_lines`: \
*** Begin Patch, file operation headers, @@ hunks, and *** End Patch.",
None,
patch_failure_contexts(&self.project_root, &patches),
),
}),
};
}
let patch = patches.join("\n");
for file in &affected {
let full = self.project_root.join(file);
if let Some(parent) = full.parent() {
let _ = std::fs::create_dir_all(parent);
}
}
let git_result = run_git_apply(&self.project_root, &patch).await?;
if git_result.status.success() {
let mut output = json!({
"method": "git_apply",
"status": git_result.status.code(),
"patches_applied": patches.len(),
"stdout": String::from_utf8_lossy(&git_result.stdout),
"stderr": String::from_utf8_lossy(&git_result.stderr),
"files_patched": affected.len(),
"affected_paths": affected,
});
attach_patch_display_diff(&mut output, &self.project_root, &affected, &before_snapshot);
return Ok(ToolResult {
invocation_id: invocation.id,
ok: true,
output,
});
}
let git_stderr = String::from_utf8_lossy(&git_result.stderr).to_string();
let relaxed_git_result = run_git_apply_relaxed(&self.project_root, &patch).await?;
if relaxed_git_result.status.success() {
let mut output = json!({
"method": "git_apply_relaxed",
"status": relaxed_git_result.status.code(),
"patches_applied": patches.len(),
"stdout": String::from_utf8_lossy(&relaxed_git_result.stdout),
"stderr": String::from_utf8_lossy(&relaxed_git_result.stderr),
"files_patched": affected.len(),
"affected_paths": affected,
});
attach_patch_display_diff(&mut output, &self.project_root, &affected, &before_snapshot);
return Ok(ToolResult {
invocation_id: invocation.id,
ok: true,
output,
});
}
let patch_result = run_patch_command(&self.project_root, &patch).await?;
if patch_result.status.success() {
let mut output = json!({
"method": "patch_fallback",
"status": patch_result.status.code(),
"patches_applied": patches.len(),
"stdout": String::from_utf8_lossy(&patch_result.stdout),
"stderr": String::from_utf8_lossy(&patch_result.stderr),
"files_patched": affected.len(),
"affected_paths": affected,
});
attach_patch_display_diff(&mut output, &self.project_root, &affected, &before_snapshot);
return Ok(ToolResult {
invocation_id: invocation.id,
ok: true,
output,
});
}
let patch_stderr = String::from_utf8_lossy(&patch_result.stderr).to_string();
let hint = git_apply_error_hint(&git_stderr);
Ok(ToolResult {
invocation_id: invocation.id,
ok: false,
output: patch_failed_output(
"patch_failed",
format!(
"git apply failed:\n{}\npatch -p1 failed:\n{}",
git_stderr.trim(),
patch_stderr.trim()
),
hint,
Some(format!(
"git apply stderr:\n{}\npatch stderr:\n{}",
git_stderr.trim(),
patch_stderr.trim()
)),
patch_failure_contexts(&self.project_root, &patches),
),
})
}
}
fn strip_heredoc(patch: &str) -> Option<String> {
let trimmed = patch.trim_start();
let skip_len = if trimmed.starts_with("<<'EOF'") {
Some(7) } else if trimmed.starts_with("<<\"EOF\"") {
Some(7) } else if trimmed.starts_with("<<EOF") {
Some(5) } else {
None
}?;
let after_marker = &trimmed[skip_len..];
let eof_pos = after_marker.rfind("\nEOF")?;
let body = after_marker[..eof_pos].trim_end().trim_start();
if body.starts_with("*** Begin Patch") || body.starts_with("--- ") {
Some(body.to_string())
} else {
None
}
}
fn normalize_structured_patch_hunk_prefixes(patch: &str) -> String {
if !is_structured_patch(patch) {
return patch.to_string();
}
let mut normalized = Vec::new();
let mut in_hunk = false;
for line in patch.lines() {
if line.starts_with("*** ") {
in_hunk = false;
normalized.push(line.to_string());
continue;
}
if line.starts_with("@@") {
in_hunk = true;
normalized.push(line.to_string());
continue;
}
if in_hunk
&& !line.is_empty()
&& !line.starts_with(' ')
&& !line.starts_with('-')
&& !line.starts_with('+')
{
normalized.push(format!(" {line}"));
} else {
normalized.push(line.to_string());
}
}
let mut patch = normalized.join("\n");
if patch.ends_with("*** End Patch") || patch.ends_with("*** End of File") {
patch.push('\n');
}
patch
}
fn normalise_line(s: &str) -> String {
s.replace(['\u{2013}', '\u{2011}'], "-") .replace('\u{2014}', "--") .replace(['\u{2018}', '\u{2019}'], "'") .replace(['\u{201c}', '\u{201d}'], "\"") }
fn verify_structured_patch(project_root: &Path, patch: &str) -> Result<()> {
let ops = parse_structured_patch(patch)?;
for op in &ops {
match op {
StructuredOp::Update { path, hunks, .. } => {
let full = resolve_tool_path(project_root, path);
let content = match fs::read_to_string(&full) {
Ok(c) => c,
Err(e) => {
bail!("Cannot read {path} for verification: {e}");
}
};
let old_lines: Vec<String> = content.lines().map(str::to_string).collect();
let mut cursor = 0usize;
for (hunk_idx, hunk) in hunks.iter().enumerate() {
let pos = find_hunk_position(&old_lines, cursor, hunk)
.or_else(|| {
let normalised: Vec<String> =
old_lines.iter().map(|l| normalise_line(l)).collect();
find_hunk_position(&normalised, cursor, hunk)
})
.with_context(|| {
format!(
"Hunk {} of {}: context lines do not match the file on disk. \
Re-read the file and regenerate the patch with exact context.",
hunk_idx + 1,
path
)
})?;
cursor = pos;
for line in hunk {
match line {
HunkLine::Context(_) | HunkLine::Remove(_) => cursor += 1,
HunkLine::Add(_) => {}
}
}
}
}
StructuredOp::Delete { path } => {
let full = resolve_tool_path(project_root, path);
if !full.exists() {
bail!("Cannot delete {path}: file does not exist");
}
}
StructuredOp::Add { .. } => {
}
}
}
Ok(())
}
fn apply_structured_symbol_replacement_fallback(
project_root: &Path,
patches: &[String],
) -> Result<Option<usize>> {
let mut candidates = Vec::new();
for patch in patches {
if !is_structured_patch(patch) {
continue;
}
candidates.extend(structured_symbol_replacement_candidates(
project_root,
patch,
)?);
}
match candidates.len() {
0 => Ok(None),
1 => {
let candidate = candidates.pop().expect("one candidate");
fs::write(&candidate.path, candidate.content)
.with_context(|| format!("failed to write {}", candidate.path.display()))?;
Ok(Some(1))
}
_ => bail!(
"ambiguous malformed patch recovery: {} symbol replacement candidates",
candidates.len()
),
}
}
struct SymbolReplacementCandidate {
path: PathBuf,
content: String,
}
fn structured_symbol_replacement_candidates(
project_root: &Path,
patch: &str,
) -> Result<Vec<SymbolReplacementCandidate>> {
let mut candidates = Vec::new();
let mut current_path: Option<String> = None;
let mut hunk_lines = Vec::new();
for line in patch.lines().chain(std::iter::once("*** End Patch")) {
if let Some(path) = line.strip_prefix("*** Update File: ") {
if let Some(path) = current_path.take() {
candidates.extend(symbol_replacements_from_hunks(
project_root,
&path,
&hunk_lines,
)?);
hunk_lines.clear();
}
current_path = Some(path.to_string());
continue;
}
if line.starts_with("*** ") {
if let Some(path) = current_path.take() {
candidates.extend(symbol_replacements_from_hunks(
project_root,
&path,
&hunk_lines,
)?);
hunk_lines.clear();
}
continue;
}
if current_path.is_some() {
hunk_lines.push(line.to_string());
}
}
Ok(candidates)
}
fn symbol_replacements_from_hunks(
project_root: &Path,
relative_path: &str,
hunk_lines: &[String],
) -> Result<Vec<SymbolReplacementCandidate>> {
#[cfg(not(feature = "code-vfs"))]
{
let _ = (project_root, relative_path, hunk_lines);
return Ok(Vec::new());
}
#[cfg(feature = "code-vfs")]
{
let path = resolve_tool_path(project_root, relative_path);
let source =
fs::read_to_string(&path).with_context(|| format!("failed to read {relative_path}"))?;
let mut candidates = Vec::new();
for replacement in extract_complete_function_blocks(hunk_lines) {
let Ok(symbols) = symbols_for_source(&path, &replacement) else {
continue;
};
if symbols.len() != 1 {
continue;
}
let symbol = &symbols[0];
let Ok(edit) =
replace_symbol_definition(&path, &source, &symbol.name, &replacement, None)
else {
continue;
};
if edit.content != source {
candidates.push(SymbolReplacementCandidate {
path: path.clone(),
content: edit.content,
});
}
}
Ok(candidates)
}
}
#[cfg(feature = "code-vfs")]
fn extract_complete_function_blocks(hunk_lines: &[String]) -> Vec<String> {
let mut blocks = Vec::new();
let mut index = 0;
while index < hunk_lines.len() {
let Some(content) = replacement_line_content(&hunk_lines[index]) else {
index += 1;
continue;
};
if !looks_like_function_start(content) {
index += 1;
continue;
}
let mut block = Vec::new();
let mut brace_balance = 0isize;
let mut saw_open_brace = false;
let mut cursor = index;
while cursor < hunk_lines.len() {
let Some(content) = replacement_line_content(&hunk_lines[cursor]) else {
break;
};
block.push(content.to_string());
for ch in content.chars() {
match ch {
'{' => {
saw_open_brace = true;
brace_balance += 1;
}
'}' => brace_balance -= 1,
_ => {}
}
}
cursor += 1;
if saw_open_brace && brace_balance == 0 {
blocks.push(block.join("\n"));
break;
}
}
index = cursor.max(index + 1);
}
blocks
}
#[cfg(feature = "code-vfs")]
fn replacement_line_content(line: &str) -> Option<&str> {
if line.starts_with("@@") || line.starts_with('-') {
None
} else if let Some(content) = line.strip_prefix('+') {
Some(content)
} else if let Some(content) = line.strip_prefix(' ') {
Some(content)
} else {
Some(line)
}
}
#[cfg(feature = "code-vfs")]
fn looks_like_function_start(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("fn ")
|| trimmed.starts_with("pub fn ")
|| trimmed.starts_with("pub(crate) fn ")
|| trimmed.starts_with("pub(super) fn ")
}
fn patch_affected_files(patches: &[String]) -> Result<Vec<String>> {
let mut files = Vec::new();
for patch in patches {
if is_structured_patch(patch) {
for op in parse_structured_patch(patch)? {
match op {
StructuredOp::Add { path, .. } | StructuredOp::Delete { path } => {
push_unique_string(&mut files, path);
}
StructuredOp::Update { path, move_to, .. } => {
push_unique_string(&mut files, path);
if let Some(target) = move_to {
push_unique_string(&mut files, target);
}
}
}
}
} else {
for path in extract_patched_files(patch) {
push_unique_string(&mut files, path);
}
}
}
files.sort();
Ok(files)
}
fn push_unique_string(values: &mut Vec<String>, value: String) {
if !values.contains(&value) {
values.push(value);
}
}
fn write_json_schema() -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Project-relative file path (required for direct write mode, in combination with `content`)."
},
"content": {
"type": "string",
"description": "Full UTF-8 file content to write (required for direct write mode, in combination with `path`)."
},
"patch": {
"type": "string",
"description": "A single complete patch string (structured format: *** Begin Patch, hunks, *** End Patch; or unified diff; or JSON search/replace array)."
},
"patches": {
"type": "array",
"description": "Multiple patch strings to apply in one call. Each element is a complete patch string (structured, unified diff, or JSON search/replace array).",
"items": { "type": "string" },
"minItems": 1
},
"edits": {
"type": "array",
"description": "Simple search/replace edits. Each object must include `path`, `search` and `replace`. This is the easiest format for surgical edits when the exact file content is known. `search` must match a contiguous block in the file exactly; use `replace` with the desired content.",
"items": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Project-relative file path to edit." },
"search": { "type": "string", "description": "Exact contiguous text to search for in the file." },
"replace": { "type": "string", "description": "Text to replace the matched block with." }
},
"required": ["path", "search", "replace"],
"additionalProperties": false
},
"minItems": 1
}
},
"additionalProperties": false,
"examples": [
{ "path": "src/main.rs", "content": "fn main() { println!(\"hello\"); }" },
{ "patch": "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch" },
{ "edits": [{ "path": "src/lib.rs", "search": "old\n", "replace": "new\n" }] }
]
})
}
fn direct_write_json_schema() -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Project-relative file path to write."
},
"content": {
"type": "string",
"description": "Full UTF-8 file content to write."
}
},
"required": ["path", "content"],
"additionalProperties": false,
"examples": [
{ "path": "src/main.rs", "content": "fn main() { println!(\"hello\"); }\n" }
]
})
}
fn patch_write_json_schema() -> serde_json::Value {
json!({
"type": "object",
"properties": {
"patch": {
"type": "string",
"description": "A single complete patch string (structured format: *** Begin Patch, hunks, *** End Patch; or unified diff; or JSON search/replace array)."
},
"patches": {
"type": "array",
"description": "Multiple patch strings to apply in one call. Each element is a complete patch string (structured, unified diff, or JSON search/replace array).",
"items": { "type": "string" },
"minItems": 1
},
"edits": {
"type": "array",
"description": "Simple search/replace edits. Each object must include `path`, `search` and `replace`. This is the easiest format for surgical edits when the exact file content is known. `search` must match a contiguous block in the file exactly; use `replace` with the desired content.",
"items": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Project-relative file path to edit." },
"search": { "type": "string", "description": "Exact contiguous text to search for in the file." },
"replace": { "type": "string", "description": "Text to replace the matched block with." }
},
"required": ["path", "search", "replace"],
"additionalProperties": false
},
"minItems": 1
}
},
"additionalProperties": false,
"examples": [
{ "patch": "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch" },
{ "edits": [{ "path": "src/lib.rs", "search": "old\n", "replace": "new\n" }] }
]
})
}
fn is_structured_patch(patch: &str) -> bool {
patch.trim_start().starts_with("*** Begin Patch")
}
fn apply_structured_patches(project_root: &Path, patches: &[String]) -> Result<usize> {
let mut all_ops = Vec::new();
for patch in patches {
all_ops.extend(parse_structured_patch(patch)?);
}
let backup_paths = structured_backup_paths(project_root, &all_ops)?;
let backups = collect_path_backups(backup_paths)?;
let mut files_patched = 0;
for patch in patches {
let ops = match parse_structured_patch(patch) {
Ok(ops) => ops,
Err(err) => {
rollback_backups(backups);
return Err(err);
}
};
let changes = match plan_structured_changes(project_root, &ops) {
Ok(c) => c,
Err(err) => {
rollback_backups(backups);
return Err(err);
}
};
files_patched += changes.len();
if let Err(err) = write_planned_changes(&changes) {
rollback_backups(backups);
return Err(err);
}
}
Ok(files_patched)
}
fn parse_structured_patch(patch: &str) -> Result<Vec<StructuredOp>> {
let mut lines: Vec<&str> = patch.lines().collect();
while lines.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
lines.pop();
}
let mut peek_iter = lines.into_iter().peekable();
let Some(first) = peek_iter.next() else {
bail!("empty patch");
};
if first.trim() != "*** Begin Patch" {
bail!("missing *** Begin Patch header");
}
let mut ops = Vec::new();
while let Some(line) = peek_iter.next() {
if line.trim() == "*** End Patch" {
return Ok(ops);
}
if line.starts_with("*** Environment ID: ") {
continue;
}
if let Some(path) = line.strip_prefix("*** Add File: ") {
let mut new_lines = Vec::new();
while let Some(next) = peek_iter.peek().copied() {
if next.starts_with("*** ") {
break;
}
let line = peek_iter.next().expect("peeked line exists");
let Some(content) = line.strip_prefix('+') else {
bail!("add file lines must start with `+` for {path}");
};
new_lines.push(content.to_string());
}
ops.push(StructuredOp::Add {
path: path.to_string(),
lines: new_lines,
});
continue;
}
if let Some(path) = line.strip_prefix("*** Delete File: ") {
ops.push(StructuredOp::Delete {
path: path.to_string(),
});
continue;
}
if let Some(path) = line.strip_prefix("*** Update File: ") {
let mut move_to = None;
let mut hunks = Vec::new();
while let Some(next) = peek_iter.peek().copied() {
if next.starts_with("*** Update File: ")
|| next.starts_with("*** Add File: ")
|| next.starts_with("*** Delete File: ")
|| next.trim() == "*** End Patch"
{
break;
}
let line = peek_iter.next().expect("peeked line exists");
if let Some(target) = line.strip_prefix("*** Move to: ") {
move_to = Some(target.to_string());
continue;
}
if line.starts_with("@@") {
let mut hunk = Vec::new();
while let Some(hunk_line) = peek_iter.peek().copied() {
if hunk_line.starts_with("@@")
|| hunk_line.starts_with("*** ")
|| hunk_line.trim() == "*** End of File"
{
break;
}
hunk.push(parse_hunk_line(
peek_iter.next().expect("peeked line exists"),
)?);
}
hunks.push(hunk);
continue;
}
if line.trim().is_empty() {
continue;
}
bail!("unexpected line in update for {path}: {line}");
}
ops.push(StructuredOp::Update {
path: path.to_string(),
move_to,
hunks,
});
continue;
}
if line.trim().is_empty() {
continue;
}
bail!("unexpected patch line: {line}");
}
bail!("missing *** End Patch footer");
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum StructuredOp {
Add {
path: String,
lines: Vec<String>,
},
Delete {
path: String,
},
Update {
path: String,
move_to: Option<String>,
hunks: Vec<Vec<HunkLine>>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum HunkLine {
Context(String),
Remove(String),
Add(String),
}
fn parse_hunk_line(line: &str) -> Result<HunkLine> {
if let Some(content) = line.strip_prefix(' ') {
Ok(HunkLine::Context(content.to_string()))
} else if let Some(content) = line.strip_prefix('-') {
Ok(HunkLine::Remove(content.to_string()))
} else if let Some(content) = line.strip_prefix('+') {
Ok(HunkLine::Add(content.to_string()))
} else if line.is_empty() {
Ok(HunkLine::Context(String::new()))
} else {
bail!("hunk lines must start with space, `-`, or `+`: {line}")
}
}
#[derive(Debug, Clone)]
enum PlannedChange {
Write {
path: PathBuf,
lines: Vec<String>,
trailing_newline: bool,
},
Delete {
path: PathBuf,
},
Update {
source: PathBuf,
target: PathBuf,
lines: Vec<String>,
trailing_newline: bool,
},
}
fn plan_structured_changes(
project_root: &Path,
ops: &[StructuredOp],
) -> Result<Vec<PlannedChange>> {
let mut changes = Vec::new();
for op in ops {
match op {
StructuredOp::Add { path, lines } => {
let full = resolve_tool_path(project_root, path);
if full.exists() {
bail!("file already exists: {path}");
}
changes.push(PlannedChange::Write {
path: full,
lines: lines.clone(),
trailing_newline: true,
});
}
StructuredOp::Delete { path } => {
let full = resolve_tool_path(project_root, path);
if !full.exists() {
bail!("file does not exist: {path}");
}
changes.push(PlannedChange::Delete { path: full });
}
StructuredOp::Update {
path,
move_to,
hunks,
} => {
changes.push(plan_structured_update(
project_root,
path,
move_to.as_deref(),
hunks,
)?);
}
}
}
Ok(changes)
}
fn plan_structured_update(
project_root: &Path,
path: &str,
move_to: Option<&str>,
hunks: &[Vec<HunkLine>],
) -> Result<PlannedChange> {
let source = resolve_tool_path(project_root, path);
let content = fs::read_to_string(&source).with_context(|| format!("failed to read {path}"))?;
let had_trailing_newline = content.ends_with('\n');
let old_lines: Vec<String> = content.lines().map(str::to_string).collect();
let new_lines = apply_hunks(&old_lines, hunks)?;
let target = if let Some(move_to) = move_to {
resolve_tool_path(project_root, move_to)
} else {
source.clone()
};
Ok(PlannedChange::Update {
source,
target,
lines: new_lines,
trailing_newline: had_trailing_newline,
})
}
fn apply_hunks(old_lines: &[String], hunks: &[Vec<HunkLine>]) -> Result<Vec<String>> {
let mut result = Vec::new();
let mut cursor = 0usize;
for hunk in hunks {
let pos = find_hunk_position(old_lines, cursor, hunk)
.or_else(|| {
let normalised: Vec<String> = old_lines.iter().map(|l| normalise_line(l)).collect();
find_hunk_position(&normalised, cursor, hunk).map(|_p| {
let mut _actual_cursor = cursor;
let expected: Vec<&String> = hunk
.iter()
.filter_map(|line| match line {
HunkLine::Context(c) | HunkLine::Remove(c) => Some(c),
HunkLine::Add(_) => None,
})
.collect();
for exp in &expected {
while _actual_cursor < old_lines.len() {
if normalise_line(&old_lines[_actual_cursor]) == **exp {
_actual_cursor += 1;
break;
}
_actual_cursor += 1;
}
}
_actual_cursor
})
})
.with_context(|| "hunk context did not match target file")?;
result.extend_from_slice(&old_lines[cursor..pos]);
cursor = pos;
for line in hunk {
match line {
HunkLine::Context(content) => {
let actual = old_lines.get(cursor).map(|s| s.as_str()).unwrap_or("");
if actual != content
&& actual.trim_end() != content.trim_end()
&& normalise_line(actual) != normalise_line(content)
{
bail!("context mismatch at line {}", cursor + 1);
}
result.push(old_lines[cursor].clone());
cursor += 1;
}
HunkLine::Remove(content) => {
let actual = old_lines.get(cursor).map(|s| s.as_str()).unwrap_or("");
if actual != content
&& actual.trim_end() != content.trim_end()
&& normalise_line(actual) != normalise_line(content)
{
bail!("remove mismatch at line {}", cursor + 1);
}
cursor += 1;
}
HunkLine::Add(content) => result.push(content.clone()),
}
}
}
result.extend_from_slice(&old_lines[cursor..]);
Ok(result)
}
fn find_hunk_position(old_lines: &[String], start: usize, hunk: &[HunkLine]) -> Option<usize> {
let expected: Vec<&String> = hunk
.iter()
.filter_map(|line| match line {
HunkLine::Context(content) | HunkLine::Remove(content) => Some(content),
HunkLine::Add(_) => None,
})
.collect();
if expected.is_empty() {
return Some(start);
}
let exact = (start..=old_lines.len().saturating_sub(expected.len())).find(|&pos| {
expected
.iter()
.enumerate()
.all(|(offset, line)| old_lines.get(pos + offset) == Some(line))
});
if exact.is_some() {
return exact;
}
let trimmed_old: Vec<String> = old_lines.iter().map(|l| l.trim_end().to_string()).collect();
let trimmed_expected: Vec<String> = expected.iter().map(|l| l.trim_end().to_string()).collect();
let trimmed = (start..=trimmed_old.len().saturating_sub(trimmed_expected.len())).find(|&pos| {
trimmed_expected
.iter()
.enumerate()
.all(|(offset, line)| trimmed_old.get(pos + offset) == Some(line))
});
if trimmed.is_some() {
return trimmed;
}
let normalised_old: Vec<String> = old_lines.iter().map(|l| normalise_line(l)).collect();
let normalised_expected: Vec<String> = expected.iter().map(|l| normalise_line(l)).collect();
(start
..=normalised_old
.len()
.saturating_sub(normalised_expected.len()))
.find(|&pos| {
normalised_expected
.iter()
.enumerate()
.all(|(offset, line)| {
normalised_old.get(pos + offset).map(|s| s.as_str()) == Some(line.as_str())
})
})
}
fn structured_backup_paths(project_root: &Path, ops: &[StructuredOp]) -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
for op in ops {
match op {
StructuredOp::Add { path, .. } | StructuredOp::Delete { path } => {
push_unique_path(&mut paths, resolve_tool_path(project_root, path));
}
StructuredOp::Update { path, move_to, .. } => {
push_unique_path(&mut paths, resolve_tool_path(project_root, path));
if let Some(move_to) = move_to {
push_unique_path(&mut paths, resolve_tool_path(project_root, move_to));
}
}
}
}
Ok(paths)
}
fn collect_path_backups(paths: Vec<PathBuf>) -> Result<Vec<(PathBuf, Option<Vec<u8>>)>> {
paths
.into_iter()
.map(|path| {
let content = if path.exists() {
Some(
fs::read(&path)
.with_context(|| format!("failed to back up {}", path.display()))?,
)
} else {
None
};
Ok((path, content))
})
.collect()
}
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.contains(&path) {
paths.push(path);
}
}
fn write_planned_changes(changes: &[PlannedChange]) -> Result<()> {
for change in changes {
match change {
PlannedChange::Write {
path,
lines,
trailing_newline,
} => write_lines(path, lines, *trailing_newline)?,
PlannedChange::Delete { path } => fs::remove_file(path)
.with_context(|| format!("failed to delete {}", path.display()))?,
PlannedChange::Update {
source,
target,
lines,
trailing_newline,
} => {
write_lines(target, lines, *trailing_newline)?;
if target != source {
fs::remove_file(source).with_context(|| {
format!("failed to remove moved file {}", source.display())
})?;
}
}
}
}
Ok(())
}
fn rollback_backups(backups: Vec<(PathBuf, Option<Vec<u8>>)>) {
for (path, content) in backups {
match content {
Some(content) => {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(path, content);
}
None => {
let _ = fs::remove_file(path);
}
}
}
}
fn resolve_tool_path(project_root: &Path, path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() {
p.to_path_buf()
} else {
project_root.join(p)
}
}
fn write_lines(path: &Path, lines: &[String], trailing_newline: bool) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let mut content = lines.join("\n");
if trailing_newline && !content.ends_with('\n') {
content.push('\n');
}
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
}
async fn run_git_apply(project_root: &Path, patch: &str) -> Result<std::process::Output> {
let mut child = Command::new("git")
.args(["apply", "--whitespace=fix", "-"])
.current_dir(project_root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn git apply")?;
child
.stdin
.as_mut()
.context("failed to open git apply stdin")?
.write_all(patch.as_bytes())
.await
.context("failed to send patch to git apply")?;
child
.wait_with_output()
.await
.context("failed to wait for git apply")
}
async fn run_git_apply_relaxed(project_root: &Path, patch: &str) -> Result<std::process::Output> {
let mut child = Command::new("git")
.args([
"apply",
"--whitespace=fix",
"--ignore-space-change",
"--ignore-whitespace",
"-",
])
.current_dir(project_root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn git apply")?;
child
.stdin
.as_mut()
.context("failed to open git apply stdin")?
.write_all(patch.as_bytes())
.await
.context("failed to send patch to git apply")?;
child
.wait_with_output()
.await
.context("failed to wait for git apply")
}
async fn run_patch_command(project_root: &Path, patch: &str) -> Result<std::process::Output> {
let mut child = Command::new("patch")
.args(["-p1", "--force", "--no-backup-if-mismatch"])
.current_dir(project_root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn patch")?;
child
.stdin
.as_mut()
.context("failed to open patch stdin")?
.write_all(patch.as_bytes())
.await
.context("failed to send patch to patch command")?;
child
.wait_with_output()
.await
.context("failed to wait for patch command")
}
fn extract_patched_files(patch: &str) -> Vec<String> {
let mut files = Vec::new();
for line in patch.lines() {
if let Some(path) = line.strip_prefix("--- a/")
&& !files.contains(&path.to_string())
{
files.push(path.to_string());
}
if let Some(path) = line.strip_prefix("+++ b/")
&& !files.contains(&path.to_string())
{
files.push(path.to_string());
}
}
files
}
fn patch_failed_output(
error_code: &str,
message: impl Into<String>,
hint: &str,
stderr: Option<String>,
context_lines: Vec<Value>,
) -> Value {
let mut output = helpers::tool_error(error_code, message, true, Some(hint), stderr);
if !context_lines.is_empty()
&& let Value::Object(object) = &mut output
{
object.insert("context_lines".to_string(), Value::Array(context_lines));
object.insert(
"context_note".to_string(),
Value::String(
"Each context window includes up to 20 lines before and 20 lines after \
the nearest relevant patch hunk location."
.to_string(),
),
);
}
output
}
fn patch_failure_contexts(project_root: &Path, patches: &[String]) -> Vec<Value> {
let mut contexts = Vec::new();
for patch in patches {
if is_structured_patch(patch) {
contexts.extend(structured_patch_contexts(project_root, patch));
} else {
contexts.extend(unified_patch_contexts(project_root, patch));
}
if contexts.len() >= MAX_PATCH_CONTEXT_WINDOWS {
contexts.truncate(MAX_PATCH_CONTEXT_WINDOWS);
break;
}
}
contexts
}
fn structured_patch_contexts(project_root: &Path, patch: &str) -> Vec<Value> {
let Ok(ops) = parse_structured_patch(patch) else {
return extract_structured_patch_paths(patch)
.into_iter()
.filter_map(|path| file_context_window(project_root, &path, 1))
.collect();
};
let mut contexts = Vec::new();
for op in ops {
match op {
StructuredOp::Update { path, hunks, .. } => {
for hunk in &hunks {
let preferred_line = preferred_structured_hunk_line(project_root, &path, hunk);
if let Some(context) = file_context_window(project_root, &path, preferred_line)
{
contexts.push(context);
}
}
}
StructuredOp::Delete { path } => {
if let Some(context) = file_context_window(project_root, &path, 1) {
contexts.push(context);
}
}
StructuredOp::Add { .. } => {}
}
}
contexts
}
fn extract_structured_patch_paths(patch: &str) -> Vec<String> {
let mut paths = Vec::new();
for line in patch.lines() {
if let Some(path) = line.strip_prefix("*** Update File: ") {
paths.push(PathBuf::from(path));
} else if let Some(path) = line.strip_prefix("*** Delete File: ") {
paths.push(PathBuf::from(path));
}
}
paths
.into_iter()
.filter_map(|path| path.to_str().map(str::to_string))
.collect()
}
fn preferred_structured_hunk_line(project_root: &Path, path: &str, hunk: &[HunkLine]) -> usize {
let full_path = resolve_tool_path(project_root, path);
let Ok(content) = fs::read_to_string(full_path) else {
return 1;
};
let lines: Vec<String> = content.lines().map(str::to_string).collect();
if let Some(pos) = find_hunk_position(&lines, 0, hunk) {
return pos + 1;
}
hunk.iter()
.filter_map(|line| match line {
HunkLine::Context(content) | HunkLine::Remove(content) if !content.is_empty() => {
Some(content)
}
_ => None,
})
.find_map(|expected| {
lines
.iter()
.position(|line| line == expected)
.or_else(|| {
let normalised_expected = normalise_line(expected);
lines
.iter()
.position(|line| normalise_line(line) == normalised_expected)
})
.map(|pos| pos + 1)
})
.unwrap_or(1)
}
fn unified_patch_contexts(project_root: &Path, patch: &str) -> Vec<Value> {
let mut contexts = Vec::new();
let mut _old_path: Option<String> = None;
let mut current_path: Option<String> = None;
for line in patch.lines() {
if let Some(path) = line.strip_prefix("--- ") {
_old_path = clean_unified_path(path);
} else if let Some(path) = line.strip_prefix("+++ ") {
current_path = clean_unified_path(path).or_else(|| _old_path.clone());
} else if line.starts_with("@@") {
let preferred_line = parse_unified_old_start(line).unwrap_or(1);
if let Some(path) = current_path.as_deref()
&& let Some(context) = file_context_window(project_root, path, preferred_line)
{
contexts.push(context);
}
}
}
contexts
}
fn clean_unified_path(path: &str) -> Option<String> {
let path = path.split_whitespace().next().unwrap_or(path);
if path == "/dev/null" {
return None;
}
Some(
path.strip_prefix("a/")
.or_else(|| path.strip_prefix("b/"))
.unwrap_or(path)
.to_string(),
)
}
fn parse_unified_old_start(hunk_header: &str) -> Option<usize> {
let after_dash = hunk_header.split_once('-')?.1;
let number = after_dash
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>();
number.parse::<usize>().ok().filter(|line| *line > 0)
}
fn file_context_window(project_root: &Path, path: &str, preferred_line: usize) -> Option<Value> {
let full_path = resolve_tool_path(project_root, path);
let content = fs::read_to_string(full_path).ok()?;
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return None;
}
let preferred_line = preferred_line.clamp(1, lines.len());
let start_line = preferred_line.saturating_sub(PATCH_CONTEXT_RADIUS).max(1);
let end_line = (preferred_line + PATCH_CONTEXT_RADIUS).min(lines.len());
let context = (start_line..=end_line)
.map(|line| {
json!({
"line": line,
"text": lines[line - 1],
})
})
.collect::<Vec<_>>();
Some(json!({
"path": path,
"start_line": start_line,
"end_line": end_line,
"lines": context,
}))
}
fn git_apply_error_hint(stderr: &str) -> &'static str {
let lower = stderr.to_lowercase();
if lower.contains("corrupt patch") {
"Patch is malformed. Ensure it uses valid unified diff format: \
--- a/path, +++ b/path, @@ hunk headers with line counts, and context lines \
(starting with space) that exactly match the file on disk."
} else if lower.contains("patch does not apply") || lower.contains("does not apply") {
"The patch context lines don't match the file on disk. Re-read the file with \
read_file and regenerate the diff against the content you see. Ensure the @@ hunk \
line numbers and counts are correct for the target file."
} else if lower.contains("no such file or directory") {
"The target file doesn't exist. For new files, use --- /dev/null and \
+++ b/newfile/path. For renames, ensure both old and new paths are correct."
} else if lower.contains("already exists") {
"The file already exists. To modify an existing file, use --- a/path and +++ b/path. \
For new files, the file must not already exist."
} else if lower.contains("permission denied") {
"Permission denied. Check file permissions on the target file or directory."
} else {
"Check that the patch uses unified diff format with correct --- a/ and +++ b/ headers, \
@@ hunk headers with accurate line numbers, and context lines that match the file \
content. Re-read the file before regenerating the patch."
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[tokio::test]
async fn test_direct_write_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-1".into(),
tool_name: "write".into(),
input: json!({
"path": "test.txt",
"content": "hello\nworld\n",
}),
})
.await
.unwrap();
assert!(result.ok);
assert_eq!(fs::read_to_string(&path).unwrap(), "hello\nworld\n");
let output = result.output;
assert_eq!(output["path"], "test.txt");
assert_eq!(output["lines_added"], 2);
assert_eq!(output["lines_removed"], 0);
let diff = output["diff"].as_str().unwrap_or("");
assert!(diff.contains("*** Add File: test.txt"));
assert!(diff.contains("+hello\n+world\n"));
}
#[tokio::test]
async fn test_direct_write_creates_parent_dirs() {
let dir = tempdir().unwrap();
let path = dir.path().join("a").join("b").join("c.txt");
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-2".into(),
tool_name: "write".into(),
input: json!({
"path": "a/b/c.txt",
"content": "deep",
}),
})
.await
.unwrap();
assert!(result.ok);
assert_eq!(fs::read_to_string(&path).unwrap(), "deep");
}
#[tokio::test]
async fn test_direct_write_overwrites_existing() {
let dir = tempdir().unwrap();
let path = dir.path().join("overwrite.txt");
fs::write(&path, "old\ncontent\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-3".into(),
tool_name: "write".into(),
input: json!({
"path": "overwrite.txt",
"content": "new content",
}),
})
.await
.unwrap();
assert!(result.ok);
assert_eq!(fs::read_to_string(&path).unwrap(), "new content");
assert!(result.output["lines_removed"].as_u64().unwrap_or(0) > 0);
let diff = result.output["diff"].as_str().unwrap_or("");
assert!(diff.contains("*** Update File: overwrite.txt"));
assert!(diff.contains("-old") || diff.contains("-content"));
assert!(diff.contains("+new content"));
}
#[test]
fn test_build_write_display_diff_add_and_update() {
let add = build_write_display_diff("a.rs", None, "fn a() {}\n");
assert!(add.starts_with("*** Add File: a.rs\n"));
assert!(add.contains("+fn a() {}\n"));
let update =
build_write_display_diff("a.rs", Some("fn a() {\n 1\n}\n"), "fn a() {\n 2\n}\n");
assert!(update.contains("*** Update File: a.rs\n"));
assert!(update.contains("- 1\n"));
assert!(update.contains("+ 2\n"));
assert!(update.contains(" fn a() {"));
assert!(update.contains("@@ -1,"));
let identical = build_write_display_diff("a.rs", Some("same\n"), "same\n");
assert!(identical.is_empty());
}
#[tokio::test]
async fn test_search_replace_edits() {
let dir = tempdir().unwrap();
let path = dir.path().join("lib.rs");
fs::write(&path, "fn old() -> i32 {\n 1\n}\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-edits".into(),
tool_name: "write".into(),
input: json!({
"edits": [
{ "path": "lib.rs", "search": "fn old() -> i32 {\n 1\n}", "replace": "fn new() -> i32 {\n 2\n}" }
]
}),
})
.await
.unwrap();
assert!(result.ok, "edits failed: {:?}", result.output);
assert_eq!(
fs::read_to_string(&path).unwrap(),
"fn new() -> i32 {\n 2\n}\n"
);
let diff = result.output["diff"].as_str().unwrap_or("");
assert!(
diff.contains("*** Update File: lib.rs"),
"expected update-file display diff, got:\n{diff}"
);
assert!(
diff.contains("@@ -1,") || diff.contains("@@ -1 "),
"expected numbered hunk header, got:\n{diff}"
);
assert!(diff.contains("-fn old() -> i32 {") || diff.contains("- 1"));
assert!(diff.contains("+fn new() -> i32 {") || diff.contains("+ 2"));
assert!(result.output["lines_added"].as_u64().unwrap_or(0) > 0);
assert!(result.output["lines_removed"].as_u64().unwrap_or(0) > 0);
}
#[tokio::test]
async fn test_search_replace_edits_multiple() {
let dir = tempdir().unwrap();
let path = dir.path().join("lib.rs");
fs::write(&path, "fn a() {}\nfn b() {}\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-edits-multi".into(),
tool_name: "write".into(),
input: json!({
"edits": [
{ "path": "lib.rs", "search": "fn a() {}", "replace": "fn a_new() {}" },
{ "path": "lib.rs", "search": "fn b() {}", "replace": "fn b_new() {}" }
]
}),
})
.await
.unwrap();
assert!(result.ok, "edits failed: {:?}", result.output);
assert_eq!(
fs::read_to_string(&path).unwrap(),
"fn a_new() {}\nfn b_new() {}\n"
);
}
#[tokio::test]
async fn test_search_replace_edits_missing_block_fails() {
let dir = tempdir().unwrap();
let path = dir.path().join("lib.rs");
fs::write(&path, "fn a() {}\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-edits-missing".into(),
tool_name: "write".into(),
input: json!({
"edits": [
{ "path": "lib.rs", "search": "fn missing() {}", "replace": "fn x() {}" }
]
}),
})
.await
.unwrap();
assert!(!result.ok);
assert_eq!(result.output["error_code"], "edit_failed");
}
#[tokio::test]
async fn test_search_replace_edits_ignores_trailing_newline_difference() {
let dir = tempdir().unwrap();
let path = dir.path().join("lib.rs");
fs::write(&path, "fn a() {}\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-edits-nl".into(),
tool_name: "write".into(),
input: json!({
"edits": [
{ "path": "lib.rs", "search": "fn a() {}", "replace": "fn b() {}" }
]
}),
})
.await
.unwrap();
assert!(result.ok, "edits failed: {:?}", result.output);
assert_eq!(fs::read_to_string(&path).unwrap(), "fn b() {}\n");
}
#[tokio::test]
async fn test_apply_patch_alias_accepts_edits() {
let dir = tempdir().unwrap();
let path = dir.path().join("lib.rs");
fs::write(&path, "const X: i32 = 1;\n").unwrap();
let tool = WriteTool::apply_patch(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-alias-edits".into(),
tool_name: "apply_patch".into(),
input: json!({
"edits": [
{ "path": "lib.rs", "search": "const X: i32 = 1;", "replace": "const X: i32 = 2;" }
]
}),
})
.await
.unwrap();
assert!(
result.ok,
"apply_patch alias edits failed: {:?}",
result.output
);
assert_eq!(fs::read_to_string(&path).unwrap(), "const X: i32 = 2;\n");
}
#[tokio::test]
async fn test_structured_add_file() {
let dir = tempdir().unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-add".into(),
tool_name: "write".into(),
input: json!({
"patch": "*** Begin Patch\n*** Add File: new.txt\n+hello\n+world\n*** End Patch",
}),
})
.await
.unwrap();
assert!(result.ok, "patch failed: {:?}", result.output);
let path = dir.path().join("new.txt");
assert_eq!(fs::read_to_string(&path).unwrap(), "hello\nworld\n");
}
#[tokio::test]
async fn test_structured_update_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("edit.txt");
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-upd".into(),
tool_name: "write".into(),
input: json!({
"patch": format!(
"*** Begin Patch\n*** Update File: edit.txt\n@@\n foo\n-bar\n+BAR\n*** End Patch"
),
}),
})
.await
.unwrap();
assert!(result.ok, "patch failed: {:?}", result.output);
assert_eq!(fs::read_to_string(&path).unwrap(), "foo\nBAR\nbaz\n");
}
#[tokio::test]
async fn test_structured_update_accepts_unprefixed_context_lines() {
let dir = tempdir().unwrap();
let path = dir.path().join("lib.rs");
fs::write(&path, "pub fn target() -> i32 {\n 1\n}\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-unprefixed-context".into(),
tool_name: "write".into(),
input: json!({
"patch": "*** Begin Patch\n*** Update File: lib.rs\n@@\npub fn target() -> i32 {\n- 1\n+ 2\n}\n*** End Patch",
}),
})
.await
.unwrap();
assert!(result.ok, "patch failed: {:?}", result.output);
assert_eq!(
fs::read_to_string(&path).unwrap(),
"pub fn target() -> i32 {\n 2\n}\n"
);
}
#[tokio::test]
async fn test_malformed_structured_patch_can_recover_symbol_replacement() {
let dir = tempdir().unwrap();
let path = dir.path().join("lib.rs");
fs::write(
&path,
"#[derive(Debug)]\npub struct SymbolRecord;\n\npub fn search_symbols() -> i32 {\n 1\n}\n",
)
.unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-symbol-fallback".into(),
tool_name: "write".into(),
input: json!({
"patch": "*** Begin Patch\n*** Update File: lib.rs\n@@\n-use std::collections::HashSet;\n+\n #[derive(Debug)]\n@@\n-use std::collections::HashSet;\n pub fn search_symbols() -> i32 {\n 1\n@@\n pub fn search_symbols() -> i32 {\n 2\n }\n*** End Patch",
}),
})
.await
.unwrap();
assert!(result.ok, "patch failed: {:?}", result.output);
assert_eq!(
result.output["method"],
"structured_symbol_replacement_fallback"
);
assert_eq!(
fs::read_to_string(&path).unwrap(),
"#[derive(Debug)]\npub struct SymbolRecord;\n\npub fn search_symbols() -> i32 {\n 2\n}\n"
);
}
#[tokio::test]
async fn test_structured_delete_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("del.txt");
fs::write(&path, "delete me\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-del".into(),
tool_name: "write".into(),
input: json!({
"patch": "*** Begin Patch\n*** Delete File: del.txt\n*** End Patch",
}),
})
.await
.unwrap();
assert!(result.ok);
assert!(!path.exists());
}
#[tokio::test]
async fn test_structured_move_file() {
let dir = tempdir().unwrap();
let src = dir.path().join("src.txt");
let dst = dir.path().join("dst.txt");
fs::write(&src, "line\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-mv".into(),
tool_name: "write".into(),
input: json!({
"patch": "*** Begin Patch\n*** Update File: src.txt\n*** Move to: dst.txt\n@@\n-line\n+line2\n*** End Patch",
}),
})
.await
.unwrap();
assert!(result.ok, "patch failed: {:?}", result.output);
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "line2\n");
}
#[tokio::test]
async fn test_multiple_patches() {
let dir = tempdir().unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-multi".into(),
tool_name: "write".into(),
input: json!({
"patches": [
"*** Begin Patch\n*** Add File: a.txt\n+aaa\n*** End Patch",
"*** Begin Patch\n*** Add File: b.txt\n+bbb\n*** End Patch",
],
}),
})
.await
.unwrap();
assert!(result.ok, "multi-patch failed: {:?}", result.output);
assert_eq!(
fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"aaa\n"
);
assert_eq!(
fs::read_to_string(dir.path().join("b.txt")).unwrap(),
"bbb\n"
);
}
#[tokio::test]
async fn test_heredoc_stripping() {
let dir = tempdir().unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-heredoc".into(),
tool_name: "write".into(),
input: json!({
"patch": "<<'EOF'\n*** Begin Patch\n*** Add File: from_heredoc.txt\n+heredoc content\n*** End Patch\nEOF",
}),
})
.await
.unwrap();
assert!(result.ok, "heredoc patch failed: {:?}", result.output);
let path = dir.path().join("from_heredoc.txt");
assert_eq!(fs::read_to_string(&path).unwrap(), "heredoc content\n");
}
#[tokio::test]
async fn test_verification_detects_mismatch() {
let dir = tempdir().unwrap();
let path = dir.path().join("target.txt");
fs::write(&path, "line1\nline2\n").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-verify".into(),
tool_name: "write".into(),
input: json!({
"patch": "*** Begin Patch\n*** Update File: target.txt\n@@\n-nonexistent\n+replacement\n*** End Patch",
}),
})
.await
.unwrap();
assert!(!result.ok, "patch should have failed verification");
let err = result.output["error_code"].as_str().unwrap_or("");
assert_eq!(err, "verification_failed");
}
#[tokio::test]
async fn test_environment_id_preamble() {
let dir = tempdir().unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-env".into(),
tool_name: "write".into(),
input: json!({
"patch": "*** Begin Patch\n*** Environment ID: remote\n*** Add File: env.txt\n+hello\n*** End Patch",
}),
})
.await
.unwrap();
assert!(result.ok, "env-id patch failed: {:?}", result.output);
let path = dir.path().join("env.txt");
assert_eq!(fs::read_to_string(&path).unwrap(), "hello\n");
}
#[tokio::test]
async fn test_no_args_error() {
let dir = tempdir().unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-noargs".into(),
tool_name: "write".into(),
input: json!({}),
})
.await
.unwrap();
assert!(!result.ok);
}
#[test]
fn test_patch_affected_files_structured() {
let patch = "*** Begin Patch\n*** Add File: new.txt\n+content\n*** Update File: old.txt\n@@\n ctx\n-old\n+new\n*** Delete File: gone.txt\n*** End Patch"
.to_string();
let files = patch_affected_files(&[patch]).unwrap();
assert_eq!(files, vec!["gone.txt", "new.txt", "old.txt"]);
}
#[test]
fn test_patch_affected_files_unified() {
let patch = "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new".to_string();
let files = patch_affected_files(&[patch]).unwrap();
assert_eq!(files, vec!["src/main.rs"]);
}
#[tokio::test]
async fn test_fuzzy_unicode_match() {
let dir = tempdir().unwrap();
let path = dir.path().join("unicode.py");
let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n";
fs::write(&path, original).unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.invoke(ToolInvocation {
id: "test-fuzzy".into(),
tool_name: "write".into(),
input: json!({
"patch": format!(
"*** Begin Patch\n*** Update File: unicode.py\n@@\n-import asyncio # local import - avoids top-level dep\n+import asyncio # HELLO\n*** End Patch"
),
}),
})
.await
.unwrap();
assert!(result.ok, "fuzzy patch failed: {:?}", result.output);
let expected = "import asyncio # HELLO\n";
assert_eq!(fs::read_to_string(&path).unwrap(), expected);
}
}