Skip to main content

lash_tool_apply_patch/
lib.rs

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
98/// Build the cached `apply_patch` tool provider.
99pub 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    // `resolve_under` already passes absolute paths through (normalized) and
397    // joins relative ones onto `here`, so it covers both branches; an absent
398    // `workdir` is just the cwd itself.
399    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                // The @@ label is an anchor, not a consumed context line.
829                // Keep the search window at the matched line so updates whose
830                // first old-line equals the label still match correctly.
831                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}