use serde_json::json;
use std::path::{Path, PathBuf};
use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolScheduling};
use lash_tool_support::{
StaticToolExecute, StaticToolProvider, compact_diff, display_relative, normalize_lexical,
object_schema, require_str, resolve_under, run_blocking,
};
const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
const END_PATCH_MARKER: &str = "*** End Patch";
const ADD_FILE_MARKER: &str = "*** Add File: ";
const DELETE_FILE_MARKER: &str = "*** Delete File: ";
const UPDATE_FILE_MARKER: &str = "*** Update File: ";
const MOVE_TO_MARKER: &str = "*** Move to: ";
const EOF_MARKER: &str = "*** End of File";
const CHANGE_CONTEXT_MARKER: &str = "@@ ";
const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
const APPLY_PATCH_INSTRUCTIONS: &str = r#"Use `files.patch(...)` to edit files. The patch body is a stripped-down, file-oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high-level envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create or replace a file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by *** Move to: <new path> if you want to rename the file.
Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
- " " (a space) for unchanged context
- "-" for a line being removed
- "+" for a line being added
For [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change's [context_after] lines in the second change's [context_before] lines.
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
@@ class BaseClass
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
@@ class BaseClass
@@ def method():
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
The full grammar definition is below:
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
```
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
```
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
- File references can only be relative, NEVER ABSOLUTE.
- Avoid re-reading a file just to confirm a successful patch; if `files.patch` succeeds, trust it and move on to the next targeted check"#;
#[derive(Default)]
pub struct ApplyPatchTool;
pub fn apply_patch_provider() -> StaticToolProvider<ApplyPatchTool> {
StaticToolProvider::new(vec![apply_patch_tool_definition()], ApplyPatchTool)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatchAction {
Add,
Delete,
Update,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchFileOp {
pub action: PatchAction,
pub path: PathBuf,
pub move_path: Option<PathBuf>,
}
#[async_trait::async_trait]
impl StaticToolExecute for ApplyPatchTool {
async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
let input = match require_str(call.args, "input") {
Ok(value) => value.to_string(),
Err(err) => return err,
};
let workdir = call
.args
.get("workdir")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(str::to_string);
run_blocking(move || apply_patch(&input, workdir.as_deref())).await
}
}
fn apply_patch_tool_definition() -> ToolDefinition {
ToolDefinition::raw(
"tool:apply_patch",
"apply_patch",
APPLY_PATCH_INSTRUCTIONS,
object_schema(
serde_json::json!({
"input": {
"type": "string",
"description": "Patch body in the file patch format"
},
"workdir": {
"type": "string",
"description": "Optional working directory used to resolve relative patch paths"
}
}),
&["input"],
),
apply_patch_output_schema(),
)
.with_examples(vec![
"await files.patch({ input: \"*** Begin Patch\\n*** Add File: hello.txt\\n+hello\\n*** End Patch\" })?"
.into(),
"await files.patch({ input: \"*** Begin Patch\\n*** Update File: src/main.rs\\n@@ fn main() {\\n- old();\\n+ new();\\n*** End Patch\" })?"
.into(),
])
.with_agent_surface(lash_tool_support::agent_surface(
["files"],
"patch",
&["patch", "edit_file"],
))
.with_scheduling(ToolScheduling::Serial)
}
fn apply_patch_output_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"summary": { "type": "string" },
"added": { "type": "integer", "minimum": 0 },
"removed": { "type": "integer", "minimum": 0 },
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string" },
"status": { "type": "string", "enum": ["added", "deleted", "modified", "moved"] },
"added": { "type": "integer", "minimum": 0 },
"removed": { "type": "integer", "minimum": 0 },
"diff": { "type": "string" },
"from_path": { "type": "string" }
},
"required": ["path", "status", "added", "removed", "diff"],
"additionalProperties": false
}
},
"diff": {
"type": "string",
"description": "Combined diff preview capped to the first three changed files."
}
},
"required": ["summary", "added", "removed", "files", "diff"],
"additionalProperties": false
})
}
#[cfg(test)]
mod description_tests {
use super::*;
#[test]
fn apply_patch_description_mentions_avoiding_rereads() {
let description = apply_patch_tool_definition().manifest().description;
assert!(description.contains("Avoid re-reading a file"));
}
}
pub fn apply_patch(input: &str, workdir: Option<&str>) -> ToolResult {
let patch = match parse_patch(input) {
Ok(patch) => patch,
Err(err) => return ToolResult::err_fmt(err),
};
if patch.hunks.is_empty() {
return ToolResult::err_fmt("No files were modified.");
}
let cwd = match resolve_patch_workdir(workdir) {
Ok(path) => path,
Err(err) => return ToolResult::err_fmt(err),
};
let mut applied = Vec::new();
for hunk in &patch.hunks {
match apply_hunk(hunk, &cwd) {
Ok(change) => applied.push(change),
Err(err) => return ToolResult::err_fmt(err),
}
}
let files = applied
.iter()
.map(PreparedChange::as_json)
.collect::<Vec<_>>();
let changed = applied.len();
let (total_added, total_removed) = applied.iter().fold((0usize, 0usize), |acc, change| {
let (added, removed) = change.line_delta();
(acc.0 + added, acc.1 + removed)
});
let summary = format!(
"Applied patch to {} file{}",
changed,
if changed == 1 { "" } else { "s" }
);
let combined_diff = applied
.iter()
.map(PreparedChange::diff)
.filter(|diff| !diff.trim().is_empty())
.take(3)
.map(str::to_string)
.collect::<Vec<_>>()
.join("\n");
ToolResult::ok(json!({
"summary": summary,
"added": total_added,
"removed": total_removed,
"files": files,
"diff": combined_diff,
}))
}
pub fn inspect_patch_ops(input: &str, workdir: Option<&str>) -> Result<Vec<PatchFileOp>, String> {
let patch = parse_patch(input)?;
let cwd = resolve_patch_workdir(workdir)?;
Ok(patch
.hunks
.into_iter()
.map(|hunk| match hunk {
Hunk::Add { path, .. } => PatchFileOp {
action: PatchAction::Add,
path: resolve_under(&cwd, &path),
move_path: None,
},
Hunk::Delete { path } => PatchFileOp {
action: PatchAction::Delete,
path: resolve_under(&cwd, &path),
move_path: None,
},
Hunk::Update {
path, move_path, ..
} => PatchFileOp {
action: PatchAction::Update,
path: resolve_under(&cwd, &path),
move_path: move_path.as_ref().map(|target| resolve_under(&cwd, target)),
},
})
.collect())
}
#[derive(Debug, PartialEq, Clone)]
struct ParsedPatch {
hunks: Vec<Hunk>,
}
#[derive(Debug, PartialEq, Clone)]
enum Hunk {
Add {
path: PathBuf,
contents: String,
},
Delete {
path: PathBuf,
},
Update {
path: PathBuf,
move_path: Option<PathBuf>,
chunks: Vec<UpdateFileChunk>,
},
}
#[derive(Debug, PartialEq, Clone)]
struct UpdateFileChunk {
change_context: Option<String>,
old_lines: Vec<String>,
new_lines: Vec<String>,
is_end_of_file: bool,
}
fn parse_patch(input: &str) -> Result<ParsedPatch, String> {
let normalized = normalize_patch_input(input);
let lines: Vec<&str> = normalized.lines().collect();
let (first, last) = match lines.as_slice() {
[] => (None, None),
[first] => (Some(first.trim()), Some(first.trim())),
[first, .., last] => (Some(first.trim()), Some(last.trim())),
};
match (first, last) {
(Some(BEGIN_PATCH_MARKER), Some(END_PATCH_MARKER)) => {}
(Some(first), _) if first != BEGIN_PATCH_MARKER => {
return Err("The first line of the patch must be '*** Begin Patch'".to_string());
}
_ => return Err("The last line of the patch must be '*** End Patch'".to_string()),
}
if lines.len() <= 2 {
return Ok(ParsedPatch { hunks: Vec::new() });
}
let mut hunks = Vec::new();
let mut remaining = &lines[1..lines.len() - 1];
let mut line_number = 2usize;
while !remaining.is_empty() {
let (hunk, consumed) = parse_one_hunk(remaining, line_number)?;
hunks.push(hunk);
remaining = &remaining[consumed..];
line_number += consumed;
}
Ok(ParsedPatch { hunks })
}
fn normalize_patch_input(input: &str) -> String {
let trimmed = input.trim();
if has_patch_boundaries(trimmed.lines()) {
return trimmed.to_string();
}
strip_heredoc_wrapper(trimmed).unwrap_or_else(|| trimmed.to_string())
}
fn has_patch_boundaries<'a>(mut lines: impl DoubleEndedIterator<Item = &'a str>) -> bool {
match (lines.next(), lines.next_back()) {
(Some(first), Some(last)) => {
first.trim() == BEGIN_PATCH_MARKER && last.trim() == END_PATCH_MARKER
}
(Some(only), None) => only.trim() == BEGIN_PATCH_MARKER,
_ => false,
}
}
fn strip_heredoc_wrapper(input: &str) -> Option<String> {
let lines: Vec<&str> = input.lines().collect();
if lines.len() < 4 {
return None;
}
let marker = parse_heredoc_start(lines[0].trim())?;
let last = lines.last()?.trim();
if last != marker && !last.ends_with(marker) {
return None;
}
let inner = lines[1..lines.len() - 1].join("\n");
has_patch_boundaries(inner.lines()).then(|| inner.trim().to_string())
}
fn resolve_patch_workdir(workdir: Option<&str>) -> Result<PathBuf, String> {
let here = std::env::current_dir().map_err(|err| format!("Failed to determine cwd: {err}"))?;
Ok(match workdir {
Some(path) => resolve_under(&here, Path::new(path)),
None => normalize_lexical(&here),
})
}
fn parse_heredoc_start(line: &str) -> Option<&str> {
let marker = if let Some(rest) = line.strip_prefix("<<") {
rest
} else {
line.strip_prefix("apply_patch <<")?
};
let marker = marker.trim();
if marker.len() >= 2 {
let bytes = marker.as_bytes();
if (bytes[0] == b'\'' && bytes[marker.len() - 1] == b'\'')
|| (bytes[0] == b'"' && bytes[marker.len() - 1] == b'"')
{
return Some(&marker[1..marker.len() - 1]);
}
}
Some(marker)
}
fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), String> {
if lines.is_empty() {
return Err(format!("invalid hunk at line {line_number}: missing hunk"));
}
let first_line = lines[0].trim();
if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
let mut contents = String::new();
let mut consumed = 1usize;
for line in &lines[1..] {
if let Some(text) = line.strip_prefix('+') {
contents.push_str(text);
contents.push('\n');
consumed += 1;
} else {
break;
}
}
if contents.is_empty()
&& lines
.get(1)
.is_some_and(|line| !line.trim_start().starts_with("***"))
{
return Err(format!(
"invalid hunk at line {line_number}: add file hunk for '{path}' is empty; every new file content line must start with '+'"
));
}
return Ok((
Hunk::Add {
path: PathBuf::from(path),
contents,
},
consumed,
));
}
if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) {
return Ok((
Hunk::Delete {
path: PathBuf::from(path),
},
1,
));
}
if let Some(path) = first_line.strip_prefix(UPDATE_FILE_MARKER) {
let mut consumed = 1usize;
let mut remaining = &lines[1..];
let move_path = remaining
.first()
.and_then(|line| line.strip_prefix(MOVE_TO_MARKER))
.map(PathBuf::from);
if move_path.is_some() {
consumed += 1;
remaining = &remaining[1..];
}
let mut chunks = Vec::new();
while !remaining.is_empty() {
if remaining[0].trim().is_empty() {
consumed += 1;
remaining = &remaining[1..];
continue;
}
if remaining[0].starts_with("***") {
break;
}
let (chunk, chunk_lines) =
parse_update_file_chunk(remaining, line_number + consumed, chunks.is_empty())?;
chunks.push(chunk);
consumed += chunk_lines;
remaining = &remaining[chunk_lines..];
}
if chunks.is_empty() {
return Err(format!(
"invalid hunk at line {line_number}: update file hunk for path '{path}' is empty"
));
}
return Ok((
Hunk::Update {
path: PathBuf::from(path),
move_path,
chunks,
},
consumed,
));
}
Err(format!(
"invalid hunk at line {line_number}: '{first_line}' is not a valid hunk header"
))
}
fn parse_update_file_chunk(
lines: &[&str],
line_number: usize,
allow_missing_context: bool,
) -> Result<(UpdateFileChunk, usize), String> {
if lines.is_empty() {
return Err(format!(
"invalid hunk at line {line_number}: update hunk does not contain any lines"
));
}
let (change_context, start_index) = if lines[0].trim_end() == EMPTY_CHANGE_CONTEXT_MARKER {
(None, 1)
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
(Some(context.to_string()), 1)
} else if allow_missing_context {
(None, 0)
} else {
return Err(format!(
"invalid hunk at line {line_number}: expected update hunk to start with a @@ context marker"
));
};
if start_index >= lines.len() {
return Err(format!(
"invalid hunk at line {}: update hunk does not contain any lines",
line_number + 1
));
}
let mut chunk = UpdateFileChunk {
change_context,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
};
let mut parsed_lines = 0usize;
for line in &lines[start_index..] {
match *line {
EOF_MARKER => {
if parsed_lines == 0 {
return Err(format!(
"invalid hunk at line {}: update hunk does not contain any lines",
line_number + 1
));
}
chunk.is_end_of_file = true;
parsed_lines += 1;
break;
}
line_contents => match line_contents.chars().next() {
None => {
chunk.old_lines.push(String::new());
chunk.new_lines.push(String::new());
parsed_lines += 1;
}
Some(' ') => {
chunk.old_lines.push(line_contents[1..].to_string());
chunk.new_lines.push(line_contents[1..].to_string());
parsed_lines += 1;
}
Some('+') => {
chunk.new_lines.push(line_contents[1..].to_string());
parsed_lines += 1;
}
Some('-') => {
chunk.old_lines.push(line_contents[1..].to_string());
parsed_lines += 1;
}
_ => {
if parsed_lines == 0 {
return Err(format!(
"invalid hunk at line {}: unexpected line in update hunk",
line_number + 1
));
}
break;
}
},
}
}
Ok((chunk, parsed_lines + start_index))
}
enum PreparedChange {
Add {
display_path: String,
diff: String,
},
Delete {
display_path: String,
diff: String,
},
Update {
display_path: String,
diff: String,
},
Move {
from_display_path: String,
display_path: String,
diff: String,
},
}
impl PreparedChange {
fn diff(&self) -> &str {
match self {
Self::Add { diff, .. }
| Self::Delete { diff, .. }
| Self::Update { diff, .. }
| Self::Move { diff, .. } => diff,
}
}
fn line_delta(&self) -> (usize, usize) {
count_diff_delta(self.diff())
}
fn as_json(&self) -> serde_json::Value {
let (added, removed) = self.line_delta();
match self {
Self::Add {
display_path, diff, ..
} => json!({
"path": display_path,
"status": "added",
"added": added,
"removed": removed,
"diff": diff,
}),
Self::Delete {
display_path, diff, ..
} => json!({
"path": display_path,
"status": "deleted",
"added": added,
"removed": removed,
"diff": diff,
}),
Self::Update {
display_path, diff, ..
} => json!({
"path": display_path,
"status": "modified",
"added": added,
"removed": removed,
"diff": diff,
}),
Self::Move {
from_display_path,
display_path,
diff,
..
} => json!({
"path": display_path,
"from_path": from_display_path,
"status": "moved",
"added": added,
"removed": removed,
"diff": diff,
}),
}
}
}
fn count_diff_delta(diff: &str) -> (usize, usize) {
let mut added = 0usize;
let mut removed = 0usize;
for line in diff.lines() {
if line.starts_with("+++ ") || line.starts_with("--- ") || line.starts_with("@@") {
continue;
}
if line.starts_with('+') {
added += 1;
} else if line.starts_with('-') {
removed += 1;
}
}
(added, removed)
}
fn apply_hunk(hunk: &Hunk, cwd: &Path) -> Result<PreparedChange, String> {
match hunk {
Hunk::Add { path, contents } => {
let resolved = resolve_under(cwd, path);
let original_contents = std::fs::read_to_string(&resolved).unwrap_or_default();
if let Some(parent) = resolved.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"Failed to create directories for {}: {err}",
resolved.display()
)
})?;
}
std::fs::write(&resolved, contents)
.map_err(|err| format!("Failed to write {}: {err}", resolved.display()))?;
let display_path = display_relative(cwd, &resolved);
let diff = compact_diff(&original_contents, contents, &display_path, 120);
Ok(PreparedChange::Add { display_path, diff })
}
Hunk::Delete { path } => {
let resolved = resolve_under(cwd, path);
let original = std::fs::read_to_string(&resolved).unwrap_or_default();
std::fs::remove_file(&resolved)
.map_err(|err| format!("Failed to delete {}: {err}", resolved.display()))?;
let display_path = display_relative(cwd, &resolved);
let diff = compact_diff(&original, "", &display_path, 120);
Ok(PreparedChange::Delete { display_path, diff })
}
Hunk::Update {
path,
move_path,
chunks,
} => {
let resolved = resolve_under(cwd, path);
let applied = derive_new_contents_from_chunks(&resolved, chunks)?;
let target = move_path
.as_ref()
.map(|path| -> Result<PathBuf, String> { Ok(resolve_under(cwd, path)) })
.transpose()?
.unwrap_or_else(|| resolved.clone());
if let Some(parent) = target.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"Failed to create directories for {}: {err}",
target.display()
)
})?;
}
std::fs::write(&target, &applied.new_contents)
.map_err(|err| format!("Failed to write {}: {err}", target.display()))?;
if move_path.is_some() {
std::fs::remove_file(&resolved).map_err(|err| {
format!("Failed to remove original {}: {err}", resolved.display())
})?;
}
let display_path = display_relative(cwd, &target);
let diff = compact_diff(
&applied.original_contents,
&applied.new_contents,
&display_path,
120,
);
if move_path.is_some() {
Ok(PreparedChange::Move {
from_display_path: display_relative(cwd, &resolved),
display_path,
diff,
})
} else {
Ok(PreparedChange::Update { display_path, diff })
}
}
}
}
struct AppliedPatch {
original_contents: String,
new_contents: String,
}
fn derive_new_contents_from_chunks(
path: &Path,
chunks: &[UpdateFileChunk],
) -> Result<AppliedPatch, String> {
let original_contents = std::fs::read_to_string(path)
.map_err(|err| format!("Failed to read file to update {}: {err}", path.display()))?;
let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect();
if original_lines.last().is_some_and(String::is_empty) {
original_lines.pop();
}
let replacements = compute_replacements(&original_lines, path, chunks)?;
let mut new_lines = apply_replacements(original_lines, &replacements);
if !new_lines.last().is_some_and(String::is_empty) {
new_lines.push(String::new());
}
let new_contents = new_lines.join("\n");
Ok(AppliedPatch {
original_contents,
new_contents,
})
}
fn compute_replacements(
original_lines: &[String],
path: &Path,
chunks: &[UpdateFileChunk],
) -> Result<Vec<(usize, usize, Vec<String>)>, String> {
let mut replacements = Vec::new();
let mut line_index = 0usize;
for chunk in chunks {
if let Some(ctx_line) = &chunk.change_context {
if let Some(index) = seek_sequence(
original_lines,
std::slice::from_ref(ctx_line),
line_index,
false,
) {
line_index = index;
} else {
return Err(format!(
"Failed to find context '{}' in {}",
ctx_line,
path.display()
));
}
}
if chunk.old_lines.is_empty() {
let insertion_idx = if original_lines.last().is_some_and(String::is_empty) {
original_lines.len().saturating_sub(1)
} else {
original_lines.len()
};
replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
continue;
}
let mut pattern: &[String] = &chunk.old_lines;
let mut new_slice: &[String] = &chunk.new_lines;
let mut found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
if found.is_none() && pattern.last().is_some_and(String::is_empty) {
pattern = &pattern[..pattern.len() - 1];
if new_slice.last().is_some_and(String::is_empty) {
new_slice = &new_slice[..new_slice.len() - 1];
}
found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
}
if let Some(start_idx) = found {
replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
line_index = start_idx + pattern.len();
} else {
return Err(format!(
"Failed to find expected lines in {}:\n{}",
path.display(),
chunk.old_lines.join("\n")
));
}
}
replacements.sort_by_key(|(start_idx, _, _)| *start_idx);
Ok(replacements)
}
fn apply_replacements(
mut lines: Vec<String>,
replacements: &[(usize, usize, Vec<String>)],
) -> Vec<String> {
for (start_idx, old_len, new_segment) in replacements.iter().rev() {
for _ in 0..*old_len {
if *start_idx < lines.len() {
lines.remove(*start_idx);
}
}
for (offset, line) in new_segment.iter().enumerate() {
lines.insert(*start_idx + offset, line.clone());
}
}
lines
}
fn seek_sequence(lines: &[String], pattern: &[String], start: usize, eof: bool) -> Option<usize> {
if pattern.is_empty() {
return Some(start);
}
if pattern.len() > lines.len() {
return None;
}
let search_start = if eof && lines.len() >= pattern.len() {
lines.len() - pattern.len()
} else {
start
};
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
if lines[i..i + pattern.len()] == *pattern {
return Some(i);
}
}
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if lines[i + p_idx].trim_end() != pat.trim_end() {
ok = false;
break;
}
}
if ok {
return Some(i);
}
}
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if lines[i + p_idx].trim() != pat.trim() {
ok = false;
break;
}
}
if ok {
return Some(i);
}
}
fn normalize_for_match(text: &str) -> String {
text.trim()
.chars()
.map(|ch| match ch {
'\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
| '\u{2212}' => '-',
'\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
'\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
'\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
| '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
| '\u{3000}' => ' ',
other => other,
})
.collect()
}
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if normalize_for_match(&lines[i + p_idx]) != normalize_for_match(pat) {
ok = false;
break;
}
}
if ok {
return Some(i);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn run_patch(dir: &TempDir, input: impl AsRef<str>) -> ToolResult {
apply_patch(input.as_ref(), Some(dir.path().to_str().unwrap()))
}
#[test]
fn apply_patch_contract_documents_result_shape() {
let definition = apply_patch_tool_definition();
assert_eq!(
definition.contract.output_schema["properties"]["files"]["type"],
serde_json::json!("array")
);
let rendered = definition.compact_contract().render_signature();
assert!(rendered.contains("files"), "{rendered}");
assert!(rendered.contains("summary"), "{rendered}");
}
#[test]
fn direct_apply_patch_creates_file() {
let dir = TempDir::new().unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch",
);
assert!(result.is_success());
assert_eq!(
std::fs::read_to_string(dir.path().join("hello.txt")).unwrap(),
"hello\n"
);
}
#[test]
fn update_file_patch_modifies_file() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {\n old();\n}\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: main.rs\n@@ fn main() {\n- old();\n+ new();\n*** End Patch",
);
assert!(result.is_success());
assert_eq!(
std::fs::read_to_string(dir.path().join("main.rs")).unwrap(),
"fn main() {\n new();\n}\n"
);
}
#[test]
fn delete_file_patch_removes_file() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("old.txt"), "gone\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Delete File: old.txt\n*** End Patch",
);
assert!(result.is_success());
assert!(!dir.path().join("old.txt").exists());
}
#[test]
fn move_patch_renames_file() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("old.txt"), "line\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n line\n*** End Patch",
);
assert!(result.is_success());
assert!(!dir.path().join("old.txt").exists());
assert_eq!(
std::fs::read_to_string(dir.path().join("new.txt")).unwrap(),
"line\n"
);
}
#[test]
fn patch_result_uses_workdir_relative_display_paths() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("base.txt"), "old\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: base.txt\n@@\n-old\n+new\n*** End Patch",
);
assert!(result.is_success());
let result_value = result.value_for_projection();
let diff = result_value["diff"].as_str().expect("diff");
assert!(diff.contains("--- a/base.txt"));
assert!(diff.contains("+++ b/base.txt"));
assert!(!diff.contains("/tmp/"));
assert_eq!(result_value["files"][0]["path"], "base.txt");
assert_eq!(result_value["files"][0]["added"], 1);
assert_eq!(result_value["files"][0]["removed"], 1);
assert_eq!(result_value["added"], 1);
assert_eq!(result_value["removed"], 1);
}
#[test]
fn add_file_patch_requires_plus_prefixed_lines() {
let dir = TempDir::new().unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Add File: hello.txt\nhello\n*** End Patch",
);
assert!(!result.is_success());
assert!(
result
.value_for_projection()
.to_string()
.contains("must start with '+'")
);
}
#[test]
fn add_file_patch_can_create_truly_empty_file() {
let dir = TempDir::new().unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Add File: empty.txt\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("empty.txt")).unwrap(),
""
);
assert_eq!(
result.value_for_projection()["files"][0]["path"],
"empty.txt"
);
}
#[test]
fn update_file_patch_allows_first_chunk_without_explicit_marker() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("module.py"), "import alpha\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: module.py\n import alpha\n+import beta\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("module.py")).unwrap(),
"import alpha\nimport beta\n"
);
}
#[test]
fn update_file_patch_accepts_whitespace_padded_headers_and_markers() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("pad.txt"), "one\n").unwrap();
let result = run_patch(
&dir,
" *** Begin Patch\n *** Update File: pad.txt\n@@\n-one\n+two\n *** End Patch ",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("pad.txt")).unwrap(),
"two\n"
);
}
#[test]
fn update_file_patch_supports_pure_addition_chunk() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("notes.txt"), "alpha\nbeta\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: notes.txt\n@@\n+gamma\n+delta\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("notes.txt")).unwrap(),
"alpha\nbeta\ngamma\ndelta\n"
);
}
#[test]
fn update_file_patch_supports_deletion_only_chunk() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("lines.txt"), "line1\nline2\nline3\nline4\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: lines.txt\n@@\n line1\n-line2\n line3\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("lines.txt")).unwrap(),
"line1\nline3\nline4\n"
);
}
#[test]
fn update_file_patch_supports_end_of_file_marker() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("tail.txt"), "first\nsecond\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: tail.txt\n@@\n first\n-second\n+second updated\n*** End of File\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("tail.txt")).unwrap(),
"first\nsecond updated\n"
);
}
#[test]
fn update_file_patch_appends_trailing_newline() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("plain.txt"), "just one line").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: plain.txt\n@@\n-just one line\n+first row\n+second row\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("plain.txt")).unwrap(),
"first row\nsecond row\n"
);
}
#[test]
fn empty_patch_returns_no_files_modified() {
let dir = TempDir::new().unwrap();
let result = run_patch(&dir, "*** Begin Patch\n*** End Patch");
assert!(!result.is_success());
assert!(
result
.value_for_projection()
.to_string()
.contains("No files were modified.")
);
}
#[test]
fn invalid_hunk_header_is_rejected() {
let dir = TempDir::new().unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Rename File: nope.txt\n*** End Patch",
);
assert!(!result.is_success());
assert!(
result
.value_for_projection()
.to_string()
.contains("is not a valid hunk header")
);
}
#[test]
fn direct_heredoc_wrapper_is_accepted() {
let dir = TempDir::new().unwrap();
let result = run_patch(
&dir,
"<<EOF\n*** Begin Patch\n*** Add File: tiny.txt\n+ok\n*** End Patch\nEOF",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("tiny.txt")).unwrap(),
"ok\n"
);
}
#[test]
fn apply_patch_accepts_absolute_add_paths() {
let dir = TempDir::new().unwrap();
let abs = dir.path().join("hello.txt");
let input = format!(
"*** Begin Patch\n*** Add File: {}\n+hello\n*** End Patch",
abs.display()
);
let result = run_patch(&dir, input);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(std::fs::read_to_string(&abs).unwrap(), "hello\n");
assert_eq!(
result.value_for_projection()["files"][0]["path"],
"hello.txt"
);
}
#[test]
fn apply_patch_accepts_absolute_update_paths() {
let dir = TempDir::new().unwrap();
let abs = dir.path().join("main.rs");
std::fs::write(&abs, "fn main() {\n old();\n}\n").unwrap();
let input = format!(
"*** Begin Patch\n*** Update File: {}\n@@ fn main() {{\n- old();\n+ new();\n*** End Patch",
abs.display()
);
let result = run_patch(&dir, input);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(&abs).unwrap(),
"fn main() {\n new();\n}\n"
);
assert_eq!(result.value_for_projection()["files"][0]["path"], "main.rs");
}
#[test]
fn apply_patch_accepts_absolute_delete_paths() {
let dir = TempDir::new().unwrap();
let abs = dir.path().join("old.txt");
std::fs::write(&abs, "gone\n").unwrap();
let input = format!(
"*** Begin Patch\n*** Delete File: {}\n*** End Patch",
abs.display()
);
let result = run_patch(&dir, input);
assert!(result.is_success(), "{}", result.value_for_projection());
assert!(!abs.exists());
assert_eq!(result.value_for_projection()["files"][0]["path"], "old.txt");
}
#[test]
fn apply_patch_accepts_absolute_move_paths() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("old.txt");
let dest = dir.path().join("nested").join("new.txt");
std::fs::write(&source, "line\n").unwrap();
let input = format!(
"*** Begin Patch\n*** Update File: {}\n*** Move to: {}\n@@\n line\n*** End Patch",
source.display(),
dest.display()
);
let result = run_patch(&dir, input);
assert!(result.is_success(), "{}", result.value_for_projection());
assert!(!source.exists());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "line\n");
assert_eq!(
result.value_for_projection()["files"][0]["path"],
"nested/new.txt"
);
}
#[test]
fn apply_patch_accepts_lenient_heredoc_wrapper() {
let dir = TempDir::new().unwrap();
let result = run_patch(
&dir,
"apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\nPATCH",
);
assert!(result.is_success());
assert_eq!(
std::fs::read_to_string(dir.path().join("hello.txt")).unwrap(),
"hello\n"
);
}
#[test]
fn apply_patch_treats_unified_diff_header_as_plain_context() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {\n old();\n}\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: main.rs\n@@ -1,3 +1,3 @@\n- old();\n+ new();\n*** End Patch",
);
assert!(!result.is_success());
assert!(
result
.value_for_projection()
.to_string()
.contains("Failed to find context '-1,3 +1,3 @@'")
);
}
#[test]
fn update_file_patch_allows_context_label_matching_first_old_line() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("main.rs"),
"fn main() {\n println!(\"old\");\n}\n",
)
.unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: main.rs\n@@ fn main() {\n fn main() {\n- println!(\"old\");\n+ println!(\"new\");\n }\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("main.rs")).unwrap(),
"fn main() {\n println!(\"new\");\n}\n"
);
}
#[test]
fn update_file_patch_allows_whitespace_padded_bare_hunk_header() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("hello.txt"),
"Hello from apply_patch!\nLine two.\n",
)
.unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: hello.txt\n@@ \n Hello from apply_patch!\n-Line two.\n+Line two updated by patch.\n+Line three added.\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("hello.txt")).unwrap(),
"Hello from apply_patch!\nLine two updated by patch.\nLine three added.\n"
);
}
#[test]
fn update_file_patch_matches_common_unicode_punctuation() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("unicode.txt"),
"note - uses an en dash \u{2013} and a nonbreaking hyphen in top\u{2011}level text\n",
)
.unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: unicode.txt\n@@\n-note - uses an en dash - and a nonbreaking hyphen in top-level text\n+normalized replacement\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("unicode.txt")).unwrap(),
"normalized replacement\n"
);
}
#[test]
fn add_file_patch_overwrites_existing_target() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("dupe.txt"), "original\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Add File: dupe.txt\n+replacement\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("dupe.txt")).unwrap(),
"replacement\n"
);
}
#[test]
fn delete_file_patch_rejects_directory_target() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("folder")).unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Delete File: folder\n*** End Patch",
);
assert!(!result.is_success());
assert!(dir.path().join("folder").is_dir());
assert!(
result
.value_for_projection()
.to_string()
.contains("Failed to delete")
);
}
#[test]
fn delete_missing_file_reports_delete_failure() {
let dir = TempDir::new().unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch",
);
assert!(!result.is_success());
assert!(
result
.value_for_projection()
.to_string()
.contains("Failed to delete")
);
}
#[test]
fn move_patch_overwrites_existing_destination() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("renamed").join("dir")).unwrap();
std::fs::write(dir.path().join("old.txt"), "from\n").unwrap();
std::fs::write(
dir.path().join("renamed").join("dir").join("name.txt"),
"stale\n",
)
.unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert!(!dir.path().join("old.txt").exists());
assert_eq!(
std::fs::read_to_string(dir.path().join("renamed").join("dir").join("name.txt"))
.unwrap(),
"new\n"
);
}
#[test]
fn later_hunk_sees_earlier_hunk_changes() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("chain.txt"), "old\n").unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Update File: chain.txt\n@@\n-old\n+mid\n*** Update File: chain.txt\n@@\n-mid\n+new\n*** End Patch",
);
assert!(result.is_success(), "{}", result.value_for_projection());
assert_eq!(
std::fs::read_to_string(dir.path().join("chain.txt")).unwrap(),
"new\n"
);
}
#[test]
fn failed_later_hunk_keeps_earlier_successful_changes() {
let dir = TempDir::new().unwrap();
let result = run_patch(
&dir,
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch",
);
assert!(!result.is_success());
assert_eq!(
std::fs::read_to_string(dir.path().join("created.txt")).unwrap(),
"hello\n"
);
assert!(
result
.value_for_projection()
.to_string()
.contains("Failed to read file to update")
);
}
}