1use serde_json::json;
2use std::path::{Path, PathBuf};
3
4use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolScheduling};
5
6use lash_tool_support::{
7 StaticToolExecute, StaticToolProvider, compact_diff, display_relative, normalize_lexical,
8 object_schema, require_str, resolve_under, run_blocking,
9};
10
11const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
12const END_PATCH_MARKER: &str = "*** End Patch";
13const ADD_FILE_MARKER: &str = "*** Add File: ";
14const DELETE_FILE_MARKER: &str = "*** Delete File: ";
15const UPDATE_FILE_MARKER: &str = "*** Update File: ";
16const MOVE_TO_MARKER: &str = "*** Move to: ";
17const EOF_MARKER: &str = "*** End of File";
18const CHANGE_CONTEXT_MARKER: &str = "@@ ";
19const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
20const 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:
21
22*** Begin Patch
23[ one or more file sections ]
24*** End Patch
25
26Within that envelope, you get a sequence of file operations.
27You MUST include a header to specify the action you are taking.
28Each operation starts with one of three headers:
29
30*** Add File: <path> - create or replace a file. Every following line is a + line (the initial contents).
31*** Delete File: <path> - remove an existing file. Nothing follows.
32*** Update File: <path> - patch an existing file in place (optionally with a rename).
33
34May be immediately followed by *** Move to: <new path> if you want to rename the file.
35Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
36Within a hunk each line starts with:
37
38- " " (a space) for unchanged context
39- "-" for a line being removed
40- "+" for a line being added
41
42For [context_before] and [context_after]:
43- 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.
44- 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:
45
46@@ class BaseClass
47[3 lines of pre-context]
48- [old_code]
49+ [new_code]
50[3 lines of post-context]
51
52- 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:
53
54@@ class BaseClass
55@@ def method():
56[3 lines of pre-context]
57- [old_code]
58+ [new_code]
59[3 lines of post-context]
60
61The full grammar definition is below:
62Patch := Begin { FileOp } End
63Begin := "*** Begin Patch" NEWLINE
64End := "*** End Patch" NEWLINE
65FileOp := AddFile | DeleteFile | UpdateFile
66AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
67DeleteFile := "*** Delete File: " path NEWLINE
68UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
69MoveTo := "*** Move to: " newPath NEWLINE
70Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
71HunkLine := (" " | "-" | "+") text NEWLINE
72
73A full patch can combine several operations:
74
75```
76*** Begin Patch
77*** Add File: hello.txt
78+Hello world
79*** Update File: src/app.py
80*** Move to: src/main.py
81@@ def greet():
82-print("Hi")
83+print("Hello, world!")
84*** Delete File: obsolete.txt
85*** End Patch
86```
87
88It is important to remember:
89
90- You must include a header with your intended action (Add/Delete/Update)
91- You must prefix new lines with `+` even when creating a new file
92- File references can only be relative, NEVER ABSOLUTE.
93- 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"#;
94
95#[derive(Default)]
96pub struct ApplyPatchTool;
97
98pub fn apply_patch_provider() -> StaticToolProvider<ApplyPatchTool> {
100 StaticToolProvider::new(vec![apply_patch_tool_definition()], ApplyPatchTool)
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum PatchAction {
105 Add,
106 Delete,
107 Update,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct PatchFileOp {
112 pub action: PatchAction,
113 pub path: PathBuf,
114 pub move_path: Option<PathBuf>,
115}
116
117#[async_trait::async_trait]
118impl StaticToolExecute for ApplyPatchTool {
119 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
120 let input = match require_str(call.args, "input") {
121 Ok(value) => value.to_string(),
122 Err(err) => return err,
123 };
124 let workdir = call
125 .args
126 .get("workdir")
127 .and_then(|value| value.as_str())
128 .filter(|value| !value.is_empty())
129 .map(str::to_string);
130
131 run_blocking(move || apply_patch(&input, workdir.as_deref())).await
132 }
133}
134
135fn apply_patch_tool_definition() -> ToolDefinition {
136 ToolDefinition::raw(
137 "tool:apply_patch",
138 "apply_patch",
139 APPLY_PATCH_INSTRUCTIONS,
140 object_schema(
141 serde_json::json!({
142 "input": {
143 "type": "string",
144 "description": "Patch body in the file patch format"
145 },
146 "workdir": {
147 "type": "string",
148 "description": "Optional working directory used to resolve relative patch paths"
149 }
150 }),
151 &["input"],
152 ),
153 apply_patch_output_schema(),
154 )
155 .with_examples(vec![
156 "await files.patch({ input: \"*** Begin Patch\\n*** Add File: hello.txt\\n+hello\\n*** End Patch\" })?"
157 .into(),
158 "await files.patch({ input: \"*** Begin Patch\\n*** Update File: src/main.rs\\n@@ fn main() {\\n- old();\\n+ new();\\n*** End Patch\" })?"
159 .into(),
160 ])
161 .with_agent_surface(lash_tool_support::agent_surface(
162 ["files"],
163 "patch",
164 &["patch", "edit_file"],
165 ))
166 .with_scheduling(ToolScheduling::Serial)
167}
168
169fn apply_patch_output_schema() -> serde_json::Value {
170 serde_json::json!({
171 "type": "object",
172 "properties": {
173 "summary": { "type": "string" },
174 "added": { "type": "integer", "minimum": 0 },
175 "removed": { "type": "integer", "minimum": 0 },
176 "files": {
177 "type": "array",
178 "items": {
179 "type": "object",
180 "properties": {
181 "path": { "type": "string" },
182 "status": { "type": "string", "enum": ["added", "deleted", "modified", "moved"] },
183 "added": { "type": "integer", "minimum": 0 },
184 "removed": { "type": "integer", "minimum": 0 },
185 "diff": { "type": "string" },
186 "from_path": { "type": "string" }
187 },
188 "required": ["path", "status", "added", "removed", "diff"],
189 "additionalProperties": false
190 }
191 },
192 "diff": {
193 "type": "string",
194 "description": "Combined diff preview capped to the first three changed files."
195 }
196 },
197 "required": ["summary", "added", "removed", "files", "diff"],
198 "additionalProperties": false
199 })
200}
201
202#[cfg(test)]
203mod description_tests {
204 use super::*;
205
206 #[test]
207 fn apply_patch_description_mentions_avoiding_rereads() {
208 let description = apply_patch_tool_definition().manifest().description;
209 assert!(description.contains("Avoid re-reading a file"));
210 }
211}
212
213pub fn apply_patch(input: &str, workdir: Option<&str>) -> ToolResult {
214 let patch = match parse_patch(input) {
215 Ok(patch) => patch,
216 Err(err) => return ToolResult::err_fmt(err),
217 };
218
219 if patch.hunks.is_empty() {
220 return ToolResult::err_fmt("No files were modified.");
221 }
222
223 let cwd = match resolve_patch_workdir(workdir) {
224 Ok(path) => path,
225 Err(err) => return ToolResult::err_fmt(err),
226 };
227
228 let mut applied = Vec::new();
229 for hunk in &patch.hunks {
230 match apply_hunk(hunk, &cwd) {
231 Ok(change) => applied.push(change),
232 Err(err) => return ToolResult::err_fmt(err),
233 }
234 }
235
236 let files = applied
237 .iter()
238 .map(PreparedChange::as_json)
239 .collect::<Vec<_>>();
240 let changed = applied.len();
241 let (total_added, total_removed) = applied.iter().fold((0usize, 0usize), |acc, change| {
242 let (added, removed) = change.line_delta();
243 (acc.0 + added, acc.1 + removed)
244 });
245 let summary = format!(
246 "Applied patch to {} file{}",
247 changed,
248 if changed == 1 { "" } else { "s" }
249 );
250 let combined_diff = applied
251 .iter()
252 .map(PreparedChange::diff)
253 .filter(|diff| !diff.trim().is_empty())
254 .take(3)
255 .map(str::to_string)
256 .collect::<Vec<_>>()
257 .join("\n");
258
259 ToolResult::ok(json!({
260 "summary": summary,
261 "added": total_added,
262 "removed": total_removed,
263 "files": files,
264 "diff": combined_diff,
265 }))
266}
267
268pub fn inspect_patch_ops(input: &str, workdir: Option<&str>) -> Result<Vec<PatchFileOp>, String> {
269 let patch = parse_patch(input)?;
270 let cwd = resolve_patch_workdir(workdir)?;
271 Ok(patch
272 .hunks
273 .into_iter()
274 .map(|hunk| match hunk {
275 Hunk::Add { path, .. } => PatchFileOp {
276 action: PatchAction::Add,
277 path: resolve_under(&cwd, &path),
278 move_path: None,
279 },
280 Hunk::Delete { path } => PatchFileOp {
281 action: PatchAction::Delete,
282 path: resolve_under(&cwd, &path),
283 move_path: None,
284 },
285 Hunk::Update {
286 path, move_path, ..
287 } => PatchFileOp {
288 action: PatchAction::Update,
289 path: resolve_under(&cwd, &path),
290 move_path: move_path.as_ref().map(|target| resolve_under(&cwd, target)),
291 },
292 })
293 .collect())
294}
295
296#[derive(Debug, PartialEq, Clone)]
297struct ParsedPatch {
298 hunks: Vec<Hunk>,
299}
300
301#[derive(Debug, PartialEq, Clone)]
302enum Hunk {
303 Add {
304 path: PathBuf,
305 contents: String,
306 },
307 Delete {
308 path: PathBuf,
309 },
310 Update {
311 path: PathBuf,
312 move_path: Option<PathBuf>,
313 chunks: Vec<UpdateFileChunk>,
314 },
315}
316
317#[derive(Debug, PartialEq, Clone)]
318struct UpdateFileChunk {
319 change_context: Option<String>,
320 old_lines: Vec<String>,
321 new_lines: Vec<String>,
322 is_end_of_file: bool,
323}
324
325fn parse_patch(input: &str) -> Result<ParsedPatch, String> {
326 let normalized = normalize_patch_input(input);
327 let lines: Vec<&str> = normalized.lines().collect();
328 let (first, last) = match lines.as_slice() {
329 [] => (None, None),
330 [first] => (Some(first.trim()), Some(first.trim())),
331 [first, .., last] => (Some(first.trim()), Some(last.trim())),
332 };
333
334 match (first, last) {
335 (Some(BEGIN_PATCH_MARKER), Some(END_PATCH_MARKER)) => {}
336 (Some(first), _) if first != BEGIN_PATCH_MARKER => {
337 return Err("The first line of the patch must be '*** Begin Patch'".to_string());
338 }
339 _ => return Err("The last line of the patch must be '*** End Patch'".to_string()),
340 }
341
342 if lines.len() <= 2 {
343 return Ok(ParsedPatch { hunks: Vec::new() });
344 }
345
346 let mut hunks = Vec::new();
347 let mut remaining = &lines[1..lines.len() - 1];
348 let mut line_number = 2usize;
349 while !remaining.is_empty() {
350 let (hunk, consumed) = parse_one_hunk(remaining, line_number)?;
351 hunks.push(hunk);
352 remaining = &remaining[consumed..];
353 line_number += consumed;
354 }
355
356 Ok(ParsedPatch { hunks })
357}
358
359fn normalize_patch_input(input: &str) -> String {
360 let trimmed = input.trim();
361 if has_patch_boundaries(trimmed.lines()) {
362 return trimmed.to_string();
363 }
364
365 strip_heredoc_wrapper(trimmed).unwrap_or_else(|| trimmed.to_string())
366}
367
368fn has_patch_boundaries<'a>(mut lines: impl DoubleEndedIterator<Item = &'a str>) -> bool {
369 match (lines.next(), lines.next_back()) {
370 (Some(first), Some(last)) => {
371 first.trim() == BEGIN_PATCH_MARKER && last.trim() == END_PATCH_MARKER
372 }
373 (Some(only), None) => only.trim() == BEGIN_PATCH_MARKER,
374 _ => false,
375 }
376}
377
378fn strip_heredoc_wrapper(input: &str) -> Option<String> {
379 let lines: Vec<&str> = input.lines().collect();
380 if lines.len() < 4 {
381 return None;
382 }
383
384 let marker = parse_heredoc_start(lines[0].trim())?;
385 let last = lines.last()?.trim();
386 if last != marker && !last.ends_with(marker) {
387 return None;
388 }
389
390 let inner = lines[1..lines.len() - 1].join("\n");
391 has_patch_boundaries(inner.lines()).then(|| inner.trim().to_string())
392}
393
394fn resolve_patch_workdir(workdir: Option<&str>) -> Result<PathBuf, String> {
395 let here = std::env::current_dir().map_err(|err| format!("Failed to determine cwd: {err}"))?;
396 Ok(match workdir {
400 Some(path) => resolve_under(&here, Path::new(path)),
401 None => normalize_lexical(&here),
402 })
403}
404
405fn parse_heredoc_start(line: &str) -> Option<&str> {
406 let marker = if let Some(rest) = line.strip_prefix("<<") {
407 rest
408 } else {
409 line.strip_prefix("apply_patch <<")?
410 };
411
412 let marker = marker.trim();
413 if marker.len() >= 2 {
414 let bytes = marker.as_bytes();
415 if (bytes[0] == b'\'' && bytes[marker.len() - 1] == b'\'')
416 || (bytes[0] == b'"' && bytes[marker.len() - 1] == b'"')
417 {
418 return Some(&marker[1..marker.len() - 1]);
419 }
420 }
421
422 Some(marker)
423}
424
425fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), String> {
426 if lines.is_empty() {
427 return Err(format!("invalid hunk at line {line_number}: missing hunk"));
428 }
429
430 let first_line = lines[0].trim();
431 if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
432 let mut contents = String::new();
433 let mut consumed = 1usize;
434 for line in &lines[1..] {
435 if let Some(text) = line.strip_prefix('+') {
436 contents.push_str(text);
437 contents.push('\n');
438 consumed += 1;
439 } else {
440 break;
441 }
442 }
443 if contents.is_empty()
444 && lines
445 .get(1)
446 .is_some_and(|line| !line.trim_start().starts_with("***"))
447 {
448 return Err(format!(
449 "invalid hunk at line {line_number}: add file hunk for '{path}' is empty; every new file content line must start with '+'"
450 ));
451 }
452 return Ok((
453 Hunk::Add {
454 path: PathBuf::from(path),
455 contents,
456 },
457 consumed,
458 ));
459 }
460
461 if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) {
462 return Ok((
463 Hunk::Delete {
464 path: PathBuf::from(path),
465 },
466 1,
467 ));
468 }
469
470 if let Some(path) = first_line.strip_prefix(UPDATE_FILE_MARKER) {
471 let mut consumed = 1usize;
472 let mut remaining = &lines[1..];
473 let move_path = remaining
474 .first()
475 .and_then(|line| line.strip_prefix(MOVE_TO_MARKER))
476 .map(PathBuf::from);
477
478 if move_path.is_some() {
479 consumed += 1;
480 remaining = &remaining[1..];
481 }
482
483 let mut chunks = Vec::new();
484 while !remaining.is_empty() {
485 if remaining[0].trim().is_empty() {
486 consumed += 1;
487 remaining = &remaining[1..];
488 continue;
489 }
490 if remaining[0].starts_with("***") {
491 break;
492 }
493 let (chunk, chunk_lines) =
494 parse_update_file_chunk(remaining, line_number + consumed, chunks.is_empty())?;
495 chunks.push(chunk);
496 consumed += chunk_lines;
497 remaining = &remaining[chunk_lines..];
498 }
499
500 if chunks.is_empty() {
501 return Err(format!(
502 "invalid hunk at line {line_number}: update file hunk for path '{path}' is empty"
503 ));
504 }
505
506 return Ok((
507 Hunk::Update {
508 path: PathBuf::from(path),
509 move_path,
510 chunks,
511 },
512 consumed,
513 ));
514 }
515
516 Err(format!(
517 "invalid hunk at line {line_number}: '{first_line}' is not a valid hunk header"
518 ))
519}
520
521fn parse_update_file_chunk(
522 lines: &[&str],
523 line_number: usize,
524 allow_missing_context: bool,
525) -> Result<(UpdateFileChunk, usize), String> {
526 if lines.is_empty() {
527 return Err(format!(
528 "invalid hunk at line {line_number}: update hunk does not contain any lines"
529 ));
530 }
531
532 let (change_context, start_index) = if lines[0].trim_end() == EMPTY_CHANGE_CONTEXT_MARKER {
533 (None, 1)
534 } else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
535 (Some(context.to_string()), 1)
536 } else if allow_missing_context {
537 (None, 0)
538 } else {
539 return Err(format!(
540 "invalid hunk at line {line_number}: expected update hunk to start with a @@ context marker"
541 ));
542 };
543
544 if start_index >= lines.len() {
545 return Err(format!(
546 "invalid hunk at line {}: update hunk does not contain any lines",
547 line_number + 1
548 ));
549 }
550
551 let mut chunk = UpdateFileChunk {
552 change_context,
553 old_lines: Vec::new(),
554 new_lines: Vec::new(),
555 is_end_of_file: false,
556 };
557 let mut parsed_lines = 0usize;
558 for line in &lines[start_index..] {
559 match *line {
560 EOF_MARKER => {
561 if parsed_lines == 0 {
562 return Err(format!(
563 "invalid hunk at line {}: update hunk does not contain any lines",
564 line_number + 1
565 ));
566 }
567 chunk.is_end_of_file = true;
568 parsed_lines += 1;
569 break;
570 }
571 line_contents => match line_contents.chars().next() {
572 None => {
573 chunk.old_lines.push(String::new());
574 chunk.new_lines.push(String::new());
575 parsed_lines += 1;
576 }
577 Some(' ') => {
578 chunk.old_lines.push(line_contents[1..].to_string());
579 chunk.new_lines.push(line_contents[1..].to_string());
580 parsed_lines += 1;
581 }
582 Some('+') => {
583 chunk.new_lines.push(line_contents[1..].to_string());
584 parsed_lines += 1;
585 }
586 Some('-') => {
587 chunk.old_lines.push(line_contents[1..].to_string());
588 parsed_lines += 1;
589 }
590 _ => {
591 if parsed_lines == 0 {
592 return Err(format!(
593 "invalid hunk at line {}: unexpected line in update hunk",
594 line_number + 1
595 ));
596 }
597 break;
598 }
599 },
600 }
601 }
602
603 Ok((chunk, parsed_lines + start_index))
604}
605
606enum PreparedChange {
607 Add {
608 display_path: String,
609 diff: String,
610 },
611 Delete {
612 display_path: String,
613 diff: String,
614 },
615 Update {
616 display_path: String,
617 diff: String,
618 },
619 Move {
620 from_display_path: String,
621 display_path: String,
622 diff: String,
623 },
624}
625
626impl PreparedChange {
627 fn diff(&self) -> &str {
628 match self {
629 Self::Add { diff, .. }
630 | Self::Delete { diff, .. }
631 | Self::Update { diff, .. }
632 | Self::Move { diff, .. } => diff,
633 }
634 }
635
636 fn line_delta(&self) -> (usize, usize) {
637 count_diff_delta(self.diff())
638 }
639
640 fn as_json(&self) -> serde_json::Value {
641 let (added, removed) = self.line_delta();
642 match self {
643 Self::Add {
644 display_path, diff, ..
645 } => json!({
646 "path": display_path,
647 "status": "added",
648 "added": added,
649 "removed": removed,
650 "diff": diff,
651 }),
652 Self::Delete {
653 display_path, diff, ..
654 } => json!({
655 "path": display_path,
656 "status": "deleted",
657 "added": added,
658 "removed": removed,
659 "diff": diff,
660 }),
661 Self::Update {
662 display_path, diff, ..
663 } => json!({
664 "path": display_path,
665 "status": "modified",
666 "added": added,
667 "removed": removed,
668 "diff": diff,
669 }),
670 Self::Move {
671 from_display_path,
672 display_path,
673 diff,
674 ..
675 } => json!({
676 "path": display_path,
677 "from_path": from_display_path,
678 "status": "moved",
679 "added": added,
680 "removed": removed,
681 "diff": diff,
682 }),
683 }
684 }
685}
686
687fn count_diff_delta(diff: &str) -> (usize, usize) {
688 let mut added = 0usize;
689 let mut removed = 0usize;
690 for line in diff.lines() {
691 if line.starts_with("+++ ") || line.starts_with("--- ") || line.starts_with("@@") {
692 continue;
693 }
694 if line.starts_with('+') {
695 added += 1;
696 } else if line.starts_with('-') {
697 removed += 1;
698 }
699 }
700 (added, removed)
701}
702
703fn apply_hunk(hunk: &Hunk, cwd: &Path) -> Result<PreparedChange, String> {
704 match hunk {
705 Hunk::Add { path, contents } => {
706 let resolved = resolve_under(cwd, path);
707 let original_contents = std::fs::read_to_string(&resolved).unwrap_or_default();
708 if let Some(parent) = resolved.parent()
709 && !parent.as_os_str().is_empty()
710 {
711 std::fs::create_dir_all(parent).map_err(|err| {
712 format!(
713 "Failed to create directories for {}: {err}",
714 resolved.display()
715 )
716 })?;
717 }
718 std::fs::write(&resolved, contents)
719 .map_err(|err| format!("Failed to write {}: {err}", resolved.display()))?;
720 let display_path = display_relative(cwd, &resolved);
721 let diff = compact_diff(&original_contents, contents, &display_path, 120);
722 Ok(PreparedChange::Add { display_path, diff })
723 }
724 Hunk::Delete { path } => {
725 let resolved = resolve_under(cwd, path);
726 let original = std::fs::read_to_string(&resolved).unwrap_or_default();
727 std::fs::remove_file(&resolved)
728 .map_err(|err| format!("Failed to delete {}: {err}", resolved.display()))?;
729 let display_path = display_relative(cwd, &resolved);
730 let diff = compact_diff(&original, "", &display_path, 120);
731 Ok(PreparedChange::Delete { display_path, diff })
732 }
733 Hunk::Update {
734 path,
735 move_path,
736 chunks,
737 } => {
738 let resolved = resolve_under(cwd, path);
739 let applied = derive_new_contents_from_chunks(&resolved, chunks)?;
740 let target = move_path
741 .as_ref()
742 .map(|path| -> Result<PathBuf, String> { Ok(resolve_under(cwd, path)) })
743 .transpose()?
744 .unwrap_or_else(|| resolved.clone());
745 if let Some(parent) = target.parent()
746 && !parent.as_os_str().is_empty()
747 {
748 std::fs::create_dir_all(parent).map_err(|err| {
749 format!(
750 "Failed to create directories for {}: {err}",
751 target.display()
752 )
753 })?;
754 }
755 std::fs::write(&target, &applied.new_contents)
756 .map_err(|err| format!("Failed to write {}: {err}", target.display()))?;
757 if move_path.is_some() {
758 std::fs::remove_file(&resolved).map_err(|err| {
759 format!("Failed to remove original {}: {err}", resolved.display())
760 })?;
761 }
762 let display_path = display_relative(cwd, &target);
763 let diff = compact_diff(
764 &applied.original_contents,
765 &applied.new_contents,
766 &display_path,
767 120,
768 );
769 if move_path.is_some() {
770 Ok(PreparedChange::Move {
771 from_display_path: display_relative(cwd, &resolved),
772 display_path,
773 diff,
774 })
775 } else {
776 Ok(PreparedChange::Update { display_path, diff })
777 }
778 }
779 }
780}
781
782struct AppliedPatch {
783 original_contents: String,
784 new_contents: String,
785}
786
787fn derive_new_contents_from_chunks(
788 path: &Path,
789 chunks: &[UpdateFileChunk],
790) -> Result<AppliedPatch, String> {
791 let original_contents = std::fs::read_to_string(path)
792 .map_err(|err| format!("Failed to read file to update {}: {err}", path.display()))?;
793
794 let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect();
795 if original_lines.last().is_some_and(String::is_empty) {
796 original_lines.pop();
797 }
798
799 let replacements = compute_replacements(&original_lines, path, chunks)?;
800 let mut new_lines = apply_replacements(original_lines, &replacements);
801 if !new_lines.last().is_some_and(String::is_empty) {
802 new_lines.push(String::new());
803 }
804 let new_contents = new_lines.join("\n");
805
806 Ok(AppliedPatch {
807 original_contents,
808 new_contents,
809 })
810}
811
812fn compute_replacements(
813 original_lines: &[String],
814 path: &Path,
815 chunks: &[UpdateFileChunk],
816) -> Result<Vec<(usize, usize, Vec<String>)>, String> {
817 let mut replacements = Vec::new();
818 let mut line_index = 0usize;
819
820 for chunk in chunks {
821 if let Some(ctx_line) = &chunk.change_context {
822 if let Some(index) = seek_sequence(
823 original_lines,
824 std::slice::from_ref(ctx_line),
825 line_index,
826 false,
827 ) {
828 line_index = index;
832 } else {
833 return Err(format!(
834 "Failed to find context '{}' in {}",
835 ctx_line,
836 path.display()
837 ));
838 }
839 }
840
841 if chunk.old_lines.is_empty() {
842 let insertion_idx = if original_lines.last().is_some_and(String::is_empty) {
843 original_lines.len().saturating_sub(1)
844 } else {
845 original_lines.len()
846 };
847 replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
848 continue;
849 }
850
851 let mut pattern: &[String] = &chunk.old_lines;
852 let mut new_slice: &[String] = &chunk.new_lines;
853 let mut found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
854
855 if found.is_none() && pattern.last().is_some_and(String::is_empty) {
856 pattern = &pattern[..pattern.len() - 1];
857 if new_slice.last().is_some_and(String::is_empty) {
858 new_slice = &new_slice[..new_slice.len() - 1];
859 }
860 found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
861 }
862
863 if let Some(start_idx) = found {
864 replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
865 line_index = start_idx + pattern.len();
866 } else {
867 return Err(format!(
868 "Failed to find expected lines in {}:\n{}",
869 path.display(),
870 chunk.old_lines.join("\n")
871 ));
872 }
873 }
874
875 replacements.sort_by_key(|(start_idx, _, _)| *start_idx);
876 Ok(replacements)
877}
878
879fn apply_replacements(
880 mut lines: Vec<String>,
881 replacements: &[(usize, usize, Vec<String>)],
882) -> Vec<String> {
883 for (start_idx, old_len, new_segment) in replacements.iter().rev() {
884 for _ in 0..*old_len {
885 if *start_idx < lines.len() {
886 lines.remove(*start_idx);
887 }
888 }
889 for (offset, line) in new_segment.iter().enumerate() {
890 lines.insert(*start_idx + offset, line.clone());
891 }
892 }
893 lines
894}
895
896fn seek_sequence(lines: &[String], pattern: &[String], start: usize, eof: bool) -> Option<usize> {
897 if pattern.is_empty() {
898 return Some(start);
899 }
900 if pattern.len() > lines.len() {
901 return None;
902 }
903
904 let search_start = if eof && lines.len() >= pattern.len() {
905 lines.len() - pattern.len()
906 } else {
907 start
908 };
909
910 for i in search_start..=lines.len().saturating_sub(pattern.len()) {
911 if lines[i..i + pattern.len()] == *pattern {
912 return Some(i);
913 }
914 }
915 for i in search_start..=lines.len().saturating_sub(pattern.len()) {
916 let mut ok = true;
917 for (p_idx, pat) in pattern.iter().enumerate() {
918 if lines[i + p_idx].trim_end() != pat.trim_end() {
919 ok = false;
920 break;
921 }
922 }
923 if ok {
924 return Some(i);
925 }
926 }
927 for i in search_start..=lines.len().saturating_sub(pattern.len()) {
928 let mut ok = true;
929 for (p_idx, pat) in pattern.iter().enumerate() {
930 if lines[i + p_idx].trim() != pat.trim() {
931 ok = false;
932 break;
933 }
934 }
935 if ok {
936 return Some(i);
937 }
938 }
939 fn normalize_for_match(text: &str) -> String {
940 text.trim()
941 .chars()
942 .map(|ch| match ch {
943 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
944 | '\u{2212}' => '-',
945 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
946 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
947 '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
948 | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
949 | '\u{3000}' => ' ',
950 other => other,
951 })
952 .collect()
953 }
954 for i in search_start..=lines.len().saturating_sub(pattern.len()) {
955 let mut ok = true;
956 for (p_idx, pat) in pattern.iter().enumerate() {
957 if normalize_for_match(&lines[i + p_idx]) != normalize_for_match(pat) {
958 ok = false;
959 break;
960 }
961 }
962 if ok {
963 return Some(i);
964 }
965 }
966 None
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 use tempfile::TempDir;
973
974 fn run_patch(dir: &TempDir, input: impl AsRef<str>) -> ToolResult {
975 apply_patch(input.as_ref(), Some(dir.path().to_str().unwrap()))
976 }
977
978 #[test]
979 fn apply_patch_contract_documents_result_shape() {
980 let definition = apply_patch_tool_definition();
981
982 assert_eq!(
983 definition.contract.output_schema["properties"]["files"]["type"],
984 serde_json::json!("array")
985 );
986 let rendered = definition.compact_contract().render_signature();
987 assert!(rendered.contains("files"), "{rendered}");
988 assert!(rendered.contains("summary"), "{rendered}");
989 }
990
991 #[test]
992 fn direct_apply_patch_creates_file() {
993 let dir = TempDir::new().unwrap();
994 let result = run_patch(
995 &dir,
996 "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch",
997 );
998 assert!(result.is_success());
999 assert_eq!(
1000 std::fs::read_to_string(dir.path().join("hello.txt")).unwrap(),
1001 "hello\n"
1002 );
1003 }
1004
1005 #[test]
1006 fn update_file_patch_modifies_file() {
1007 let dir = TempDir::new().unwrap();
1008 std::fs::write(dir.path().join("main.rs"), "fn main() {\n old();\n}\n").unwrap();
1009 let result = run_patch(
1010 &dir,
1011 "*** Begin Patch\n*** Update File: main.rs\n@@ fn main() {\n- old();\n+ new();\n*** End Patch",
1012 );
1013 assert!(result.is_success());
1014 assert_eq!(
1015 std::fs::read_to_string(dir.path().join("main.rs")).unwrap(),
1016 "fn main() {\n new();\n}\n"
1017 );
1018 }
1019
1020 #[test]
1021 fn delete_file_patch_removes_file() {
1022 let dir = TempDir::new().unwrap();
1023 std::fs::write(dir.path().join("old.txt"), "gone\n").unwrap();
1024 let result = run_patch(
1025 &dir,
1026 "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch",
1027 );
1028 assert!(result.is_success());
1029 assert!(!dir.path().join("old.txt").exists());
1030 }
1031
1032 #[test]
1033 fn move_patch_renames_file() {
1034 let dir = TempDir::new().unwrap();
1035 std::fs::write(dir.path().join("old.txt"), "line\n").unwrap();
1036 let result = run_patch(
1037 &dir,
1038 "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n line\n*** End Patch",
1039 );
1040 assert!(result.is_success());
1041 assert!(!dir.path().join("old.txt").exists());
1042 assert_eq!(
1043 std::fs::read_to_string(dir.path().join("new.txt")).unwrap(),
1044 "line\n"
1045 );
1046 }
1047
1048 #[test]
1049 fn patch_result_uses_workdir_relative_display_paths() {
1050 let dir = TempDir::new().unwrap();
1051 std::fs::write(dir.path().join("base.txt"), "old\n").unwrap();
1052 let result = run_patch(
1053 &dir,
1054 "*** Begin Patch\n*** Update File: base.txt\n@@\n-old\n+new\n*** End Patch",
1055 );
1056
1057 assert!(result.is_success());
1058 let result_value = result.value_for_projection();
1059 let diff = result_value["diff"].as_str().expect("diff");
1060 assert!(diff.contains("--- a/base.txt"));
1061 assert!(diff.contains("+++ b/base.txt"));
1062 assert!(!diff.contains("/tmp/"));
1063 assert_eq!(result_value["files"][0]["path"], "base.txt");
1064 assert_eq!(result_value["files"][0]["added"], 1);
1065 assert_eq!(result_value["files"][0]["removed"], 1);
1066 assert_eq!(result_value["added"], 1);
1067 assert_eq!(result_value["removed"], 1);
1068 }
1069
1070 #[test]
1071 fn add_file_patch_requires_plus_prefixed_lines() {
1072 let dir = TempDir::new().unwrap();
1073 let result = run_patch(
1074 &dir,
1075 "*** Begin Patch\n*** Add File: hello.txt\nhello\n*** End Patch",
1076 );
1077 assert!(!result.is_success());
1078 assert!(
1079 result
1080 .value_for_projection()
1081 .to_string()
1082 .contains("must start with '+'")
1083 );
1084 }
1085
1086 #[test]
1087 fn add_file_patch_can_create_truly_empty_file() {
1088 let dir = TempDir::new().unwrap();
1089 let result = run_patch(
1090 &dir,
1091 "*** Begin Patch\n*** Add File: empty.txt\n*** End Patch",
1092 );
1093 assert!(result.is_success(), "{}", result.value_for_projection());
1094 assert_eq!(
1095 std::fs::read_to_string(dir.path().join("empty.txt")).unwrap(),
1096 ""
1097 );
1098 assert_eq!(
1099 result.value_for_projection()["files"][0]["path"],
1100 "empty.txt"
1101 );
1102 }
1103
1104 #[test]
1105 fn update_file_patch_allows_first_chunk_without_explicit_marker() {
1106 let dir = TempDir::new().unwrap();
1107 std::fs::write(dir.path().join("module.py"), "import alpha\n").unwrap();
1108
1109 let result = run_patch(
1110 &dir,
1111 "*** Begin Patch\n*** Update File: module.py\n import alpha\n+import beta\n*** End Patch",
1112 );
1113
1114 assert!(result.is_success(), "{}", result.value_for_projection());
1115 assert_eq!(
1116 std::fs::read_to_string(dir.path().join("module.py")).unwrap(),
1117 "import alpha\nimport beta\n"
1118 );
1119 }
1120
1121 #[test]
1122 fn update_file_patch_accepts_whitespace_padded_headers_and_markers() {
1123 let dir = TempDir::new().unwrap();
1124 std::fs::write(dir.path().join("pad.txt"), "one\n").unwrap();
1125
1126 let result = run_patch(
1127 &dir,
1128 " *** Begin Patch\n *** Update File: pad.txt\n@@\n-one\n+two\n *** End Patch ",
1129 );
1130
1131 assert!(result.is_success(), "{}", result.value_for_projection());
1132 assert_eq!(
1133 std::fs::read_to_string(dir.path().join("pad.txt")).unwrap(),
1134 "two\n"
1135 );
1136 }
1137
1138 #[test]
1139 fn update_file_patch_supports_pure_addition_chunk() {
1140 let dir = TempDir::new().unwrap();
1141 std::fs::write(dir.path().join("notes.txt"), "alpha\nbeta\n").unwrap();
1142
1143 let result = run_patch(
1144 &dir,
1145 "*** Begin Patch\n*** Update File: notes.txt\n@@\n+gamma\n+delta\n*** End Patch",
1146 );
1147
1148 assert!(result.is_success(), "{}", result.value_for_projection());
1149 assert_eq!(
1150 std::fs::read_to_string(dir.path().join("notes.txt")).unwrap(),
1151 "alpha\nbeta\ngamma\ndelta\n"
1152 );
1153 }
1154
1155 #[test]
1156 fn update_file_patch_supports_deletion_only_chunk() {
1157 let dir = TempDir::new().unwrap();
1158 std::fs::write(dir.path().join("lines.txt"), "line1\nline2\nline3\nline4\n").unwrap();
1159
1160 let result = run_patch(
1161 &dir,
1162 "*** Begin Patch\n*** Update File: lines.txt\n@@\n line1\n-line2\n line3\n*** End Patch",
1163 );
1164
1165 assert!(result.is_success(), "{}", result.value_for_projection());
1166 assert_eq!(
1167 std::fs::read_to_string(dir.path().join("lines.txt")).unwrap(),
1168 "line1\nline3\nline4\n"
1169 );
1170 }
1171
1172 #[test]
1173 fn update_file_patch_supports_end_of_file_marker() {
1174 let dir = TempDir::new().unwrap();
1175 std::fs::write(dir.path().join("tail.txt"), "first\nsecond\n").unwrap();
1176
1177 let result = run_patch(
1178 &dir,
1179 "*** Begin Patch\n*** Update File: tail.txt\n@@\n first\n-second\n+second updated\n*** End of File\n*** End Patch",
1180 );
1181
1182 assert!(result.is_success(), "{}", result.value_for_projection());
1183 assert_eq!(
1184 std::fs::read_to_string(dir.path().join("tail.txt")).unwrap(),
1185 "first\nsecond updated\n"
1186 );
1187 }
1188
1189 #[test]
1190 fn update_file_patch_appends_trailing_newline() {
1191 let dir = TempDir::new().unwrap();
1192 std::fs::write(dir.path().join("plain.txt"), "just one line").unwrap();
1193
1194 let result = run_patch(
1195 &dir,
1196 "*** Begin Patch\n*** Update File: plain.txt\n@@\n-just one line\n+first row\n+second row\n*** End Patch",
1197 );
1198
1199 assert!(result.is_success(), "{}", result.value_for_projection());
1200 assert_eq!(
1201 std::fs::read_to_string(dir.path().join("plain.txt")).unwrap(),
1202 "first row\nsecond row\n"
1203 );
1204 }
1205
1206 #[test]
1207 fn empty_patch_returns_no_files_modified() {
1208 let dir = TempDir::new().unwrap();
1209 let result = run_patch(&dir, "*** Begin Patch\n*** End Patch");
1210
1211 assert!(!result.is_success());
1212 assert!(
1213 result
1214 .value_for_projection()
1215 .to_string()
1216 .contains("No files were modified.")
1217 );
1218 }
1219
1220 #[test]
1221 fn invalid_hunk_header_is_rejected() {
1222 let dir = TempDir::new().unwrap();
1223 let result = run_patch(
1224 &dir,
1225 "*** Begin Patch\n*** Rename File: nope.txt\n*** End Patch",
1226 );
1227
1228 assert!(!result.is_success());
1229 assert!(
1230 result
1231 .value_for_projection()
1232 .to_string()
1233 .contains("is not a valid hunk header")
1234 );
1235 }
1236
1237 #[test]
1238 fn direct_heredoc_wrapper_is_accepted() {
1239 let dir = TempDir::new().unwrap();
1240 let result = run_patch(
1241 &dir,
1242 "<<EOF\n*** Begin Patch\n*** Add File: tiny.txt\n+ok\n*** End Patch\nEOF",
1243 );
1244
1245 assert!(result.is_success(), "{}", result.value_for_projection());
1246 assert_eq!(
1247 std::fs::read_to_string(dir.path().join("tiny.txt")).unwrap(),
1248 "ok\n"
1249 );
1250 }
1251
1252 #[test]
1253 fn apply_patch_accepts_absolute_add_paths() {
1254 let dir = TempDir::new().unwrap();
1255 let abs = dir.path().join("hello.txt");
1256 let input = format!(
1257 "*** Begin Patch\n*** Add File: {}\n+hello\n*** End Patch",
1258 abs.display()
1259 );
1260 let result = run_patch(&dir, input);
1261 assert!(result.is_success(), "{}", result.value_for_projection());
1262 assert_eq!(std::fs::read_to_string(&abs).unwrap(), "hello\n");
1263 assert_eq!(
1264 result.value_for_projection()["files"][0]["path"],
1265 "hello.txt"
1266 );
1267 }
1268
1269 #[test]
1270 fn apply_patch_accepts_absolute_update_paths() {
1271 let dir = TempDir::new().unwrap();
1272 let abs = dir.path().join("main.rs");
1273 std::fs::write(&abs, "fn main() {\n old();\n}\n").unwrap();
1274 let input = format!(
1275 "*** Begin Patch\n*** Update File: {}\n@@ fn main() {{\n- old();\n+ new();\n*** End Patch",
1276 abs.display()
1277 );
1278 let result = run_patch(&dir, input);
1279
1280 assert!(result.is_success(), "{}", result.value_for_projection());
1281 assert_eq!(
1282 std::fs::read_to_string(&abs).unwrap(),
1283 "fn main() {\n new();\n}\n"
1284 );
1285 assert_eq!(result.value_for_projection()["files"][0]["path"], "main.rs");
1286 }
1287
1288 #[test]
1289 fn apply_patch_accepts_absolute_delete_paths() {
1290 let dir = TempDir::new().unwrap();
1291 let abs = dir.path().join("old.txt");
1292 std::fs::write(&abs, "gone\n").unwrap();
1293 let input = format!(
1294 "*** Begin Patch\n*** Delete File: {}\n*** End Patch",
1295 abs.display()
1296 );
1297 let result = run_patch(&dir, input);
1298
1299 assert!(result.is_success(), "{}", result.value_for_projection());
1300 assert!(!abs.exists());
1301 assert_eq!(result.value_for_projection()["files"][0]["path"], "old.txt");
1302 }
1303
1304 #[test]
1305 fn apply_patch_accepts_absolute_move_paths() {
1306 let dir = TempDir::new().unwrap();
1307 let source = dir.path().join("old.txt");
1308 let dest = dir.path().join("nested").join("new.txt");
1309 std::fs::write(&source, "line\n").unwrap();
1310 let input = format!(
1311 "*** Begin Patch\n*** Update File: {}\n*** Move to: {}\n@@\n line\n*** End Patch",
1312 source.display(),
1313 dest.display()
1314 );
1315 let result = run_patch(&dir, input);
1316
1317 assert!(result.is_success(), "{}", result.value_for_projection());
1318 assert!(!source.exists());
1319 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "line\n");
1320 assert_eq!(
1321 result.value_for_projection()["files"][0]["path"],
1322 "nested/new.txt"
1323 );
1324 }
1325
1326 #[test]
1327 fn apply_patch_accepts_lenient_heredoc_wrapper() {
1328 let dir = TempDir::new().unwrap();
1329 let result = run_patch(
1330 &dir,
1331 "apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\nPATCH",
1332 );
1333 assert!(result.is_success());
1334 assert_eq!(
1335 std::fs::read_to_string(dir.path().join("hello.txt")).unwrap(),
1336 "hello\n"
1337 );
1338 }
1339
1340 #[test]
1341 fn apply_patch_treats_unified_diff_header_as_plain_context() {
1342 let dir = TempDir::new().unwrap();
1343 std::fs::write(dir.path().join("main.rs"), "fn main() {\n old();\n}\n").unwrap();
1344 let result = run_patch(
1345 &dir,
1346 "*** Begin Patch\n*** Update File: main.rs\n@@ -1,3 +1,3 @@\n- old();\n+ new();\n*** End Patch",
1347 );
1348 assert!(!result.is_success());
1349 assert!(
1350 result
1351 .value_for_projection()
1352 .to_string()
1353 .contains("Failed to find context '-1,3 +1,3 @@'")
1354 );
1355 }
1356
1357 #[test]
1358 fn update_file_patch_allows_context_label_matching_first_old_line() {
1359 let dir = TempDir::new().unwrap();
1360 std::fs::write(
1361 dir.path().join("main.rs"),
1362 "fn main() {\n println!(\"old\");\n}\n",
1363 )
1364 .unwrap();
1365 let result = run_patch(
1366 &dir,
1367 "*** Begin Patch\n*** Update File: main.rs\n@@ fn main() {\n fn main() {\n- println!(\"old\");\n+ println!(\"new\");\n }\n*** End Patch",
1368 );
1369
1370 assert!(result.is_success(), "{}", result.value_for_projection());
1371 assert_eq!(
1372 std::fs::read_to_string(dir.path().join("main.rs")).unwrap(),
1373 "fn main() {\n println!(\"new\");\n}\n"
1374 );
1375 }
1376
1377 #[test]
1378 fn update_file_patch_allows_whitespace_padded_bare_hunk_header() {
1379 let dir = TempDir::new().unwrap();
1380 std::fs::write(
1381 dir.path().join("hello.txt"),
1382 "Hello from apply_patch!\nLine two.\n",
1383 )
1384 .unwrap();
1385 let result = run_patch(
1386 &dir,
1387 "*** 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",
1388 );
1389
1390 assert!(result.is_success(), "{}", result.value_for_projection());
1391 assert_eq!(
1392 std::fs::read_to_string(dir.path().join("hello.txt")).unwrap(),
1393 "Hello from apply_patch!\nLine two updated by patch.\nLine three added.\n"
1394 );
1395 }
1396
1397 #[test]
1398 fn update_file_patch_matches_common_unicode_punctuation() {
1399 let dir = TempDir::new().unwrap();
1400 std::fs::write(
1401 dir.path().join("unicode.txt"),
1402 "note - uses an en dash \u{2013} and a nonbreaking hyphen in top\u{2011}level text\n",
1403 )
1404 .unwrap();
1405
1406 let result = run_patch(
1407 &dir,
1408 "*** 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",
1409 );
1410
1411 assert!(result.is_success(), "{}", result.value_for_projection());
1412 assert_eq!(
1413 std::fs::read_to_string(dir.path().join("unicode.txt")).unwrap(),
1414 "normalized replacement\n"
1415 );
1416 }
1417
1418 #[test]
1419 fn add_file_patch_overwrites_existing_target() {
1420 let dir = TempDir::new().unwrap();
1421 std::fs::write(dir.path().join("dupe.txt"), "original\n").unwrap();
1422
1423 let result = run_patch(
1424 &dir,
1425 "*** Begin Patch\n*** Add File: dupe.txt\n+replacement\n*** End Patch",
1426 );
1427
1428 assert!(result.is_success(), "{}", result.value_for_projection());
1429 assert_eq!(
1430 std::fs::read_to_string(dir.path().join("dupe.txt")).unwrap(),
1431 "replacement\n"
1432 );
1433 }
1434
1435 #[test]
1436 fn delete_file_patch_rejects_directory_target() {
1437 let dir = TempDir::new().unwrap();
1438 std::fs::create_dir_all(dir.path().join("folder")).unwrap();
1439
1440 let result = run_patch(
1441 &dir,
1442 "*** Begin Patch\n*** Delete File: folder\n*** End Patch",
1443 );
1444
1445 assert!(!result.is_success());
1446 assert!(dir.path().join("folder").is_dir());
1447 assert!(
1448 result
1449 .value_for_projection()
1450 .to_string()
1451 .contains("Failed to delete")
1452 );
1453 }
1454
1455 #[test]
1456 fn delete_missing_file_reports_delete_failure() {
1457 let dir = TempDir::new().unwrap();
1458
1459 let result = run_patch(
1460 &dir,
1461 "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch",
1462 );
1463
1464 assert!(!result.is_success());
1465 assert!(
1466 result
1467 .value_for_projection()
1468 .to_string()
1469 .contains("Failed to delete")
1470 );
1471 }
1472
1473 #[test]
1474 fn move_patch_overwrites_existing_destination() {
1475 let dir = TempDir::new().unwrap();
1476 std::fs::create_dir_all(dir.path().join("renamed").join("dir")).unwrap();
1477 std::fs::write(dir.path().join("old.txt"), "from\n").unwrap();
1478 std::fs::write(
1479 dir.path().join("renamed").join("dir").join("name.txt"),
1480 "stale\n",
1481 )
1482 .unwrap();
1483
1484 let result = run_patch(
1485 &dir,
1486 "*** Begin Patch\n*** Update File: old.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch",
1487 );
1488
1489 assert!(result.is_success(), "{}", result.value_for_projection());
1490 assert!(!dir.path().join("old.txt").exists());
1491 assert_eq!(
1492 std::fs::read_to_string(dir.path().join("renamed").join("dir").join("name.txt"))
1493 .unwrap(),
1494 "new\n"
1495 );
1496 }
1497
1498 #[test]
1499 fn later_hunk_sees_earlier_hunk_changes() {
1500 let dir = TempDir::new().unwrap();
1501 std::fs::write(dir.path().join("chain.txt"), "old\n").unwrap();
1502
1503 let result = run_patch(
1504 &dir,
1505 "*** 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",
1506 );
1507
1508 assert!(result.is_success(), "{}", result.value_for_projection());
1509 assert_eq!(
1510 std::fs::read_to_string(dir.path().join("chain.txt")).unwrap(),
1511 "new\n"
1512 );
1513 }
1514
1515 #[test]
1516 fn failed_later_hunk_keeps_earlier_successful_changes() {
1517 let dir = TempDir::new().unwrap();
1518
1519 let result = run_patch(
1520 &dir,
1521 "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch",
1522 );
1523
1524 assert!(!result.is_success());
1525 assert_eq!(
1526 std::fs::read_to_string(dir.path().join("created.txt")).unwrap(),
1527 "hello\n"
1528 );
1529 assert!(
1530 result
1531 .value_for_projection()
1532 .to_string()
1533 .contains("Failed to read file to update")
1534 );
1535 }
1536}