返回 CodeWhale
apply_patch.rs
根目录 / crates / tui / src / tools / apply_patch.rs
1 //! Patch tools: `apply_patch` for unified diff patching
2 //!
3 //! This tool provides precise file modifications using unified diff format,
4 //! supporting multi-hunk patches and fuzzy matching.
5
6 use std::collections::HashSet;
7 use std::fs;
8 use std::path::{Path, PathBuf};
9
10 use async_trait::async_trait;
11 use serde::{Deserialize, Serialize};
12 use serde_json::{Value, json};
13 use thiserror::Error;
14
15 use super::diff_format::make_unified_diff;
16 use super::file::{
17 EXPECTED_HASH_DESCRIPTION, PATCH_PARAMS, PATH_ALIASES, apply_param_aliases, content_hash,
18 };
19 use super::rust_format::normalize_edit;
20 use super::spec::{
21 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
22 lsp_diagnostics_for_paths, optional_bool, optional_str, optional_u64,
23 };
24 use super::syntax_check::guard_edit;
25
26 /// Maximum lines of context for fuzzy matching (increased for better tolerance)
27 const MAX_FUZZ: usize = 50;
28 /// Default fuzz when the caller does not specify one. Matches the tool schema's
29 /// documented default. Previously the default was `MAX_FUZZ` (50), so a hunk
30 /// with no `fuzz` argument could silently apply up to 50 lines from its stated
31 /// position — landing in the wrong region of a file with repeated blocks.
32 const DEFAULT_FUZZ: usize = 3;
33
34 /// Minimum number of expected lines (context + removed) required before a hunk
35 /// may be relocated to a unique whole-file context match when its stated line
36 /// numbers are stale (#5003). Short anchors — a lone `}` or a 1-2 line snippet
37 /// — appear in too many places to relocate safely.
38 const MIN_ANCHOR_LINES: usize = 4;
39
40 /// Reassemble hunk-processed logical lines back into file content, preserving
41 /// the base file's line-ending style (CRLF vs LF) and its trailing-newline
42 /// state. Processing round-trips through `str::lines()`, which strips both the
43 /// trailing `\n` and any `\r`; naively `join("\n")`-ing would silently delete
44 /// the file's final newline and flip a CRLF file to LF on every patch.
45 fn reassemble_preserving_newlines(lines: &[String], base_content: &str) -> String {
46 if lines.is_empty() {
47 return String::new();
48 }
49 let terminator = if base_content.contains("\r\n") {
50 "\r\n"
51 } else {
52 "\n"
53 };
54 // A newly created file (empty base) gets a conventional trailing newline;
55 // an existing file preserves whether it had one.
56 let trailing = base_content.is_empty() || base_content.ends_with('\n');
57 let mut out = lines.join(terminator);
58 if trailing {
59 out.push_str(terminator);
60 }
61 out
62 }
63 /// Limit how much context we print in error messages.
64 const HUNK_PREVIEW_LINES: usize = 4;
65 const SNIPPET_RADIUS: usize = 2;
66 const FILE_LIST_LIMIT: usize = 6;
67
68 // === Types ===
69
70 /// Result of applying a patch
71 #[derive(Debug, Clone, Serialize, Deserialize)]
72 pub struct PatchResult {
73 pub success: bool,
74 pub files_applied: usize,
75 pub files_total: usize,
76 pub hunks_applied: usize,
77 pub hunks_total: usize,
78 pub fuzz_used: usize,
79 #[serde(default)]
80 pub hunks_with_fuzz: usize,
81 #[serde(default, skip_serializing_if = "is_zero")]
82 pub hunks_relocated: usize,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub touched_files: Vec<String>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub file_summaries: Vec<FileSummary>,
87 pub message: String,
88 }
89
90 /// Per-file summary for patch application output.
91 #[derive(Debug, Clone, Serialize, Deserialize)]
92 pub struct FileSummary {
93 pub path: String,
94 pub hunks: usize,
95 pub hunks_applied: usize,
96 pub fuzz_used: usize,
97 pub hunks_with_fuzz: usize,
98 #[serde(default, skip_serializing_if = "is_zero")]
99 pub hunks_relocated: usize,
100 pub created: bool,
101 pub deleted: bool,
102 }
103
104 /// No-mutation summary of what an `apply_patch` input intends to touch.
105 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106 pub struct ApplyPatchPreflight {
107 pub touched_files: Vec<String>,
108 pub files_total: usize,
109 pub hunks_total: usize,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub creates: Vec<String>,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub deletes: Vec<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub path_override: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub header_path_mismatch: Option<String>,
118 }
119
120 /// A single hunk in a unified diff
121 #[derive(Debug, Clone)]
122 pub struct Hunk {
123 pub old_start: usize,
124 pub old_count: usize,
125 pub new_start: usize,
126 #[cfg_attr(not(test), expect(dead_code))]
127 pub new_count: usize,
128 pub lines: Vec<HunkLine>,
129 }
130
131 /// A line in a hunk
132 #[derive(Debug, Clone)]
133 pub enum HunkLine {
134 Context(String),
135 Add(String),
136 Remove(String),
137 }
138
139 /// Tool for applying unified diff patches to files
140 pub struct ApplyPatchTool;
141
142 #[derive(Debug, Clone)]
143 struct FilePatch {
144 path: String,
145 hunks: Vec<Hunk>,
146 delete_after: bool,
147 create_if_missing: bool,
148 /// The section's old header is `/dev/null`: it creates the file and has
149 /// nothing to anchor against, so it must not land on an existing one.
150 creates_new_file: bool,
151 }
152
153 #[derive(Debug, Clone)]
154 struct PendingWrite {
155 path: PathBuf,
156 content: Option<String>,
157 original: Option<String>,
158 }
159
160 #[derive(Debug, Default, Clone, Copy)]
161 struct PatchStats {
162 files_applied: usize,
163 files_total: usize,
164 hunks_applied: usize,
165 hunks_total: usize,
166 fuzz_used: usize,
167 hunks_with_fuzz: usize,
168 hunks_relocated: usize,
169 }
170
171 fn is_zero(value: &usize) -> bool {
172 *value == 0
173 }
174
175 #[derive(Debug, Default, Clone)]
176 struct PatchStatsExt {
177 stats: PatchStats,
178 touched_files: Vec<String>,
179 file_summaries: Vec<FileSummary>,
180 header_path_mismatch: Option<String>,
181 }
182
183 #[derive(Debug, Default, Clone)]
184 struct PatchShape {
185 has_hunks: bool,
186 header_files: Vec<String>,
187 /// Some section's old header is `/dev/null` (a file creation).
188 creates_new_file: bool,
189 }
190
191 impl PatchShape {
192 fn file_count(&self) -> usize {
193 self.header_files.len()
194 }
195 }
196
197 #[derive(Debug, Default, Clone, Copy)]
198 struct HunkApplyStats {
199 hunks_applied: usize,
200 fuzz_used: usize,
201 hunks_with_fuzz: usize,
202 hunks_relocated: usize,
203 }
204
205 /// Result of applying a single hunk: how much positional fuzz was used, and
206 /// whether the hunk had to be relocated to a unique whole-file context match
207 /// (stale line numbers after earlier edits, #5003).
208 #[derive(Debug, Default, Clone, Copy)]
209 struct HunkApplyOutcome {
210 fuzz_used: usize,
211 relocated: bool,
212 }
213
214 #[derive(Debug, Clone)]
215 enum ApplyPatchPreflightKind {
216 Replace,
217 PathOverride {
218 path: String,
219 hunks: Vec<Hunk>,
220 creates_new_file: bool,
221 },
222 FilePatches(Vec<FilePatch>),
223 }
224
225 /// Canonicalized `apply_patch` payload mode.
226 ///
227 /// `replace` is the preferred spelling for full-file replacements. `changes`
228 /// remains a compatibility alias for callers that learned the original tool
229 /// schema before the clearer name was introduced.
230 #[derive(Debug, Clone, Copy)]
231 pub(crate) enum NormalizedApplyPatchInput<'a> {
232 Patch(&'a str),
233 Replacement {
234 entries: &'a [Value],
235 source_field: &'static str,
236 },
237 }
238
239 /// Validate mutual exclusivity and normalize the legacy `changes` alias.
240 ///
241 /// This is the single parser used by execution, preflight, policy, approval,
242 /// and UI consumers so every surface agrees on the accepted input contract.
243 pub(crate) fn normalize_apply_patch_input(
244 input: &Value,
245 ) -> Result<NormalizedApplyPatchInput<'_>, ToolError> {
246 let provided: Vec<&'static str> = ["patch", "replace", "changes"]
247 .into_iter()
248 .filter(|field| input.get(*field).is_some())
249 .collect();
250
251 if provided.len() > 1 {
252 let fields = provided
253 .iter()
254 .map(|field| format!("`{field}`"))
255 .collect::<Vec<_>>()
256 .join(", ");
257 return Err(ToolError::invalid_input(format!(
258 "Cannot use {fields} simultaneously. Choose exactly one of `patch`, `replace`, or the deprecated `changes` alias."
259 )));
260 }
261
262 let Some(field) = provided.first().copied() else {
263 return Err(ToolError::missing_field(
264 "patch, replace, or deprecated changes",
265 ));
266 };
267
268 if field == "patch" {
269 let patch = input
270 .get(field)
271 .and_then(Value::as_str)
272 .ok_or_else(|| ToolError::invalid_input("`patch` must be a string"))?;
273 return Ok(NormalizedApplyPatchInput::Patch(patch));
274 }
275
276 let entries = input.get(field).and_then(Value::as_array).ok_or_else(|| {
277 ToolError::invalid_input(format!(
278 "`{field}` must be an array of objects like {{path, content}}"
279 ))
280 })?;
281 if entries.is_empty() {
282 return Err(ToolError::invalid_input(format!(
283 "`{field}` cannot be empty"
284 )));
285 }
286
287 Ok(NormalizedApplyPatchInput::Replacement {
288 entries,
289 source_field: field,
290 })
291 }
292
293 #[derive(Debug, Clone)]
294 struct ApplyPatchPreflightPlan {
295 summary: ApplyPatchPreflight,
296 kind: ApplyPatchPreflightKind,
297 }
298
299 // === Errors ===
300
301 #[derive(Debug, Error)]
302 enum ApplyHunkError {
303 #[error(
304 "Failed to find matching location for hunk (expected at line {expected_line}, adjusted to {adjusted_line} with offset {offset:+})"
305 )]
306 NoMatch {
307 expected_line: usize,
308 adjusted_line: usize,
309 offset: isize,
310 },
311 #[error(
312 "Hunk context is ambiguous: matches at multiple locations {candidate_lines:?}, expected around line {expected_line}"
313 )]
314 ContextAmbiguous {
315 expected_line: usize,
316 candidate_lines: Vec<usize>,
317 },
318 }
319
320 #[async_trait]
321 impl ToolSpec for ApplyPatchTool {
322 fn name(&self) -> &'static str {
323 "apply_patch"
324 }
325
326 fn model_visible(&self) -> bool {
327 true
328 }
329
330 fn description(&self) -> &'static str {
331 "Apply a transactional unified-diff patch across one or more files, with fuzzy context matching and a rendered diff."
332 }
333
334 fn input_schema(&self) -> Value {
335 json!({
336 "type": "object",
337 "properties": {
338 "path": {
339 "type": "string",
340 "description": "Path to the file to patch (relative to workspace)"
341 },
342 "patch": {
343 "type": "string",
344 "description": "Unified diff patch content"
345 },
346 "replace": {
347 "type": "array",
348 "description": "Optional full file replacements (path + content).",
349 "items": {
350 "type": "object",
351 "properties": {
352 "path": { "type": "string" },
353 "content": { "type": "string" }
354 },
355 "required": ["path", "content"]
356 }
357 },
358 "changes": {
359 "type": "array",
360 "description": "Deprecated compatibility alias for `replace` (full file replacements by path + content).",
361 "items": {
362 "type": "object",
363 "properties": {
364 "path": { "type": "string" },
365 "content": { "type": "string" }
366 },
367 "required": ["path", "content"]
368 }
369 },
370 "fuzz": {
371 "type": "integer",
372 "description": "Maximum fuzz factor for fuzzy matching (default: 3)"
373 },
374 "create_if_missing": {
375 "type": "boolean",
376 "description": "Create the file if it doesn't exist (for new file patches)"
377 },
378 "expected_hash": {
379 "type": "string",
380 "description": format!(
381 "{EXPECTED_HASH_DESCRIPTION} Verifies the patch target — the `path` argument when given, otherwise the first file the patch touches; other files in a multi-file patch are not hash-checked."
382 )
383 }
384 },
385 "oneOf": [
386 { "required": ["patch"] },
387 { "required": ["replace"] },
388 { "required": ["changes"] }
389 ]
390 })
391 }
392
393 fn capabilities(&self) -> Vec<ToolCapability> {
394 vec![
395 ToolCapability::WritesFiles,
396 ToolCapability::Sandboxable,
397 ToolCapability::RequiresApproval,
398 ]
399 }
400
401 fn approval_requirement(&self) -> ApprovalRequirement {
402 ApprovalRequirement::Suggest
403 }
404
405 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
406 let mut input = input;
407 apply_param_aliases(&mut input, PATH_ALIASES, "File patch")?;
408 PATCH_PARAMS.reject_unknown(&input)?;
409 let input = input;
410
411 let fuzz = optional_u64(&input, "fuzz", DEFAULT_FUZZ as u64)?.min(MAX_FUZZ as u64);
412 let fuzz = usize::try_from(fuzz).unwrap_or(DEFAULT_FUZZ);
413 let normalized = normalize_apply_patch_input(&input)?;
414 let create_if_missing = optional_bool(&input, "create_if_missing", false)?;
415 let preflight = preflight_apply_patch_plan(&input, normalized)?;
416 verify_patch_expected_hash(&input, &preflight.summary, context)?;
417
418 if let NormalizedApplyPatchInput::Replacement {
419 entries,
420 source_field,
421 } = normalized
422 {
423 let entries = entries.to_vec();
424 let build_context = context.clone();
425 let (mut pending, stats) = patch_blocking(move || {
426 build_pending_writes_from_replace(&entries, source_field, &build_context)
427 })
428 .await?;
429 normalize_pending_rust(&mut pending).await;
430 let pending = apply_pending_writes_blocking(pending).await?;
431 // Resolve absolute paths for LSP diagnostics query.
432 let abs_paths: Vec<PathBuf> = pending.iter().map(|p| p.path.clone()).collect();
433 let diag_block = lsp_diagnostics_for_paths(context, &abs_paths).await;
434 let result = PatchResult {
435 success: true,
436 files_applied: stats.stats.files_applied,
437 files_total: stats.stats.files_total,
438 hunks_applied: stats.stats.hunks_applied,
439 hunks_total: stats.stats.hunks_total,
440 fuzz_used: stats.stats.fuzz_used,
441 hunks_with_fuzz: stats.stats.hunks_with_fuzz,
442 hunks_relocated: stats.stats.hunks_relocated,
443 touched_files: stats.touched_files.clone(),
444 file_summaries: stats.file_summaries.clone(),
445 message: build_summary_message(&stats),
446 };
447 let mut tool_result = ToolResult::json(&result)
448 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
449 tool_result = tool_result.with_metadata(apply_patch_result_metadata(
450 &preflight.summary,
451 &pending,
452 &stats,
453 ));
454 if !diag_block.is_empty() {
455 tool_result.content.push('\n');
456 tool_result.content.push_str(&diag_block);
457 }
458 return Ok(tool_result);
459 }
460
461 let file_patches = match preflight.kind {
462 ApplyPatchPreflightKind::Replace => {
463 unreachable!("replace input returned before patch execution")
464 }
465 ApplyPatchPreflightKind::PathOverride {
466 path,
467 hunks,
468 creates_new_file,
469 } => vec![FilePatch {
470 path,
471 hunks,
472 delete_after: false,
473 create_if_missing,
474 creates_new_file,
475 }],
476 ApplyPatchPreflightKind::FilePatches(file_patches) => file_patches,
477 };
478
479 let build_context = context.clone();
480 let (mut pending, mut stats) = patch_blocking(move || {
481 build_pending_writes_from_patches(file_patches, &build_context, fuzz)
482 })
483 .await?;
484 stats.header_path_mismatch = preflight.summary.header_path_mismatch.clone();
485 normalize_pending_rust(&mut pending).await;
486 let pending = apply_pending_writes_blocking(pending).await?;
487 // Resolve absolute paths for LSP diagnostics query.
488 let abs_paths: Vec<PathBuf> = pending
489 .iter()
490 .filter(|p| p.content.is_some()) // skip deleted files
491 .map(|p| p.path.clone())
492 .collect();
493 let diag_block = lsp_diagnostics_for_paths(context, &abs_paths).await;
494 let result = PatchResult {
495 success: true,
496 files_applied: stats.stats.files_applied,
497 files_total: stats.stats.files_total,
498 hunks_applied: stats.stats.hunks_applied,
499 hunks_total: stats.stats.hunks_total,
500 fuzz_used: stats.stats.fuzz_used,
501 hunks_with_fuzz: stats.stats.hunks_with_fuzz,
502 hunks_relocated: stats.stats.hunks_relocated,
503 touched_files: stats.touched_files.clone(),
504 file_summaries: stats.file_summaries.clone(),
505 message: build_summary_message(&stats),
506 };
507 let mut tool_result =
508 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
509 tool_result = tool_result.with_metadata(apply_patch_result_metadata(
510 &preflight.summary,
511 &pending,
512 &stats,
513 ));
514 if !diag_block.is_empty() {
515 tool_result.content.push('\n');
516 tool_result.content.push_str(&diag_block);
517 }
518 Ok(tool_result)
519 }
520 }
521
522 /// Enforce the optional `expected_hash` precondition for a patch (#3979).
523 ///
524 /// **This is a whole-patch precondition on a single target file, not a per-file
525 /// guard.** A unified diff carries no place to attach a hash per file section,
526 /// and `File action="patch"` flattens its arguments into one object, so there
527 /// is no clean parameter shape for a hash-per-path map without inventing a
528 /// syntax the model has never seen. The guarded file is therefore the patch's
529 /// target: the explicit `path` argument when one is given, otherwise the first
530 /// file the patch touches. A multi-file patch still verifies only that one
531 /// file; for the rest, the existing hunk-context matching remains the check
532 /// that a stale patch fails on.
533 ///
534 /// Runs before any pending write is built or applied, so a mismatch leaves
535 /// every file in the patch untouched.
536 fn verify_patch_expected_hash(
537 input: &Value,
538 summary: &ApplyPatchPreflight,
539 context: &ToolContext,
540 ) -> Result<(), ToolError> {
541 let Some(expected) = optional_str(input, "expected_hash")? else {
542 return Ok(());
543 };
544
545 let Some(target) = summary
546 .path_override
547 .as_deref()
548 .or_else(|| summary.touched_files.first().map(String::as_str))
549 else {
550 return Err(ToolError::execution_failed(
551 "File `patch` refused: expected_hash was supplied but the patch names no target file to verify it against, so nothing was written.".to_string(),
552 ));
553 };
554
555 let resolved = context.resolve_path(target)?;
556 if !resolved.exists() {
557 // Fail closed, matching `write`: a hash describes a file that was
558 // read, so a missing target means the guard cannot be honored.
559 return Err(ToolError::execution_failed(format!(
560 "File `patch` refused: expected_hash was supplied but {target} does not exist, so there is no snapshot to verify and nothing was written. Recovery: drop `expected_hash` when creating files."
561 )));
562 }
563
564 let current = fs::read(&resolved).map_err(|e| {
565 ToolError::execution_failed(format!(
566 "File `patch` refused: could not read {target} to verify expected_hash ({e}); nothing was written."
567 ))
568 })?;
569 let actual = content_hash(&current);
570 if actual == expected {
571 return Ok(());
572 }
573 Err(ToolError::execution_failed(format!(
574 "File `patch` refused: {target} changed since it was read. \
575 expected_hash was {expected} but the file is now {actual}, so nothing was written. \
576 Recovery: call File with action=\"read\" path=\"{target}\" to get the current contents \
577 and its content_hash, then rebuild the patch against them."
578 )))
579 }
580
581 /// Parse `apply_patch` input into a reusable, no-mutation preflight summary.
582 ///
583 /// This deliberately stops before workspace resolution or file reads. It is
584 /// suitable for policy checks, audit logs, diagnostics hooks, and future undo
585 /// planning that must know the target files before mutation.
586 pub fn preflight_apply_patch(input: &Value) -> Result<ApplyPatchPreflight, ToolError> {
587 let normalized = normalize_apply_patch_input(input)?;
588 Ok(preflight_apply_patch_plan(input, normalized)?.summary)
589 }
590
591 fn preflight_apply_patch_plan(
592 input: &Value,
593 normalized: NormalizedApplyPatchInput<'_>,
594 ) -> Result<ApplyPatchPreflightPlan, ToolError> {
595 let create_if_missing = optional_bool(input, "create_if_missing", false)?;
596
597 if let NormalizedApplyPatchInput::Replacement {
598 entries,
599 source_field,
600 } = normalized
601 {
602 return Ok(ApplyPatchPreflightPlan {
603 summary: preflight_replace(entries, source_field)?,
604 kind: ApplyPatchPreflightKind::Replace,
605 });
606 }
607
608 let NormalizedApplyPatchInput::Patch(patch_text) = normalized else {
609 unreachable!("replacement input returned before patch parsing")
610 };
611 let path_override = optional_str(input, "path")?;
612 let patch_shape = inspect_patch_shape(patch_text);
613 validate_patch_shape(&patch_shape, path_override)?;
614 let header_path_mismatch =
615 path_override.and_then(|path| diff_header_mismatch(path, &patch_shape));
616
617 if let Some(path) = path_override {
618 let hunks = parse_unified_diff(patch_text)?;
619 if hunks.is_empty() {
620 return Err(ToolError::invalid_input(
621 "Patch did not contain any hunks (`@@ ... @@`). Provide a unified diff hunk.",
622 ));
623 }
624 return Ok(ApplyPatchPreflightPlan {
625 summary: ApplyPatchPreflight {
626 touched_files: vec![path.to_string()],
627 files_total: 1,
628 hunks_total: hunks.len(),
629 creates: if create_if_missing {
630 vec![path.to_string()]
631 } else {
632 Vec::new()
633 },
634 deletes: Vec::new(),
635 path_override: Some(path.to_string()),
636 header_path_mismatch,
637 },
638 kind: ApplyPatchPreflightKind::PathOverride {
639 path: path.to_string(),
640 hunks,
641 // `path` retargets the section; a `--- /dev/null` header
642 // still means "create", and must not land on an existing file.
643 creates_new_file: patch_shape.creates_new_file,
644 },
645 });
646 }
647
648 let file_patches = parse_unified_diff_files(patch_text, create_if_missing)?;
649 if file_patches.is_empty() {
650 return Err(ToolError::invalid_input(
651 "No valid file patches found. Ensure the patch includes `---`/`+++` headers or provide `path`.",
652 ));
653 }
654
655 let mut touched_files = Vec::new();
656 let mut creates = Vec::new();
657 let mut deletes = Vec::new();
658 let mut hunks_total = 0;
659 for file_patch in &file_patches {
660 if file_patch.hunks.is_empty() {
661 return Err(ToolError::invalid_input(format!(
662 "Patch section for `{}` has no hunks (`@@ ... @@`).",
663 file_patch.path
664 )));
665 }
666 push_unique(&mut touched_files, file_patch.path.clone());
667 hunks_total += file_patch.hunks.len();
668 if file_patch.create_if_missing && !file_patch.delete_after {
669 push_unique(&mut creates, file_patch.path.clone());
670 }
671 if file_patch.delete_after {
672 push_unique(&mut deletes, file_patch.path.clone());
673 }
674 }
675
676 Ok(ApplyPatchPreflightPlan {
677 summary: ApplyPatchPreflight {
678 files_total: file_patches.len(),
679 touched_files,
680 hunks_total,
681 creates,
682 deletes,
683 path_override: None,
684 header_path_mismatch,
685 },
686 kind: ApplyPatchPreflightKind::FilePatches(file_patches),
687 })
688 }
689
690 fn preflight_replace(
691 changes: &[Value],
692 source_field: &str,
693 ) -> Result<ApplyPatchPreflight, ToolError> {
694 let mut touched_files = Vec::new();
695 for change in changes {
696 let path = change
697 .get("path")
698 .and_then(Value::as_str)
699 .ok_or_else(|| ToolError::missing_field(format!("{source_field}[].path")))?;
700 let _content = change
701 .get("content")
702 .and_then(Value::as_str)
703 .ok_or_else(|| ToolError::missing_field(format!("{source_field}[].content")))?;
704 push_unique(&mut touched_files, path.to_string());
705 }
706
707 Ok(ApplyPatchPreflight {
708 files_total: changes.len(),
709 touched_files,
710 hunks_total: 0,
711 creates: Vec::new(),
712 deletes: Vec::new(),
713 path_override: None,
714 header_path_mismatch: None,
715 })
716 }
717
718 fn apply_patch_result_metadata(
719 preflight: &ApplyPatchPreflight,
720 pending: &[PendingWrite],
721 stats: &PatchStatsExt,
722 ) -> Value {
723 let mut metadata =
724 serde_json::to_value(preflight).expect("ApplyPatchPreflight should serialize");
725 if let Some(object) = metadata.as_object_mut() {
726 object.insert("event".to_string(), json!("apply_patch.preflight"));
727 object.insert(
728 "mutation".to_string(),
729 build_mutation_metadata(pending, &stats.file_summaries),
730 );
731 }
732 metadata
733 }
734
735 /// Preserve the exact applied before/after diff independently from approval
736 /// presentation. The TUI consumes this success-only metadata for its calm
737 /// File receipt; the normal model-facing result remains compact JSON.
738 fn build_mutation_metadata(pending: &[PendingWrite], summaries: &[FileSummary]) -> Value {
739 let mut matched = HashSet::new();
740 let mut renames = Vec::new();
741
742 for (delete_index, (deleted, delete_summary)) in pending.iter().zip(summaries).enumerate() {
743 if !delete_summary.deleted || matched.contains(&delete_index) {
744 continue;
745 }
746 let Some(old_content) = deleted.original.as_deref() else {
747 continue;
748 };
749 let Some((create_index, (created, create_summary))) = pending
750 .iter()
751 .zip(summaries)
752 .enumerate()
753 .find(|(index, (created, summary))| {
754 !matched.contains(index)
755 && summary.created
756 && created.content.as_deref() == Some(old_content)
757 })
758 else {
759 continue;
760 };
761 matched.insert(delete_index);
762 matched.insert(create_index);
763 // The destination's bytes are the ones this call wrote, so a rename
764 // carries the same size/sha256 facts as a created or updated file.
765 let mut rename = json!({
766 "from": delete_summary.path,
767 "to": create_summary.path,
768 });
769 if let Some(content) = created.content.as_deref() {
770 rename["size"] = json!(content.len());
771 rename["sha256"] = json!(crate::hashing::sha256_hex(content.as_bytes()));
772 }
773 renames.push(rename);
774 }
775
776 let mut files = Vec::new();
777 for (index, summary) in summaries.iter().enumerate() {
778 if matched.contains(&index) {
779 continue;
780 }
781 let outcome = if summary.created {
782 "created"
783 } else if summary.deleted {
784 "deleted"
785 } else {
786 "updated"
787 };
788 // Deleted entries carry neither size nor sha256: no bytes were written.
789 let written = pending
790 .get(index)
791 .and_then(|write| write.content.as_deref())
792 .filter(|_| !summary.deleted)
793 .map(str::as_bytes);
794 files.push(crate::tools::file::mutation_file_entry(
795 &summary.path,
796 outcome,
797 written,
798 ));
799 }
800
801 let mut diff_parts = Vec::new();
802 for rename in &renames {
803 let from = rename["from"].as_str().unwrap_or("<file>");
804 let to = rename["to"].as_str().unwrap_or("<file>");
805 diff_parts.push(format!(
806 "diff --git a/{from} b/{to}\nsimilarity index 100%\nrename from {from}\nrename to {to}\n"
807 ));
808 }
809 for (index, (write, summary)) in pending.iter().zip(summaries).enumerate() {
810 if matched.contains(&index) {
811 continue;
812 }
813 let old = write.original.as_deref().unwrap_or("");
814 let new = write.content.as_deref().unwrap_or("");
815 let diff = make_unified_diff(&summary.path, old, new);
816 if !diff.is_empty() {
817 diff_parts.push(format!(
818 "diff --git a/{path} b/{path}\n{diff}",
819 path = summary.path
820 ));
821 }
822 }
823
824 json!({
825 "diff": diff_parts.join("\n"),
826 "files": files,
827 "renames": renames,
828 })
829 }
830
831 /// Parse a unified diff into hunks
832 fn parse_unified_diff(patch: &str) -> Result<Vec<Hunk>, ToolError> {
833 let mut hunks = Vec::new();
834 let mut lines = patch.lines().peekable();
835
836 // Skip header lines (---, +++ etc)
837 while let Some(line) = lines.peek() {
838 if line.starts_with("@@") {
839 break;
840 }
841 lines.next();
842 }
843
844 // Parse hunks
845 while let Some(line) = lines.next() {
846 if line.starts_with("@@") {
847 let hunk = parse_hunk_header(line, &mut lines)?;
848 hunks.push(hunk);
849 }
850 }
851
852 Ok(hunks)
853 }
854
855 fn parse_unified_diff_files(
856 patch: &str,
857 create_if_missing: bool,
858 ) -> Result<Vec<FilePatch>, ToolError> {
859 let mut files = Vec::new();
860 let mut lines = patch.lines().peekable();
861 let mut current: Option<FilePatch> = None;
862 let mut old_path: Option<String> = None;
863
864 while let Some(line) = lines.next() {
865 if line.starts_with("diff --git ") {
866 if let Some(file) = current.take() {
867 files.push(file);
868 }
869 old_path = None;
870 continue;
871 }
872
873 if let Some(stripped) = line.strip_prefix("--- ") {
874 old_path = Some(stripped.trim().to_string());
875 continue;
876 }
877
878 if let Some(stripped) = line.strip_prefix("+++ ") {
879 let new_path = Some(stripped.trim().to_string());
880 let (path, delete_after, create_flag) =
881 resolve_diff_paths(old_path.as_deref(), new_path.as_deref(), create_if_missing)?;
882 let creates_new_file = old_path.as_deref().is_some_and(is_dev_null_header);
883 old_path = None;
884 if let Some(file) = current.take() {
885 files.push(file);
886 }
887 current = Some(FilePatch {
888 path,
889 hunks: Vec::new(),
890 delete_after,
891 create_if_missing: create_flag,
892 creates_new_file,
893 });
894 continue;
895 }
896
897 if line.starts_with("@@") {
898 let Some(file) = current.as_mut() else {
899 if let Some(path) = old_path.as_deref() {
900 return Err(ToolError::invalid_input(format!(
901 "Patch hunk encountered after `--- {path}` but before a matching `+++` header. Each file section must include both headers."
902 )));
903 }
904 return Err(ToolError::invalid_input(
905 "Patch hunk encountered before any file header. Add `---`/`+++` headers or provide `path`.",
906 ));
907 };
908 let hunk = parse_hunk_header(line, &mut lines)?;
909 file.hunks.push(hunk);
910 }
911 }
912
913 if let Some(file) = current {
914 files.push(file);
915 }
916
917 Ok(files)
918 }
919
920 fn resolve_diff_paths(
921 old_path: Option<&str>,
922 new_path: Option<&str>,
923 create_if_missing: bool,
924 ) -> Result<(String, bool, bool), ToolError> {
925 let old_norm = old_path.and_then(normalize_diff_path);
926 let new_norm = new_path.and_then(normalize_diff_path);
927 let delete_after = new_norm.is_none();
928 let create_flag = create_if_missing || old_norm.is_none();
929 let path = new_norm
930 .or(old_norm)
931 .ok_or_else(|| ToolError::invalid_input("Patch is missing both old and new file paths"))?;
932 Ok((path, delete_after, create_flag))
933 }
934
935 fn is_dev_null_header(raw: &str) -> bool {
936 let raw = raw.split_once('\t').map_or(raw, |(path, _timestamp)| path);
937 matches!(raw.trim(), "/dev/null" | "dev/null")
938 }
939
940 fn normalize_diff_path(raw: &str) -> Option<String> {
941 let raw = raw.split_once('\t').map_or(raw, |(path, _timestamp)| path);
942 let raw = raw.trim();
943 if raw.is_empty() {
944 return None;
945 }
946 if raw == "/dev/null" || raw == "dev/null" {
947 return None;
948 }
949 let raw = raw
950 .strip_prefix("a/")
951 .or_else(|| raw.strip_prefix("b/"))
952 .unwrap_or(raw);
953 Some(raw.to_string())
954 }
955
956 /// Parse a hunk header and its content
957 fn parse_hunk_header<'a, I>(
958 header: &str,
959 lines: &mut std::iter::Peekable<I>,
960 ) -> Result<Hunk, ToolError>
961 where
962 I: Iterator<Item = &'a str> + Clone,
963 {
964 // Parse @@ -old_start,old_count +new_start,new_count @@
965 let parts: Vec<&str> = header.split_whitespace().collect();
966 if parts.len() < 3 {
967 return Err(ToolError::invalid_input(format!(
968 "Invalid hunk header: {header}. Expected numeric unified-diff form `@@ -old_start,old_count +new_start,new_count @@` (example: `@@ -12,3 +12,5 @@`)."
969 )));
970 }
971
972 let old_range = parts[1].trim_start_matches('-');
973 let new_range = parts[2].trim_start_matches('+');
974
975 let (old_start, old_count) = parse_range(old_range)?;
976 let (new_start, new_count) = parse_range(new_range)?;
977
978 // Parse hunk lines
979 let mut hunk_lines = Vec::new();
980 let expected_lines = old_count.max(new_count) + old_count.min(new_count);
981 let mut old_remaining = old_count;
982 let mut new_remaining = new_count;
983
984 for _ in 0..expected_lines * 2 {
985 // `--- ` opens the next file section once this hunk's counts are
986 // spent, or when `+++ ` follows (a header after a miscounted hunk).
987 // Otherwise it is a removed line whose text starts `-- `. Reading it
988 // as a removal unconditionally glued a second section for the same
989 // file onto the first hunk (B7).
990 let next_file_header = {
991 let mut ahead = lines.clone();
992 ahead.next().is_some_and(|line| line.starts_with("--- "))
993 && ((old_remaining == 0 && new_remaining == 0)
994 || ahead.next().is_some_and(|line| line.starts_with("+++ ")))
995 };
996 if next_file_header {
997 break;
998 }
999 // Allow for more lines than expected
1000 match lines.peek() {
1001 Some(line) if line.starts_with("@@") => break,
1002 Some(line) if line.starts_with('-') => {
1003 hunk_lines.push(HunkLine::Remove(line[1..].to_string()));
1004 old_remaining = old_remaining.saturating_sub(1);
1005 lines.next();
1006 }
1007 Some(line) if line.starts_with('+') => {
1008 hunk_lines.push(HunkLine::Add(line[1..].to_string()));
1009 new_remaining = new_remaining.saturating_sub(1);
1010 lines.next();
1011 }
1012 Some(line) if line.starts_with(' ') || line.is_empty() => {
1013 let content = if line.is_empty() { "" } else { &line[1..] };
1014 hunk_lines.push(HunkLine::Context(content.to_string()));
1015 old_remaining = old_remaining.saturating_sub(1);
1016 new_remaining = new_remaining.saturating_sub(1);
1017 lines.next();
1018 }
1019 Some(line)
1020 if line.starts_with("diff ")
1021 || line.starts_with("--- ")
1022 || line.starts_with("+++ ") =>
1023 {
1024 // Start of a new file patch - don't consume, let outer loop handle it
1025 break;
1026 }
1027 Some(line) if !line.starts_with('\\') => {
1028 // Treat as context line without leading space
1029 hunk_lines.push(HunkLine::Context((*line).to_string()));
1030 old_remaining = old_remaining.saturating_sub(1);
1031 new_remaining = new_remaining.saturating_sub(1);
1032 lines.next();
1033 }
1034 Some(_) => {
1035 lines.next(); // Skip "\ No newline at end of file" etc
1036 }
1037 None => break,
1038 }
1039 }
1040
1041 Ok(Hunk {
1042 old_start,
1043 old_count,
1044 new_start,
1045 new_count,
1046 lines: hunk_lines,
1047 })
1048 }
1049
1050 /// Parse a range like "10,5" or "10" into (start, count)
1051 fn parse_range(range: &str) -> Result<(usize, usize), ToolError> {
1052 let parts: Vec<&str> = range.split(',').collect();
1053 let start = parts[0].parse::<usize>().map_err(|_| {
1054 ToolError::invalid_input(format!(
1055 "Invalid line number `{}` in hunk header. Expected numeric unified-diff form `@@ -old_start,old_count +new_start,new_count @@` (example: `@@ -12,3 +12,5 @@`); use positive integers like `12` or `12,3`.",
1056 parts[0]
1057 ))
1058 })?;
1059 let count = if parts.len() > 1 {
1060 parts[1].parse::<usize>().map_err(|_| {
1061 ToolError::invalid_input(format!(
1062 "Invalid line count `{}` in hunk header. Expected numeric unified-diff form `@@ -old_start,old_count +new_start,new_count @@` (example: `@@ -12,3 +12,5 @@`); use positive integers like `3`.",
1063 parts[1]
1064 ))
1065 })?
1066 } else {
1067 1
1068 };
1069 Ok((start, count))
1070 }
1071
1072 fn inspect_patch_shape(patch: &str) -> PatchShape {
1073 let mut shape = PatchShape::default();
1074 let mut seen = HashSet::new();
1075 let mut old_path: Option<String> = None;
1076 let mut old_is_dev_null = false;
1077 let mut hunk_old_remaining = 0usize;
1078 let mut hunk_new_remaining = 0usize;
1079
1080 for line in patch.lines() {
1081 if line.starts_with("@@") {
1082 shape.has_hunks = true;
1083 if let Some((old_count, new_count)) = hunk_line_counts_for_shape(line) {
1084 hunk_old_remaining = old_count;
1085 hunk_new_remaining = new_count;
1086 }
1087 continue;
1088 }
1089
1090 if hunk_old_remaining > 0 || hunk_new_remaining > 0 {
1091 advance_hunk_shape_counts(line, &mut hunk_old_remaining, &mut hunk_new_remaining);
1092 continue;
1093 }
1094
1095 if let Some(stripped) = line.strip_prefix("--- ") {
1096 old_is_dev_null = is_dev_null_header(stripped);
1097 old_path = normalize_diff_path(stripped);
1098 continue;
1099 }
1100
1101 if let Some(stripped) = line.strip_prefix("+++ ") {
1102 shape.creates_new_file |= old_is_dev_null && !is_dev_null_header(stripped);
1103 old_is_dev_null = false;
1104 let new_path = normalize_diff_path(stripped);
1105 let resolved = new_path.or(old_path.clone());
1106 if let Some(path) = resolved
1107 && seen.insert(path.clone())
1108 {
1109 shape.header_files.push(path);
1110 }
1111 old_path = None;
1112 }
1113 }
1114
1115 shape
1116 }
1117
1118 fn hunk_line_counts_for_shape(header: &str) -> Option<(usize, usize)> {
1119 let parts: Vec<&str> = header.split_whitespace().collect();
1120 if parts.len() < 3 {
1121 return None;
1122 }
1123 let (_, old_count) = parse_range(parts[1].trim_start_matches('-')).ok()?;
1124 let (_, new_count) = parse_range(parts[2].trim_start_matches('+')).ok()?;
1125 Some((old_count, new_count))
1126 }
1127
1128 fn advance_hunk_shape_counts(line: &str, old_remaining: &mut usize, new_remaining: &mut usize) {
1129 if line.starts_with('\\') {
1130 return;
1131 }
1132 if line.starts_with('+') {
1133 *new_remaining = new_remaining.saturating_sub(1);
1134 } else if line.starts_with('-') {
1135 *old_remaining = old_remaining.saturating_sub(1);
1136 } else {
1137 *old_remaining = old_remaining.saturating_sub(1);
1138 *new_remaining = new_remaining.saturating_sub(1);
1139 }
1140 }
1141
1142 fn validate_patch_shape(shape: &PatchShape, path_override: Option<&str>) -> Result<(), ToolError> {
1143 if !shape.has_hunks {
1144 return Err(ToolError::invalid_input(
1145 "Patch must include at least one hunk header in numeric unified-diff form (`@@ -old_start,old_count +new_start,new_count @@`, example: `@@ -12,3 +12,5 @@`).",
1146 ));
1147 }
1148
1149 match path_override {
1150 Some(_) if shape.file_count() > 1 => Err(ToolError::invalid_input(format!(
1151 "Patch references multiple files ({}) but `path` was provided. Remove `path` to apply a multi-file patch, or provide a single-file patch.",
1152 format_file_list(&shape.header_files),
1153 ))),
1154 None if shape.file_count() == 0 => Err(ToolError::invalid_input(
1155 "Patch contains hunks but no file headers (`---`/`+++`). Provide `path` or add headers.",
1156 )),
1157 _ => Ok(()),
1158 }
1159 }
1160
1161 fn diff_header_mismatch(path_override: &str, shape: &PatchShape) -> Option<String> {
1162 if shape.file_count() != 1 {
1163 return None;
1164 }
1165 let header_path = &shape.header_files[0];
1166 let override_norm = normalize_diff_path(path_override).unwrap_or_else(|| path_override.into());
1167 if &override_norm == header_path {
1168 None
1169 } else {
1170 Some(format!(
1171 "Note: patch headers reference `{header_path}` but `path` overrides to `{override_norm}`."
1172 ))
1173 }
1174 }
1175
1176 fn build_summary_message(stats: &PatchStatsExt) -> String {
1177 let mut parts = Vec::new();
1178 if stats.stats.hunks_total > 0 {
1179 parts.push(format!(
1180 "Applied {}/{} hunks across {} file(s).",
1181 stats.stats.hunks_applied, stats.stats.hunks_total, stats.stats.files_applied
1182 ));
1183 } else {
1184 parts.push(format!(
1185 "Applied {} file change(s).",
1186 stats.stats.files_applied
1187 ));
1188 }
1189
1190 if !stats.touched_files.is_empty() {
1191 parts.push(format!(
1192 "Files: {}.",
1193 format_file_list(&stats.touched_files)
1194 ));
1195 }
1196
1197 if stats.stats.fuzz_used > 0 {
1198 parts.push(format!(
1199 "Fuzz used on {} hunk(s) (total fuzz: {}).",
1200 stats.stats.hunks_with_fuzz, stats.stats.fuzz_used
1201 ));
1202 }
1203
1204 if stats.stats.hunks_relocated > 0 {
1205 parts.push(format!(
1206 "{} hunk(s) applied with stale line numbers (auto-relocated to unique context).",
1207 stats.stats.hunks_relocated
1208 ));
1209 }
1210
1211 if let Some(note) = stats.header_path_mismatch.as_deref() {
1212 parts.push(note.to_string());
1213 }
1214
1215 parts.join(" ")
1216 }
1217
1218 fn format_file_list(files: &[String]) -> String {
1219 if files.is_empty() {
1220 return "<none>".to_string();
1221 }
1222 let mut shown: Vec<String> = files.iter().take(FILE_LIST_LIMIT).cloned().collect();
1223 let remaining = files.len().saturating_sub(shown.len());
1224 if remaining > 0 {
1225 shown.push(format!("... (+{remaining} more)"));
1226 }
1227 shown.join(", ")
1228 }
1229
1230 fn push_unique(target: &mut Vec<String>, value: String) {
1231 if !target.iter().any(|existing| existing == &value) {
1232 target.push(value);
1233 }
1234 }
1235
1236 fn build_pending_writes_from_replace(
1237 changes: &[Value],
1238 source_field: &str,
1239 context: &ToolContext,
1240 ) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
1241 let mut pending = Vec::new();
1242 let mut stats = PatchStatsExt::default();
1243 for change in changes {
1244 let path = change
1245 .get("path")
1246 .and_then(Value::as_str)
1247 .ok_or_else(|| ToolError::missing_field(format!("{source_field}[].path")))?;
1248 let content = change
1249 .get("content")
1250 .and_then(Value::as_str)
1251 .ok_or_else(|| ToolError::missing_field(format!("{source_field}[].content")))?;
1252
1253 let resolved = context.resolve_path(path)?;
1254 let original = if resolved.exists() {
1255 Some(read_file_content(&resolved)?)
1256 } else {
1257 None
1258 };
1259 let created = original.is_none();
1260
1261 pending.push(PendingWrite {
1262 path: resolved,
1263 content: Some(content.to_string()),
1264 original,
1265 });
1266
1267 stats.stats.files_total += 1;
1268 stats.stats.files_applied += 1;
1269 push_unique(&mut stats.touched_files, path.to_string());
1270 stats.file_summaries.push(FileSummary {
1271 path: path.to_string(),
1272 hunks: 0,
1273 hunks_applied: 0,
1274 fuzz_used: 0,
1275 hunks_with_fuzz: 0,
1276 hunks_relocated: 0,
1277 created,
1278 deleted: false,
1279 });
1280 }
1281
1282 Ok((pending, stats))
1283 }
1284
1285 fn build_pending_writes_from_patches(
1286 file_patches: Vec<FilePatch>,
1287 context: &ToolContext,
1288 fuzz: usize,
1289 ) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
1290 let mut pending: Vec<PendingWrite> = Vec::new();
1291 // Where each file's write already sits in `pending`: a second section for
1292 // the same file applies on top of the first instead of re-reading the
1293 // disk and silently dropping the earlier hunks (B7).
1294 let mut pending_index: std::collections::HashMap<PathBuf, usize> =
1295 std::collections::HashMap::new();
1296 let mut stats = PatchStatsExt::default();
1297 stats.stats.files_total = file_patches.len();
1298
1299 for file_patch in file_patches {
1300 if file_patch.hunks.is_empty() {
1301 return Err(ToolError::invalid_input(format!(
1302 "Patch section for `{}` has no hunks (`@@ ... @@`).",
1303 file_patch.path
1304 )));
1305 }
1306
1307 let resolved = context.resolve_path(&file_patch.path)?;
1308 if let Some(&index) = pending_index.get(&resolved) {
1309 let Some(current) = pending[index].content.clone() else {
1310 return Err(ToolError::invalid_input(format!(
1311 "Patch has a section for `{}` after a section that deletes it; merge them into one section.",
1312 file_patch.path
1313 )));
1314 };
1315 if file_patch.creates_new_file && !current.is_empty() {
1316 return Err(file_already_exists_error(&file_patch.path, &resolved));
1317 }
1318 let mut lines: Vec<String> = current.lines().map(String::from).collect();
1319 let apply_stats =
1320 apply_hunks_to_lines(&mut lines, &file_patch.hunks, fuzz, &file_patch.path)?;
1321 if file_patch.delete_after {
1322 ensure_delete_consumes_file(&lines, &file_patch.path)?;
1323 }
1324 stats.stats.hunks_applied += apply_stats.hunks_applied;
1325 stats.stats.hunks_total += file_patch.hunks.len();
1326 stats.stats.fuzz_used += apply_stats.fuzz_used;
1327 stats.stats.hunks_with_fuzz += apply_stats.hunks_with_fuzz;
1328 stats.stats.hunks_relocated += apply_stats.hunks_relocated;
1329 if let Some(summary) = stats
1330 .file_summaries
1331 .iter_mut()
1332 .rev()
1333 .find(|summary| summary.path == file_patch.path)
1334 {
1335 summary.hunks += file_patch.hunks.len();
1336 summary.hunks_applied += apply_stats.hunks_applied;
1337 summary.fuzz_used += apply_stats.fuzz_used;
1338 summary.hunks_with_fuzz += apply_stats.hunks_with_fuzz;
1339 summary.hunks_relocated += apply_stats.hunks_relocated;
1340 summary.deleted |= file_patch.delete_after;
1341 summary.created &= !file_patch.delete_after;
1342 }
1343 pending[index].content = (!file_patch.delete_after)
1344 .then(|| reassemble_preserving_newlines(&lines, &current));
1345 // One file, however many sections: count it once.
1346 stats.stats.files_total = stats.stats.files_total.saturating_sub(1);
1347 continue;
1348 }
1349 let original = if resolved.exists() {
1350 Some(read_file_content(&resolved)?)
1351 } else {
1352 None
1353 };
1354
1355 if original.is_none() && !file_patch.create_if_missing {
1356 return Err(ToolError::execution_failed(format!(
1357 "File `{}` does not exist at `{}`. Set create_if_missing=true for new files or include headers for file creation.",
1358 file_patch.path,
1359 resolved.display(),
1360 )));
1361 }
1362
1363 if file_patch.delete_after && original.is_none() {
1364 return Err(ToolError::execution_failed(format!(
1365 "File `{}` does not exist at `{}` to delete.",
1366 file_patch.path,
1367 resolved.display(),
1368 )));
1369 }
1370
1371 // A `--- /dev/null` section has no old lines to anchor on, so on an
1372 // existing file it would splice the "new" content onto the top of
1373 // the old one and report success. `git apply` refuses this too.
1374 if file_patch.creates_new_file && original.as_deref().is_some_and(|c| !c.is_empty()) {
1375 return Err(file_already_exists_error(&file_patch.path, &resolved));
1376 }
1377
1378 let base_content = original.clone().unwrap_or_default();
1379 let mut lines: Vec<String> = if base_content.is_empty() {
1380 Vec::new()
1381 } else {
1382 base_content.lines().map(String::from).collect()
1383 };
1384
1385 let apply_stats =
1386 apply_hunks_to_lines(&mut lines, &file_patch.hunks, fuzz, &file_patch.path)?;
1387 if file_patch.delete_after {
1388 ensure_delete_consumes_file(&lines, &file_patch.path)?;
1389 }
1390 stats.stats.hunks_applied += apply_stats.hunks_applied;
1391 stats.stats.hunks_total += file_patch.hunks.len();
1392 stats.stats.fuzz_used += apply_stats.fuzz_used;
1393 stats.stats.hunks_with_fuzz += apply_stats.hunks_with_fuzz;
1394 stats.stats.hunks_relocated += apply_stats.hunks_relocated;
1395 stats.stats.files_applied += 1;
1396 push_unique(&mut stats.touched_files, file_patch.path.clone());
1397 stats.file_summaries.push(FileSummary {
1398 path: file_patch.path.clone(),
1399 hunks: file_patch.hunks.len(),
1400 hunks_applied: apply_stats.hunks_applied,
1401 fuzz_used: apply_stats.fuzz_used,
1402 hunks_with_fuzz: apply_stats.hunks_with_fuzz,
1403 hunks_relocated: apply_stats.hunks_relocated,
1404 created: original.is_none() && !file_patch.delete_after,
1405 deleted: file_patch.delete_after,
1406 });
1407
1408 pending_index.insert(resolved.clone(), pending.len());
1409 if file_patch.delete_after {
1410 pending.push(PendingWrite {
1411 path: resolved,
1412 content: None,
1413 original,
1414 });
1415 } else {
1416 let new_content = reassemble_preserving_newlines(&lines, &base_content);
1417 pending.push(PendingWrite {
1418 path: resolved,
1419 content: Some(new_content),
1420 original,
1421 });
1422 }
1423 }
1424
1425 Ok((pending, stats))
1426 }
1427
1428 fn file_already_exists_error(label: &str, resolved: &Path) -> ToolError {
1429 ToolError::execution_failed(format!(
1430 "Patch creates `{label}` (`--- /dev/null`), but it already exists at `{}`. Nothing was changed. To change the existing file, send a patch with context lines from its current contents; to replace it wholesale, use a write/replace action.",
1431 resolved.display(),
1432 ))
1433 }
1434
1435 /// A `+++ /dev/null` section deletes the file, so its hunks must remove every
1436 /// line. Deleting after a partial match would silently drop the rest.
1437 fn ensure_delete_consumes_file(remaining: &[String], label: &str) -> Result<(), ToolError> {
1438 if remaining.is_empty() {
1439 return Ok(());
1440 }
1441 Err(ToolError::execution_failed(format!(
1442 "Patch deletes `{label}` (`+++ /dev/null`), but its hunks leave {} line(s) of the file unaccounted for. Nothing was changed. Include every line of the file as `-` lines to delete it, or send a patch with a real `+++` path to edit it.",
1443 remaining.len(),
1444 )))
1445 }
1446
1447 /// Normalize the Rust files a patch rewrites (#6205), before the write and
1448 /// before the result's diff is built, so the rendered diff and the bytes on
1449 /// disk are the same text and the model's next anchor matches reality.
1450 async fn normalize_pending_rust(pending: &mut [PendingWrite]) {
1451 for entry in pending.iter_mut() {
1452 let (Some(content), Some(original)) = (entry.content.as_ref(), entry.original.as_ref())
1453 else {
1454 continue;
1455 };
1456 if let Some(normalized) = normalize_edit(&entry.path, original, content).await {
1457 entry.content = Some(normalized);
1458 }
1459 }
1460 }
1461
1462 /// Run the patch's synchronous file work (reads, the syntax gate, writes and
1463 /// rollback) on the blocking pool rather than an async worker (#6559 D01-06).
1464 async fn patch_blocking<T: Send + 'static>(
1465 work: impl FnOnce() -> Result<T, ToolError> + Send + 'static,
1466 ) -> Result<T, ToolError> {
1467 tokio::task::spawn_blocking(work)
1468 .await
1469 .map_err(|error| ToolError::execution_failed(format!("patch task failed: {error}")))?
1470 }
1471
1472 /// [`apply_pending_writes`] on the blocking pool, handing `pending` back for
1473 /// the result's diagnostics and metadata.
1474 async fn apply_pending_writes_blocking(
1475 pending: Vec<PendingWrite>,
1476 ) -> Result<Vec<PendingWrite>, ToolError> {
1477 patch_blocking(move || apply_pending_writes(&pending).map(|()| pending)).await
1478 }
1479
1480 fn apply_pending_writes(pending: &[PendingWrite]) -> Result<(), ToolError> {
1481 // Syntax gate (#6204) ahead of the first write, not per file: a patch is
1482 // transactional, so one unparseable result must leave every file in the
1483 // patch untouched rather than half-applied and rolled back.
1484 for entry in pending {
1485 if let Some(content) = entry.content.as_ref() {
1486 let display = entry.path.display().to_string();
1487 guard_edit(&entry.path, &display, entry.original.as_deref(), content)?;
1488 }
1489 }
1490
1491 let mut applied = Vec::new();
1492
1493 for entry in pending {
1494 let result = if let Some(content) = entry.content.as_ref() {
1495 let parent_result = if let Some(parent) = entry.path.parent() {
1496 fs::create_dir_all(parent).map_err(|e| {
1497 ToolError::execution_failed(format!(
1498 "Failed to create directory {}: {}",
1499 parent.display(),
1500 e
1501 ))
1502 })
1503 } else {
1504 Ok(())
1505 };
1506
1507 parent_result.and_then(|()| {
1508 crate::utils::write_atomic_workspace(&entry.path, content.as_bytes()).map_err(|e| {
1509 ToolError::execution_failed(format!(
1510 "Failed to write {}: {}",
1511 entry.path.display(),
1512 e
1513 ))
1514 })
1515 })
1516 } else if entry.path.exists() {
1517 fs::remove_file(&entry.path).map_err(|e| {
1518 ToolError::execution_failed(format!(
1519 "Failed to delete {}: {}",
1520 entry.path.display(),
1521 e
1522 ))
1523 })
1524 } else {
1525 Ok(())
1526 };
1527
1528 if let Err(err) = result {
1529 let failures = rollback_pending_writes(&applied);
1530 if failures.is_empty() {
1531 return Err(err);
1532 }
1533 // A failed restore leaves the patch partly applied; say which
1534 // files still hold it rather than implying nothing changed.
1535 return Err(ToolError::execution_failed(format!(
1536 "{err}. Rolling back the files already written also failed, so the patch is \
1537 partly applied: {}",
1538 failures.join("; ")
1539 )));
1540 }
1541
1542 applied.push(entry.clone());
1543 }
1544
1545 Ok(())
1546 }
1547
1548 /// Restore every already-applied entry, newest first. Returns one line per
1549 /// entry that could not be restored (#6559 D01-06: these were discarded).
1550 fn rollback_pending_writes(applied: &[PendingWrite]) -> Vec<String> {
1551 let mut failures = Vec::new();
1552 for entry in applied.iter().rev() {
1553 let restored = match entry.original.as_ref() {
1554 Some(content) => crate::utils::write_atomic_workspace(&entry.path, content.as_bytes()),
1555 None => match fs::remove_file(&entry.path) {
1556 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1557 other => other,
1558 },
1559 };
1560 if let Err(error) = restored {
1561 failures.push(format!("{} ({error})", entry.path.display()));
1562 }
1563 }
1564 failures
1565 }
1566
1567 fn read_file_content(path: &PathBuf) -> Result<String, ToolError> {
1568 fs::read_to_string(path).map_err(|e| {
1569 ToolError::execution_failed(format!("Failed to read {}: {}", path.display(), e))
1570 })
1571 }
1572
1573 fn preview_expected_lines(hunk: &Hunk, limit: usize) -> Vec<String> {
1574 let mut preview = Vec::new();
1575 for line in hunk.lines.iter().filter_map(|line| match line {
1576 HunkLine::Context(s) => Some((" ", s)),
1577 HunkLine::Remove(s) => Some(("-", s)),
1578 HunkLine::Add(_) => None,
1579 }) {
1580 if preview.len() >= limit {
1581 break;
1582 }
1583 preview.push(format!(" {}{}", line.0, line.1));
1584 }
1585 if preview.is_empty() {
1586 preview.push(" <no context lines in hunk>".to_string());
1587 }
1588 preview
1589 }
1590
1591 fn snippet_around(lines: &[String], line_1_based: usize, radius: usize) -> Vec<String> {
1592 if lines.is_empty() {
1593 return vec![" <empty file>".to_string()];
1594 }
1595
1596 let center = line_1_based
1597 .saturating_sub(1)
1598 .min(lines.len().saturating_sub(1));
1599 let start = center.saturating_sub(radius);
1600 let end = (center + radius).min(lines.len().saturating_sub(1));
1601
1602 lines[start..=end]
1603 .iter()
1604 .enumerate()
1605 .map(|(idx, line)| {
1606 let line_no = start + idx + 1;
1607 format!(" {line_no:>4}: {line}")
1608 })
1609 .collect()
1610 }
1611
1612 fn format_hunk_no_match_error(
1613 lines: &[String],
1614 hunk: &Hunk,
1615 err: &ApplyHunkError,
1616 max_fuzz: usize,
1617 ) -> String {
1618 match err {
1619 ApplyHunkError::NoMatch {
1620 expected_line,
1621 adjusted_line,
1622 offset,
1623 } => {
1624 let expected_preview = preview_expected_lines(hunk, HUNK_PREVIEW_LINES).join("\n");
1625 let file_preview = snippet_around(lines, *adjusted_line, SNIPPET_RADIUS).join("\n");
1626 format!(
1627 "could not find matching context near line {expected_line} (searched around line {adjusted_line} with offset {offset:+} and fuzz up to {max_fuzz}). Expected context preview:\n{expected_preview}\nFile snippet near line {adjusted_line}:\n{file_preview}\nHints: the line numbers may be stale after earlier edits — call File with action=\"read\" to re-check the current contents, ensure the patch matches the file, increase `fuzz`, or regenerate the patch."
1628 )
1629 }
1630 ApplyHunkError::ContextAmbiguous {
1631 expected_line,
1632 candidate_lines,
1633 } => {
1634 let candidates = candidate_lines
1635 .iter()
1636 .map(|line| line.to_string())
1637 .collect::<Vec<_>>()
1638 .join(", ");
1639 format!(
1640 "could not find matching context near line {expected_line}: the hunk's context appears at multiple locations (lines {candidates}), and the line numbers may be stale after earlier edits, so it is not safe to relocate automatically. Hints: call File with action=\"read\" to inspect the candidate locations above, then regenerate the patch with more surrounding context lines that uniquely identify the target block."
1641 )
1642 }
1643 }
1644 }
1645
1646 fn apply_hunks_to_lines(
1647 lines: &mut Vec<String>,
1648 hunks: &[Hunk],
1649 fuzz: usize,
1650 file_label: &str,
1651 ) -> Result<HunkApplyStats, ToolError> {
1652 let mut stats = HunkApplyStats::default();
1653 let mut cumulative_offset: isize = 0;
1654
1655 for (idx, hunk) in hunks.iter().enumerate() {
1656 match apply_hunk(lines, hunk, fuzz, &mut cumulative_offset) {
1657 Ok(outcome) => {
1658 stats.hunks_applied += 1;
1659 if outcome.fuzz_used > 0 {
1660 stats.fuzz_used += outcome.fuzz_used;
1661 stats.hunks_with_fuzz += 1;
1662 }
1663 if outcome.relocated {
1664 stats.hunks_relocated += 1;
1665 }
1666 }
1667 Err(e) => {
1668 let detail = format_hunk_no_match_error(lines, hunk, &e, fuzz);
1669 return Err(ToolError::execution_failed(format!(
1670 "Failed to apply hunk {}/{} for `{}`: {}",
1671 idx + 1,
1672 hunks.len(),
1673 file_label,
1674 detail
1675 )));
1676 }
1677 }
1678 }
1679
1680 Ok(stats)
1681 }
1682
1683 /// Apply a hunk to the file content with fuzzy matching
1684 fn apply_hunk(
1685 lines: &mut Vec<String>,
1686 hunk: &Hunk,
1687 max_fuzz: usize,
1688 cumulative_offset: &mut isize,
1689 ) -> Result<HunkApplyOutcome, ApplyHunkError> {
1690 // Build expected old lines from hunk
1691 let old_lines: Vec<&str> = hunk
1692 .lines
1693 .iter()
1694 .filter_map(|line| match line {
1695 HunkLine::Context(s) | HunkLine::Remove(s) => Some(s.as_str()),
1696 HunkLine::Add(_) => None,
1697 })
1698 .collect();
1699
1700 // Build new lines from hunk
1701 let new_lines: Vec<String> = hunk
1702 .lines
1703 .iter()
1704 .filter_map(|line| match line {
1705 HunkLine::Context(s) | HunkLine::Add(s) => Some(s.clone()),
1706 HunkLine::Remove(_) => None,
1707 })
1708 .collect();
1709
1710 // Try to find the location with fuzzy matching
1711 // Apply cumulative offset from previous hunks, clamping to valid range.
1712 // A pure insertion (`@@ -N,0 +M,K @@`, as `git diff -U0` emits) names
1713 // the line it follows, so it goes *after* line N, not before it. Only a
1714 // header that says `,0` qualifies: an omitted or nonzero old count keeps
1715 // the "before line N" anchor. `new_start` settles the common hand-written
1716 // `@@ -N,0 +N,K @@` ("new text starts at line N"): when it points at
1717 // line N itself rather than after it, the text goes before line N.
1718 let pure_insertion = hunk.old_count == 0 && old_lines.is_empty();
1719 let starts_at_named_line = hunk.new_start > 0
1720 && cumulative_offset
1721 .checked_neg()
1722 .and_then(|back| (hunk.new_start - 1).checked_add_signed(back))
1723 == Some(hunk.old_start.saturating_sub(1));
1724 let base_idx = if pure_insertion && !starts_at_named_line {
1725 hunk.old_start
1726 } else {
1727 hunk.old_start.saturating_sub(1)
1728 };
1729 // Use checked_add_signed to safely handle negative offsets without
1730 // risking isize overflow on adversarial input.
1731 let start_idx = base_idx
1732 .checked_add_signed(*cumulative_offset)
1733 .unwrap_or(0)
1734 .min(lines.len());
1735
1736 for fuzz in 0..=max_fuzz {
1737 // Try at exact position first, then nearby
1738 let search_range = if fuzz == 0 {
1739 vec![start_idx]
1740 } else {
1741 let min = start_idx.saturating_sub(fuzz);
1742 let max = (start_idx + fuzz).min(lines.len());
1743 (min..=max).collect()
1744 };
1745
1746 for pos in search_range {
1747 if matches_at_position(lines, &old_lines, pos) {
1748 // Apply the hunk
1749 let end_pos = pos + old_lines.len();
1750 lines.splice(pos..end_pos, new_lines.clone());
1751
1752 // Update cumulative offset: new lines added minus old lines removed
1753 let delta = new_lines.len() as isize - old_lines.len() as isize;
1754 *cumulative_offset += delta;
1755
1756 return Ok(HunkApplyOutcome {
1757 fuzz_used: fuzz,
1758 relocated: false,
1759 });
1760 }
1761 }
1762 }
1763
1764 // Special case: adding to empty file or new hunk at end
1765 if old_lines.is_empty() && (lines.is_empty() || start_idx >= lines.len()) {
1766 let delta = new_lines.len() as isize;
1767 lines.extend(new_lines);
1768 *cumulative_offset += delta;
1769 return Ok(HunkApplyOutcome {
1770 fuzz_used: 0,
1771 relocated: false,
1772 });
1773 }
1774
1775 // #5003 — positional search failed. The line numbers are probably stale
1776 // because an earlier edit (this patch or a previous one) shifted the file
1777 // and the model regenerated the patch from outdated read_file output.
1778 // If the hunk carries enough anchor lines, look for a unique whole-file
1779 // content match and relocate there; anything that matched within `fuzz`
1780 // of `start_idx` was already tried above, so any unique match found here
1781 // is genuinely relocated. Ambiguous matches are refused (applying to the
1782 // wrong copy of a repeated block would corrupt the file).
1783 let anchor_matches: Vec<usize> = if old_lines.len() >= MIN_ANCHOR_LINES {
1784 (0..=lines.len().saturating_sub(old_lines.len()))
1785 .filter(|&pos| matches_at_position(lines, &old_lines, pos))
1786 .collect()
1787 } else {
1788 Vec::new()
1789 };
1790 match anchor_matches.as_slice() {
1791 [pos] => {
1792 let end_pos = pos + old_lines.len();
1793 lines.splice(*pos..end_pos, new_lines.clone());
1794 let delta = new_lines.len() as isize - old_lines.len() as isize;
1795 *cumulative_offset += delta;
1796 Ok(HunkApplyOutcome {
1797 fuzz_used: 0,
1798 relocated: true,
1799 })
1800 }
1801 [] => Err(ApplyHunkError::NoMatch {
1802 expected_line: hunk.old_start,
1803 adjusted_line: start_idx + 1, // Convert back to 1-indexed
1804 offset: *cumulative_offset,
1805 }),
1806 multiple => Err(ApplyHunkError::ContextAmbiguous {
1807 expected_line: hunk.old_start,
1808 candidate_lines: multiple.iter().map(|&p| p + 1).collect(),
1809 }),
1810 }
1811 }
1812
1813 /// Check if `old_lines` match at the given position
1814 fn matches_at_position(lines: &[String], old_lines: &[&str], pos: usize) -> bool {
1815 if pos + old_lines.len() > lines.len() {
1816 return false;
1817 }
1818
1819 for (i, old_line) in old_lines.iter().enumerate() {
1820 // Normalize whitespace for comparison
1821 let file_line = lines[pos + i].trim_end();
1822 let expected = old_line.trim_end();
1823 if file_line != expected {
1824 return false;
1825 }
1826 }
1827
1828 true
1829 }
1830
1831 // === Unit Tests ===
1832
1833 #[cfg(test)]
1834 mod tests {
1835 use super::*;
1836 use tempfile::tempdir;
1837
1838 /// #6559 D01-06: a rollback that cannot restore a file used to be
1839 /// discarded, so the error read as if nothing had changed. Deleting `x`
1840 /// and creating `x/y.txt` leaves a directory at `x`; when the third write
1841 /// fails, restoring the file `x` over that directory fails too.
1842 #[test]
1843 fn failed_rollback_is_reported_as_a_partly_applied_patch() {
1844 let tmp = tempdir().expect("tempdir");
1845 let root = tmp.path();
1846 fs::write(root.join("x"), "original\n").expect("seed x");
1847 fs::write(root.join("blocker"), "a file, not a directory\n").expect("seed blocker");
1848 let pending = vec![
1849 PendingWrite {
1850 path: root.join("x"),
1851 content: None,
1852 original: Some("original\n".to_string()),
1853 },
1854 PendingWrite {
1855 path: root.join("x").join("y.txt"),
1856 content: Some("new\n".to_string()),
1857 original: None,
1858 },
1859 PendingWrite {
1860 path: root.join("blocker").join("z.txt"),
1861 content: Some("never written\n".to_string()),
1862 original: None,
1863 },
1864 ];
1865
1866 let message = apply_pending_writes(&pending)
1867 .expect_err("the third write cannot create its directory")
1868 .to_string();
1869
1870 assert!(message.contains("partly applied"), "{message}");
1871 assert!(
1872 message.contains(&root.join("x").display().to_string()),
1873 "{message}"
1874 );
1875 }
1876
1877 /// The receipt entry a created/updated file must carry: its size and
1878 /// SHA-256 are those of the bytes now on disk.
1879 fn on_disk_receipt(root: &std::path::Path, path: &str, outcome: &str) -> Value {
1880 let bytes = fs::read(root.join(path)).expect("receipt target exists");
1881 json!({
1882 "path": path,
1883 "outcome": outcome,
1884 "size": bytes.len(),
1885 "sha256": crate::hashing::sha256_hex(&bytes),
1886 })
1887 }
1888
1889 fn parse_patch_result(result: ToolResult) -> PatchResult {
1890 serde_json::from_str(&result.content).expect("patch result json")
1891 }
1892
1893 #[test]
1894 fn test_parse_range() {
1895 assert_eq!(parse_range("10,5").unwrap(), (10, 5));
1896 assert_eq!(parse_range("10").unwrap(), (10, 1));
1897 assert_eq!(parse_range("1,0").unwrap(), (1, 0));
1898 }
1899
1900 #[test]
1901 fn test_parse_unified_diff() {
1902 let patch = r"--- a/test.txt
1903 +++ b/test.txt
1904 @@ -1,3 +1,3 @@
1905 line1
1906 -line2
1907 +modified line2
1908 line3
1909 ";
1910
1911 let hunks = parse_unified_diff(patch).unwrap();
1912 assert_eq!(hunks.len(), 1);
1913 assert_eq!(hunks[0].old_start, 1);
1914 assert_eq!(hunks[0].old_count, 3);
1915 assert_eq!(hunks[0].new_start, 1);
1916 assert_eq!(hunks[0].new_count, 3);
1917 }
1918
1919 /// #6205 — a patch that lands unformatted Rust in an already-clean file is
1920 /// normalized before the write, and the rendered diff shows the normalized
1921 /// text, so the model's next patch context matches the bytes on disk.
1922 #[tokio::test]
1923 async fn patch_normalizes_rust_in_an_already_clean_file() {
1924 let tmp = tempdir().expect("tempdir");
1925 let ctx = ToolContext::new(tmp.path().to_path_buf());
1926 let file = tmp.path().join("clean.rs");
1927 fs::write(&file, "fn main() {\n let x = 1;\n}\n").expect("write");
1928
1929 let patch = "--- a/clean.rs\n+++ b/clean.rs\n@@ -1,3 +1,4 @@\n fn main() {\n let x = 1;\n+ let y=2;\n }\n";
1930 let result = ApplyPatchTool
1931 .execute(json!({"path": "clean.rs", "patch": patch}), &ctx)
1932 .await
1933 .expect("execute");
1934
1935 assert_eq!(
1936 fs::read_to_string(&file).expect("read"),
1937 "fn main() {\n let x = 1;\n let y = 2;\n}\n"
1938 );
1939 let diff = result.metadata.as_ref().expect("metadata")["mutation"]["diff"]
1940 .as_str()
1941 .expect("diff")
1942 .to_string();
1943 assert!(diff.contains("+ let y = 2;"), "{diff}");
1944 assert!(!diff.contains("let y=2;"), "{diff}");
1945 }
1946
1947 /// #6204 — a patch whose result does not parse is refused before any file
1948 /// is written, so a multi-file patch cannot land half-applied.
1949 #[tokio::test]
1950 async fn patch_refuses_a_hunk_that_breaks_rust_syntax() {
1951 let tmp = tempdir().expect("tempdir");
1952 let ctx = ToolContext::new(tmp.path().to_path_buf());
1953 let file = tmp.path().join("main.rs");
1954 let original = "fn main() {\n println!(\"hi\");\n}\n";
1955 fs::write(&file, original).expect("write");
1956
1957 let patch = "--- a/main.rs\n+++ b/main.rs\n@@ -1,3 +1,2 @@\n fn main() {\n println!(\"hi\");\n-}\n";
1958 let error = ApplyPatchTool
1959 .execute(json!({"path": "main.rs", "patch": patch}), &ctx)
1960 .await
1961 .expect_err("a patch that breaks Rust syntax must be refused");
1962
1963 let message = error.to_string();
1964 assert!(message.contains("Rust syntax error at line"), "{message}");
1965 assert_eq!(
1966 fs::read_to_string(&file).expect("read"),
1967 original,
1968 "a refused patch must leave the file untouched"
1969 );
1970 }
1971
1972 #[test]
1973 fn input_schema_exposes_replace_and_deprecated_changes_alias() {
1974 let schema = ApplyPatchTool.input_schema();
1975
1976 assert_eq!(schema["properties"]["replace"]["type"], "array");
1977 assert_eq!(schema["properties"]["changes"]["type"], "array");
1978 assert!(
1979 schema["properties"]["changes"]["description"]
1980 .as_str()
1981 .is_some_and(|description| description.contains("Deprecated"))
1982 );
1983 assert_eq!(
1984 schema["oneOf"],
1985 json!([
1986 { "required": ["patch"] },
1987 { "required": ["replace"] },
1988 { "required": ["changes"] }
1989 ])
1990 );
1991 }
1992
1993 #[test]
1994 fn test_preflight_apply_patch_with_path_override() {
1995 let patch = r"@@ -1,2 +1,2 @@
1996 old
1997 -value
1998 +new-value
1999 ";
2000
2001 let preflight = preflight_apply_patch(&json!({
2002 "path": "src/lib.rs",
2003 "patch": patch
2004 }))
2005 .expect("preflight");
2006
2007 assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
2008 assert_eq!(preflight.files_total, 1);
2009 assert_eq!(preflight.hunks_total, 1);
2010 assert_eq!(preflight.path_override.as_deref(), Some("src/lib.rs"));
2011 }
2012
2013 #[test]
2014 fn test_preflight_apply_patch_multi_file_create_and_delete() {
2015 let patch = r"diff --git a/new.rs b/new.rs
2016 --- /dev/null
2017 +++ b/new.rs
2018 @@ -0,0 +1 @@
2019 +fn added() {}
2020 diff --git a/old.rs b/old.rs
2021 --- a/old.rs
2022 +++ /dev/null
2023 @@ -1 +0,0 @@
2024 -fn old() {}
2025 ";
2026
2027 let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
2028
2029 assert_eq!(preflight.touched_files, vec!["new.rs", "old.rs"]);
2030 assert_eq!(preflight.files_total, 2);
2031 assert_eq!(preflight.hunks_total, 2);
2032 assert_eq!(preflight.creates, vec!["new.rs"]);
2033 assert_eq!(preflight.deletes, vec!["old.rs"]);
2034 }
2035
2036 #[test]
2037 fn test_preflight_apply_patch_timestamp_headers_strip_metadata() {
2038 let patch = "diff --git a/src/lib.rs b/src/lib.rs\n\
2039 --- a/src/lib.rs\t2026-06-26 10:00:00 +0000\n\
2040 +++ b/src/lib.rs\t2026-06-26 10:01:00 +0000\n\
2041 @@ -1,1 +1,1 @@\n\
2042 -old\n\
2043 +new\n";
2044
2045 let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
2046
2047 assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
2048 assert_eq!(preflight.files_total, 1);
2049 assert_eq!(preflight.hunks_total, 1);
2050 }
2051
2052 #[test]
2053 fn test_preflight_apply_patch_ignores_forged_headers_inside_hunk_shape() {
2054 let patch = r"--- a/src/lib.rs
2055 +++ b/src/lib.rs
2056 @@ -1,3 +1,3 @@
2057 line1
2058 --- a/forged.rs
2059 +++ b/forged.rs
2060 line3
2061 ";
2062
2063 let preflight = preflight_apply_patch(&json!({
2064 "path": "src/lib.rs",
2065 "patch": patch
2066 }))
2067 .expect("preflight");
2068
2069 assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
2070 assert_eq!(preflight.header_path_mismatch, None);
2071 }
2072
2073 #[test]
2074 fn test_preflight_apply_patch_replace_list() {
2075 let canonical = preflight_apply_patch(&json!({
2076 "replace": [
2077 { "path": "one.txt", "content": "one" },
2078 { "path": "two.txt", "content": "two" }
2079 ]
2080 }))
2081 .expect("preflight");
2082
2083 let legacy = preflight_apply_patch(&json!({
2084 "changes": [
2085 { "path": "one.txt", "content": "one" },
2086 { "path": "two.txt", "content": "two" }
2087 ]
2088 }))
2089 .expect("legacy preflight");
2090
2091 assert_eq!(canonical.touched_files, vec!["one.txt", "two.txt"]);
2092 assert_eq!(canonical.files_total, 2);
2093 assert_eq!(canonical.hunks_total, 0);
2094 assert_eq!(legacy, canonical);
2095 }
2096
2097 #[test]
2098 fn test_preflight_replace_files_total_counts_entries() {
2099 let preflight = preflight_apply_patch(&json!({
2100 "replace": [
2101 { "path": "same.txt", "content": "one" },
2102 { "path": "same.txt", "content": "two" }
2103 ]
2104 }))
2105 .expect("preflight");
2106
2107 assert_eq!(preflight.touched_files, vec!["same.txt"]);
2108 assert_eq!(preflight.files_total, 2);
2109 }
2110
2111 #[test]
2112 fn test_preflight_patch_files_total_counts_sections() {
2113 let patch = r"diff --git a/same.txt b/same.txt
2114 --- a/same.txt
2115 +++ b/same.txt
2116 @@ -1,1 +1,1 @@
2117 -one
2118 +two
2119 diff --git a/same.txt b/same.txt
2120 --- a/same.txt
2121 +++ b/same.txt
2122 @@ -2,1 +2,1 @@
2123 -three
2124 +four
2125 ";
2126
2127 let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
2128
2129 assert_eq!(preflight.touched_files, vec!["same.txt"]);
2130 assert_eq!(preflight.files_total, 2);
2131 assert_eq!(preflight.hunks_total, 2);
2132 }
2133
2134 #[test]
2135 fn test_apply_hunk_simple() {
2136 let mut lines = vec![
2137 "line1".to_string(),
2138 "line2".to_string(),
2139 "line3".to_string(),
2140 ];
2141
2142 let hunk = Hunk {
2143 old_start: 1,
2144 old_count: 3,
2145 new_start: 1,
2146 new_count: 3,
2147 lines: vec![
2148 HunkLine::Context("line1".to_string()),
2149 HunkLine::Remove("line2".to_string()),
2150 HunkLine::Add("modified".to_string()),
2151 HunkLine::Context("line3".to_string()),
2152 ],
2153 };
2154
2155 let mut offset: isize = 0;
2156 let outcome = apply_hunk(&mut lines, &hunk, 0, &mut offset).unwrap();
2157 assert_eq!(outcome.fuzz_used, 0);
2158 assert!(!outcome.relocated);
2159 assert_eq!(lines, vec!["line1", "modified", "line3"]);
2160 }
2161
2162 #[test]
2163 fn test_apply_hunk_with_fuzz() {
2164 let mut lines = vec![
2165 "line0".to_string(),
2166 "line1".to_string(),
2167 "line2".to_string(),
2168 "line3".to_string(),
2169 ];
2170
2171 // Hunk expects to start at line 1, but content is at line 2
2172 let hunk = Hunk {
2173 old_start: 1, // Wrong position
2174 old_count: 2,
2175 new_start: 1,
2176 new_count: 2,
2177 lines: vec![
2178 HunkLine::Remove("line1".to_string()),
2179 HunkLine::Add("modified".to_string()),
2180 HunkLine::Context("line2".to_string()),
2181 ],
2182 };
2183
2184 let mut offset: isize = 0;
2185 let outcome = apply_hunk(&mut lines, &hunk, 3, &mut offset).unwrap();
2186 assert!(outcome.fuzz_used > 0);
2187 assert!(!outcome.relocated);
2188 assert_eq!(lines, vec!["line0", "modified", "line2", "line3"]);
2189 }
2190
2191 #[test]
2192 fn test_apply_hunk_no_match_returns_error() {
2193 let mut lines = vec!["line1".to_string(), "line2".to_string()];
2194 let hunk = Hunk {
2195 old_start: 5,
2196 old_count: 1,
2197 new_start: 5,
2198 new_count: 1,
2199 lines: vec![
2200 HunkLine::Context("missing".to_string()),
2201 HunkLine::Add("new".to_string()),
2202 ],
2203 };
2204
2205 let mut offset: isize = 0;
2206 let err = apply_hunk(&mut lines, &hunk, 0, &mut offset).unwrap_err();
2207 assert!(matches!(
2208 err,
2209 ApplyHunkError::NoMatch {
2210 expected_line: 5,
2211 ..
2212 }
2213 ));
2214 }
2215
2216 #[tokio::test]
2217 async fn test_apply_patch_tool() {
2218 let tmp = tempdir().expect("tempdir");
2219 let ctx = ToolContext::new(tmp.path().to_path_buf());
2220
2221 // Create a test file
2222 fs::write(tmp.path().join("test.txt"), "line1\nline2\nline3\n").expect("write");
2223
2224 let patch = r"--- a/test.txt
2225 +++ b/test.txt
2226 @@ -1,3 +1,3 @@
2227 line1
2228 -line2
2229 +modified
2230 line3
2231 ";
2232
2233 let tool = ApplyPatchTool;
2234 let result = tool
2235 .execute(json!({"path": "test.txt", "patch": patch}), &ctx)
2236 .await
2237 .expect("execute");
2238
2239 assert!(result.success);
2240 assert_eq!(
2241 result.metadata.as_ref().unwrap()["event"],
2242 "apply_patch.preflight"
2243 );
2244 assert_eq!(
2245 result.metadata.as_ref().unwrap()["touched_files"],
2246 json!(["test.txt"])
2247 );
2248 assert!(
2249 result
2250 .metadata
2251 .as_ref()
2252 .unwrap()
2253 .get("header_path_mismatch")
2254 .is_none()
2255 );
2256 assert!(
2257 result
2258 .metadata
2259 .as_ref()
2260 .unwrap()
2261 .get("path_override")
2262 .is_some()
2263 );
2264 let mutation = &result.metadata.as_ref().unwrap()["mutation"];
2265 assert_eq!(
2266 mutation["files"],
2267 json!([on_disk_receipt(tmp.path(), "test.txt", "updated")])
2268 );
2269 assert!(
2270 mutation["diff"]
2271 .as_str()
2272 .is_some_and(|diff| diff.contains("-line2") && diff.contains("+modified")),
2273 "{mutation}"
2274 );
2275 let patch_result = parse_patch_result(result);
2276 assert_eq!(patch_result.touched_files, vec!["test.txt"]);
2277 assert_eq!(patch_result.hunks_applied, 1);
2278
2279 // Verify the patch was applied
2280 let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
2281 assert!(content.contains("modified"));
2282 assert!(!content.contains("line2"));
2283 // Regression: the file's trailing newline must survive the patch.
2284 assert!(content.ends_with('\n'), "trailing newline was dropped");
2285 }
2286
2287 #[test]
2288 fn reassemble_preserving_newlines_keeps_style() {
2289 let lines = vec!["a".to_string(), "b".to_string()];
2290 // LF with trailing newline.
2291 assert_eq!(reassemble_preserving_newlines(&lines, "x\ny\n"), "a\nb\n");
2292 // LF without trailing newline.
2293 assert_eq!(reassemble_preserving_newlines(&lines, "x\ny"), "a\nb");
2294 // CRLF is preserved (endings and trailing).
2295 assert_eq!(
2296 reassemble_preserving_newlines(&lines, "x\r\ny\r\n"),
2297 "a\r\nb\r\n"
2298 );
2299 // New/empty file gets a conventional trailing newline.
2300 assert_eq!(reassemble_preserving_newlines(&lines, ""), "a\nb\n");
2301 // Empty result stays empty.
2302 assert_eq!(reassemble_preserving_newlines(&[], "x\n"), "");
2303 }
2304
2305 #[tokio::test]
2306 async fn apply_patch_preserves_crlf_line_endings() {
2307 let tmp = tempdir().expect("tempdir");
2308 let ctx = ToolContext::new(tmp.path().to_path_buf());
2309 fs::write(tmp.path().join("crlf.txt"), "line1\r\nline2\r\nline3\r\n").expect("write");
2310 let patch =
2311 "--- a/crlf.txt\n+++ b/crlf.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+modified\n line3\n";
2312 let result = ApplyPatchTool
2313 .execute(json!({"path": "crlf.txt", "patch": patch}), &ctx)
2314 .await
2315 .expect("execute");
2316 assert!(result.success);
2317 let content = fs::read_to_string(tmp.path().join("crlf.txt")).expect("read");
2318 assert!(content.contains("modified"));
2319 // Regression: a CRLF file must not be flipped to LF.
2320 assert!(
2321 content.contains("\r\n"),
2322 "CRLF was flipped to LF: {content:?}"
2323 );
2324 assert!(!content.contains("\n\n"), "spurious bare LF introduced");
2325 assert!(content.ends_with("\r\n"), "trailing CRLF dropped");
2326 }
2327
2328 #[tokio::test]
2329 async fn test_apply_patch_add_lines() {
2330 let tmp = tempdir().expect("tempdir");
2331 let ctx = ToolContext::new(tmp.path().to_path_buf());
2332
2333 fs::write(tmp.path().join("test.txt"), "line1\nline3\n").expect("write");
2334
2335 let patch = r"@@ -1,2 +1,3 @@
2336 line1
2337 +line2
2338 line3
2339 ";
2340
2341 let tool = ApplyPatchTool;
2342 let result = tool
2343 .execute(json!({"path": "test.txt", "patch": patch}), &ctx)
2344 .await
2345 .expect("execute");
2346
2347 assert!(result.success);
2348 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
2349 assert_eq!(
2350 mutation["files"],
2351 json!([on_disk_receipt(tmp.path(), "test.txt", "updated")])
2352 );
2353 assert!(
2354 mutation["diff"]
2355 .as_str()
2356 .is_some_and(|diff| diff.contains("+line2")),
2357 "{mutation}"
2358 );
2359 let patch_result = parse_patch_result(result);
2360 assert_eq!(patch_result.touched_files, vec!["test.txt"]);
2361
2362 let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
2363 assert!(content.contains("line2"));
2364 }
2365
2366 #[tokio::test]
2367 async fn test_apply_patch_create_new_file() {
2368 let tmp = tempdir().expect("tempdir");
2369 let ctx = ToolContext::new(tmp.path().to_path_buf());
2370
2371 let patch = r"@@ -0,0 +1,3 @@
2372 +line1
2373 +line2
2374 +line3
2375 ";
2376
2377 let tool = ApplyPatchTool;
2378 let result = tool
2379 .execute(
2380 json!({"path": "new_file.txt", "patch": patch, "create_if_missing": true}),
2381 &ctx,
2382 )
2383 .await
2384 .expect("execute");
2385
2386 assert!(result.success);
2387 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
2388 assert_eq!(
2389 mutation["files"],
2390 json!([on_disk_receipt(tmp.path(), "new_file.txt", "created")])
2391 );
2392 assert!(
2393 mutation["diff"]
2394 .as_str()
2395 .is_some_and(|diff| diff.contains("+line1")),
2396 "{mutation}"
2397 );
2398 let patch_result = parse_patch_result(result);
2399 assert_eq!(patch_result.touched_files, vec!["new_file.txt"]);
2400 assert!(patch_result.file_summaries.first().unwrap().created);
2401 assert!(tmp.path().join("new_file.txt").exists());
2402 }
2403
2404 #[tokio::test]
2405 async fn test_apply_patch_replace_list() {
2406 let tmp = tempdir().expect("tempdir");
2407 let ctx = ToolContext::new(tmp.path().to_path_buf());
2408
2409 fs::write(tmp.path().join("one.txt"), "old\n").expect("write");
2410
2411 let tool = ApplyPatchTool;
2412 let result = tool
2413 .execute(
2414 json!({
2415 "replace": [
2416 { "path": "one.txt", "content": "new\n" },
2417 { "path": "two.txt", "content": "second\n" }
2418 ]
2419 }),
2420 &ctx,
2421 )
2422 .await
2423 .expect("execute");
2424
2425 assert!(result.success);
2426 let metadata = result.metadata.as_ref().expect("metadata");
2427 assert_eq!(metadata["event"], "apply_patch.preflight");
2428 assert_eq!(metadata["touched_files"], json!(["one.txt", "two.txt"]));
2429 assert_eq!(metadata["files_total"], 2);
2430 assert_eq!(metadata["hunks_total"], 0);
2431 assert!(metadata.get("path_override").is_none());
2432 assert_eq!(
2433 metadata["mutation"]["files"],
2434 json!([
2435 on_disk_receipt(tmp.path(), "one.txt", "updated"),
2436 on_disk_receipt(tmp.path(), "two.txt", "created")
2437 ])
2438 );
2439 let mutation_diff = metadata["mutation"]["diff"]
2440 .as_str()
2441 .expect("mutation diff");
2442 assert!(mutation_diff.contains("diff --git a/one.txt b/one.txt"));
2443 assert!(mutation_diff.contains("diff --git a/two.txt b/two.txt"));
2444 assert!(mutation_diff.contains("--- a/one.txt"), "{mutation_diff}");
2445 assert!(mutation_diff.contains("+++ b/two.txt"), "{mutation_diff}");
2446 let patch_result = parse_patch_result(result);
2447 let mut touched = patch_result.touched_files.clone();
2448 touched.sort();
2449 assert_eq!(touched, vec!["one.txt", "two.txt"]);
2450 assert_eq!(patch_result.hunks_total, 0);
2451 assert_eq!(
2452 fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
2453 "new\n"
2454 );
2455 assert_eq!(
2456 fs::read_to_string(tmp.path().join("two.txt")).unwrap(),
2457 "second\n"
2458 );
2459 }
2460
2461 #[tokio::test]
2462 async fn test_apply_patch_legacy_changes_list() {
2463 let tmp = tempdir().expect("tempdir");
2464 let ctx = ToolContext::new(tmp.path().to_path_buf());
2465 fs::write(tmp.path().join("legacy.txt"), "old\n").expect("write");
2466
2467 let result = ApplyPatchTool
2468 .execute(
2469 json!({
2470 "changes": [
2471 { "path": "legacy.txt", "content": "new\n" }
2472 ]
2473 }),
2474 &ctx,
2475 )
2476 .await
2477 .expect("legacy changes alias should execute");
2478
2479 assert!(result.success);
2480 assert_eq!(
2481 fs::read_to_string(tmp.path().join("legacy.txt")).unwrap(),
2482 "new\n"
2483 );
2484 }
2485
2486 #[tokio::test]
2487 async fn apply_patch_rejects_every_mixed_mode_before_writing() {
2488 let tmp = tempdir().expect("tempdir");
2489 let ctx = ToolContext::new(tmp.path().to_path_buf());
2490 fs::write(tmp.path().join("guard.txt"), "old\n").expect("write");
2491 let patch = "--- a/guard.txt\n+++ b/guard.txt\n@@ -1 +1 @@\n-old\n+patched\n";
2492 let replacement = json!([{
2493 "path": "guard.txt",
2494 "content": "replaced\n"
2495 }]);
2496 let cases = [
2497 (
2498 ["patch", "replace"],
2499 json!({"patch": patch, "replace": replacement.clone()}),
2500 ),
2501 (
2502 ["patch", "changes"],
2503 json!({"patch": patch, "changes": replacement.clone()}),
2504 ),
2505 (
2506 ["replace", "changes"],
2507 json!({
2508 "replace": replacement.clone(),
2509 "changes": replacement.clone()
2510 }),
2511 ),
2512 ];
2513
2514 for (fields, input) in cases {
2515 let err = ApplyPatchTool
2516 .execute(input, &ctx)
2517 .await
2518 .expect_err("mixed modes must be rejected");
2519 let ToolError::InvalidInput { message } = err else {
2520 panic!("mixed modes should be invalid input, got: {err}");
2521 };
2522 assert!(message.contains("simultaneously"), "{message}");
2523 for field in fields {
2524 assert!(message.contains(field), "{message}");
2525 }
2526 assert_eq!(
2527 fs::read_to_string(tmp.path().join("guard.txt")).unwrap(),
2528 "old\n",
2529 "mixed modes must be rejected before the first write"
2530 );
2531 }
2532 }
2533
2534 #[tokio::test]
2535 async fn test_apply_patch_replace_list_rolls_back_on_write_failure() {
2536 let tmp = tempdir().expect("tempdir");
2537 let ctx = ToolContext::new(tmp.path().to_path_buf());
2538
2539 fs::write(tmp.path().join("one.txt"), "old\n").expect("write");
2540 fs::write(tmp.path().join("blocked"), "not a dir\n").expect("write blocker");
2541
2542 let tool = ApplyPatchTool;
2543 let err = tool
2544 .execute(
2545 json!({
2546 "replace": [
2547 { "path": "one.txt", "content": "new\n" },
2548 { "path": "blocked/two.txt", "content": "second\n" }
2549 ]
2550 }),
2551 &ctx,
2552 )
2553 .await
2554 .expect_err("second write should fail");
2555
2556 let message = err.to_string();
2557 assert!(message.contains("blocked"), "{message}");
2558 assert_eq!(
2559 fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
2560 "old\n"
2561 );
2562 assert!(!tmp.path().join("blocked").join("two.txt").exists());
2563 }
2564
2565 #[tokio::test]
2566 async fn test_apply_patch_multi_file_diff() {
2567 let tmp = tempdir().expect("tempdir");
2568 let ctx = ToolContext::new(tmp.path().to_path_buf());
2569
2570 fs::write(tmp.path().join("a.txt"), "line1\nline2\n").expect("write");
2571 fs::write(tmp.path().join("b.txt"), "alpha\nbeta\n").expect("write");
2572
2573 let patch = r"diff --git a/a.txt b/a.txt
2574 --- a/a.txt
2575 +++ b/a.txt
2576 @@ -1,2 +1,2 @@
2577 line1
2578 -line2
2579 +line2-mod
2580 diff --git a/b.txt b/b.txt
2581 --- a/b.txt
2582 +++ b/b.txt
2583 @@ -1,2 +1,3 @@
2584 alpha
2585 +beta2
2586 beta
2587 ";
2588
2589 let tool = ApplyPatchTool;
2590 let result = tool
2591 .execute(json!({"patch": patch}), &ctx)
2592 .await
2593 .expect("execute");
2594
2595 assert!(result.success);
2596 let metadata = result.metadata.as_ref().expect("metadata");
2597 assert_eq!(metadata["event"], "apply_patch.preflight");
2598 assert_eq!(metadata["touched_files"], json!(["a.txt", "b.txt"]));
2599 assert_eq!(metadata["files_total"], 2);
2600 assert_eq!(metadata["hunks_total"], 2);
2601 assert!(metadata.get("path_override").is_none());
2602 let patch_result = parse_patch_result(result);
2603 let mut touched = patch_result.touched_files.clone();
2604 touched.sort();
2605 assert_eq!(touched, vec!["a.txt", "b.txt"]);
2606 assert_eq!(patch_result.files_applied, 2);
2607 let a = fs::read_to_string(tmp.path().join("a.txt")).unwrap();
2608 let b = fs::read_to_string(tmp.path().join("b.txt")).unwrap();
2609 assert!(a.contains("line2-mod"));
2610 assert!(b.contains("beta2"));
2611 }
2612
2613 #[tokio::test]
2614 async fn mutation_receipt_covers_delete_rename_and_multifile_outcomes() {
2615 let tmp = tempdir().expect("tempdir");
2616 let ctx = ToolContext::new(tmp.path().to_path_buf());
2617 fs::write(tmp.path().join("old.txt"), "same\n").expect("old");
2618 fs::write(tmp.path().join("update.txt"), "before\n").expect("update");
2619 fs::write(tmp.path().join("delete.txt"), "gone\n").expect("delete");
2620
2621 let patch = r"diff --git a/old.txt b/old.txt
2622 --- a/old.txt
2623 +++ /dev/null
2624 @@ -1 +0,0 @@
2625 -same
2626 diff --git a/new.txt b/new.txt
2627 --- /dev/null
2628 +++ b/new.txt
2629 @@ -0,0 +1 @@
2630 +same
2631 diff --git a/update.txt b/update.txt
2632 --- a/update.txt
2633 +++ b/update.txt
2634 @@ -1 +1 @@
2635 -before
2636 +after
2637 diff --git a/create.txt b/create.txt
2638 --- /dev/null
2639 +++ b/create.txt
2640 @@ -0,0 +1 @@
2641 +fresh
2642 diff --git a/delete.txt b/delete.txt
2643 --- a/delete.txt
2644 +++ /dev/null
2645 @@ -1 +0,0 @@
2646 -gone
2647 ";
2648
2649 let result = ApplyPatchTool
2650 .execute(json!({"patch": patch}), &ctx)
2651 .await
2652 .expect("execute");
2653 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
2654 assert_eq!(
2655 mutation["files"],
2656 json!([
2657 on_disk_receipt(tmp.path(), "update.txt", "updated"),
2658 on_disk_receipt(tmp.path(), "create.txt", "created"),
2659 // A deleted entry carries neither size nor sha256.
2660 { "path": "delete.txt", "outcome": "deleted" }
2661 ])
2662 );
2663 // The rename carries the destination's written bytes.
2664 let renamed = fs::read(tmp.path().join("new.txt")).expect("renamed bytes");
2665 assert_eq!(
2666 mutation["renames"],
2667 json!([{
2668 "from": "old.txt",
2669 "to": "new.txt",
2670 "size": renamed.len(),
2671 "sha256": crate::hashing::sha256_hex(&renamed),
2672 }])
2673 );
2674 let exact = mutation["diff"].as_str().expect("exact mutation diff");
2675 assert!(exact.contains("rename from old.txt"), "{exact}");
2676 assert!(exact.contains("rename to new.txt"), "{exact}");
2677 assert!(exact.contains("--- a/update.txt"), "{exact}");
2678 assert!(exact.contains("+++ b/create.txt"), "{exact}");
2679 assert!(exact.contains("--- a/delete.txt"), "{exact}");
2680
2681 assert!(!tmp.path().join("old.txt").exists());
2682 assert_eq!(
2683 fs::read_to_string(tmp.path().join("new.txt")).expect("renamed target"),
2684 "same\n"
2685 );
2686 assert_eq!(
2687 fs::read_to_string(tmp.path().join("update.txt")).expect("updated"),
2688 "after\n"
2689 );
2690 assert!(tmp.path().join("create.txt").exists());
2691 assert!(!tmp.path().join("delete.txt").exists());
2692 }
2693
2694 #[tokio::test]
2695 async fn test_apply_patch_requires_headers_without_path() {
2696 let tmp = tempdir().expect("tempdir");
2697 let ctx = ToolContext::new(tmp.path().to_path_buf());
2698 let tool = ApplyPatchTool;
2699
2700 let patch = r"@@ -1,1 +1,1 @@
2701 -old
2702 +new
2703 ";
2704
2705 let err = tool
2706 .execute(json!({"patch": patch}), &ctx)
2707 .await
2708 .unwrap_err();
2709 match err {
2710 ToolError::InvalidInput { message } => {
2711 assert!(message.contains("no file headers"));
2712 assert!(message.contains("Provide `path`"));
2713 }
2714 other => panic!("expected invalid input, got: {other}"),
2715 }
2716 }
2717
2718 #[tokio::test]
2719 async fn test_path_override_rejects_multi_file_diff() {
2720 let tmp = tempdir().expect("tempdir");
2721 let ctx = ToolContext::new(tmp.path().to_path_buf());
2722 let tool = ApplyPatchTool;
2723
2724 let patch = r"diff --git a/a.txt b/a.txt
2725 --- a/a.txt
2726 +++ b/a.txt
2727 @@ -1,1 +1,1 @@
2728 -one
2729 +one-mod
2730 diff --git a/b.txt b/b.txt
2731 --- a/b.txt
2732 +++ b/b.txt
2733 @@ -1,1 +1,1 @@
2734 -two
2735 +two-mod
2736 ";
2737
2738 let err = tool
2739 .execute(json!({"path": "a.txt", "patch": patch}), &ctx)
2740 .await
2741 .unwrap_err();
2742 match err {
2743 ToolError::InvalidInput { message } => {
2744 assert!(message.contains("multiple files"));
2745 assert!(message.contains("a.txt"));
2746 assert!(message.contains("b.txt"));
2747 }
2748 other => panic!("expected invalid input, got: {other}"),
2749 }
2750 }
2751
2752 #[tokio::test]
2753 async fn test_apply_patch_summary_reports_fuzz() {
2754 let tmp = tempdir().expect("tempdir");
2755 let ctx = ToolContext::new(tmp.path().to_path_buf());
2756 let tool = ApplyPatchTool;
2757
2758 fs::write(tmp.path().join("test.txt"), "line0\nline1\nline2\nline3\n").expect("write");
2759
2760 let patch = r"@@ -1,2 +1,2 @@
2761 -line1
2762 +modified
2763 line2
2764 ";
2765
2766 let result = tool
2767 .execute(json!({"path": "test.txt", "patch": patch, "fuzz": 3}), &ctx)
2768 .await
2769 .expect("execute");
2770 assert!(result.success);
2771 let patch_result = parse_patch_result(result);
2772 assert_eq!(patch_result.hunks_with_fuzz, 1);
2773 assert!(patch_result.fuzz_used > 0);
2774 assert!(patch_result.message.contains("Fuzz used"));
2775 let summary = patch_result.file_summaries.first().unwrap();
2776 assert_eq!(summary.hunks_with_fuzz, 1);
2777 }
2778
2779 #[tokio::test]
2780 async fn test_path_override_header_mismatch_note() {
2781 let tmp = tempdir().expect("tempdir");
2782 let ctx = ToolContext::new(tmp.path().to_path_buf());
2783 let tool = ApplyPatchTool;
2784
2785 fs::write(tmp.path().join("override.txt"), "old\n").expect("write");
2786
2787 let patch = r"--- a/other.txt
2788 +++ b/other.txt
2789 @@ -1,1 +1,1 @@
2790 -old
2791 +new
2792 ";
2793
2794 let result = tool
2795 .execute(json!({"path": "override.txt", "patch": patch}), &ctx)
2796 .await
2797 .expect("execute");
2798 let metadata = result.metadata.as_ref().expect("metadata");
2799 assert!(
2800 metadata["header_path_mismatch"]
2801 .as_str()
2802 .unwrap()
2803 .contains("headers reference `other.txt`")
2804 );
2805 let patch_result = parse_patch_result(result);
2806 assert!(
2807 patch_result
2808 .message
2809 .contains("headers reference `other.txt`")
2810 );
2811 assert!(
2812 patch_result
2813 .message
2814 .contains("path` overrides to `override.txt`")
2815 );
2816 }
2817
2818 #[test]
2819 fn test_apply_patch_tool_properties() {
2820 let tool = ApplyPatchTool;
2821 assert_eq!(tool.name(), "apply_patch");
2822 assert!(!tool.is_read_only());
2823 assert!(tool.is_sandboxable());
2824 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
2825 }
2826
2827 #[test]
2828 fn test_multi_hunk_offset_tracking() {
2829 // File with 6 lines
2830 let mut lines: Vec<String> = vec![
2831 "line1".to_string(),
2832 "line2".to_string(),
2833 "line3".to_string(),
2834 "line4".to_string(),
2835 "line5".to_string(),
2836 "line6".to_string(),
2837 ];
2838
2839 // Hunk 1: Add 2 lines after line1 (offset becomes +2)
2840 let hunk1 = Hunk {
2841 old_start: 1,
2842 old_count: 2,
2843 new_start: 1,
2844 new_count: 4,
2845 lines: vec![
2846 HunkLine::Context("line1".to_string()),
2847 HunkLine::Add("new_a".to_string()),
2848 HunkLine::Add("new_b".to_string()),
2849 HunkLine::Context("line2".to_string()),
2850 ],
2851 };
2852
2853 // Hunk 2: Modify line5 (originally at position 5, now at position 7 due to +2 offset)
2854 let hunk2 = Hunk {
2855 old_start: 5, // Original position in the diff
2856 old_count: 1,
2857 new_start: 7,
2858 new_count: 1,
2859 lines: vec![
2860 HunkLine::Remove("line5".to_string()),
2861 HunkLine::Add("modified5".to_string()),
2862 ],
2863 };
2864
2865 let mut offset: isize = 0;
2866
2867 // Apply first hunk
2868 let outcome1 = apply_hunk(&mut lines, &hunk1, 3, &mut offset).unwrap();
2869 assert_eq!(outcome1.fuzz_used, 0);
2870 assert!(!outcome1.relocated);
2871 assert_eq!(offset, 2); // Added 2 lines (4 new - 2 old)
2872 assert_eq!(
2873 lines,
2874 vec![
2875 "line1", "new_a", "new_b", "line2", "line3", "line4", "line5", "line6"
2876 ]
2877 );
2878
2879 // Apply second hunk - this would fail without offset tracking!
2880 let outcome2 = apply_hunk(&mut lines, &hunk2, 3, &mut offset).unwrap();
2881 assert_eq!(outcome2.fuzz_used, 0);
2882 assert!(!outcome2.relocated);
2883 assert!(lines.contains(&"modified5".to_string()));
2884 assert!(!lines.contains(&"line5".to_string()));
2885 }
2886
2887 #[test]
2888 fn test_apply_hunk_relocates_to_unique_context_match() {
2889 // #5003 - stale line numbers (hunk says line 1, content is at line 20).
2890 let mut lines: Vec<String> = (0..25).map(|i| format!("line{i}")).collect();
2891 let hunk = Hunk {
2892 old_start: 1, // stale line number
2893 old_count: 5,
2894 new_start: 1,
2895 new_count: 5,
2896 lines: vec![
2897 HunkLine::Context("line19".to_string()),
2898 HunkLine::Context("line20".to_string()),
2899 HunkLine::Remove("line21".to_string()),
2900 HunkLine::Add("line21-modified".to_string()),
2901 HunkLine::Context("line22".to_string()),
2902 HunkLine::Context("line23".to_string()),
2903 ],
2904 };
2905
2906 let mut offset: isize = 0;
2907 let outcome = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap();
2908 assert!(
2909 outcome.relocated,
2910 "expected relocation for stale line numbers"
2911 );
2912 assert_eq!(outcome.fuzz_used, 0);
2913 assert_eq!(lines[21], "line21-modified");
2914 assert!(!lines.contains(&"line21".to_string()));
2915 }
2916
2917 #[test]
2918 fn test_apply_hunk_ambiguous_context_reports_candidates() {
2919 // Two identical block-a..d blocks: anchor is not unique -> ContextAmbiguous.
2920 let mut lines: Vec<String> = [
2921 "header0", "header1", "header2", "block-a", "block-b", "block-c", "block-d", "middle",
2922 "block-a", "block-b", "block-c", "block-d", "footer",
2923 ]
2924 .iter()
2925 .map(|s| s.to_string())
2926 .collect();
2927
2928 let hunk = Hunk {
2929 old_start: 1,
2930 old_count: 4,
2931 new_start: 1,
2932 new_count: 4,
2933 lines: vec![
2934 HunkLine::Remove("block-a".to_string()),
2935 HunkLine::Remove("block-b".to_string()),
2936 HunkLine::Remove("block-c".to_string()),
2937 HunkLine::Remove("block-d".to_string()),
2938 HunkLine::Add("block-a-modified".to_string()),
2939 HunkLine::Add("block-b".to_string()),
2940 HunkLine::Add("block-c".to_string()),
2941 HunkLine::Add("block-d".to_string()),
2942 ],
2943 };
2944
2945 let mut offset: isize = 0;
2946 let err = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap_err();
2947 match err {
2948 ApplyHunkError::ContextAmbiguous {
2949 expected_line,
2950 candidate_lines,
2951 } => {
2952 assert_eq!(expected_line, 1);
2953 // The two duplicate blocks start at 1-based lines 4 and 9.
2954 assert_eq!(candidate_lines, vec![4, 9]);
2955 }
2956 other => panic!("expected ContextAmbiguous, got: {other:?}"),
2957 }
2958 }
2959
2960 #[test]
2961 fn test_apply_hunk_short_anchor_not_relocated() {
2962 // Anchor too short (1 line < MIN_ANCHOR_LINES): keep NoMatch behavior.
2963 let mut lines: Vec<String> = ["zero", "one", "two", "three", "four"]
2964 .iter()
2965 .map(|s| s.to_string())
2966 .collect();
2967 let hunk = Hunk {
2968 old_start: 1,
2969 old_count: 1,
2970 new_start: 1,
2971 new_count: 1,
2972 lines: vec![
2973 HunkLine::Remove("four".to_string()),
2974 HunkLine::Add("four-modified".to_string()),
2975 ],
2976 };
2977
2978 let mut offset: isize = 0;
2979 let err = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap_err();
2980 assert!(
2981 matches!(err, ApplyHunkError::NoMatch { .. }),
2982 "short anchors must not be relocated"
2983 );
2984 }
2985
2986 #[tokio::test]
2987 async fn test_apply_patch_relocates_stale_line_numbers() {
2988 // #5003 - integration: hunk claims line 1 but content is at line 21.
2989 let tmp = tempdir().expect("tempdir");
2990 let ctx = ToolContext::new(tmp.path().to_path_buf());
2991 let tool = ApplyPatchTool;
2992
2993 let content = (0..30)
2994 .map(|i| format!("line{i}"))
2995 .collect::<Vec<_>>()
2996 .join("\n")
2997 + "\n";
2998 fs::write(tmp.path().join("stale.txt"), &content).expect("write");
2999
3000 let patch = r"@@ -1,5 +1,5 @@
3001 line19
3002 line20
3003 -line21
3004 +line21-modified
3005 line22
3006 line23
3007 ";
3008
3009 let result = tool
3010 .execute(
3011 json!({"path": "stale.txt", "patch": patch, "fuzz": 1}),
3012 &ctx,
3013 )
3014 .await
3015 .expect("execute");
3016 assert!(result.success);
3017 let patch_result = parse_patch_result(result);
3018 assert_eq!(patch_result.hunks_relocated, 1);
3019 assert!(
3020 patch_result.message.contains("stale line numbers"),
3021 "message: {}",
3022 patch_result.message
3023 );
3024 let summary = patch_result.file_summaries.first().unwrap();
3025 assert_eq!(summary.hunks_relocated, 1);
3026
3027 let edited = fs::read_to_string(tmp.path().join("stale.txt")).expect("read");
3028 assert!(edited.contains("line21-modified"));
3029 assert!(!edited.contains("\nline21\n"));
3030 }
3031
3032 #[tokio::test]
3033 async fn test_apply_patch_ambiguous_context_reports_candidates() {
3034 // #5003 - integration: duplicate context blocks, ambiguous relocation.
3035 let tmp = tempdir().expect("tempdir");
3036 let ctx = ToolContext::new(tmp.path().to_path_buf());
3037 let tool = ApplyPatchTool;
3038
3039 let content = "header0\nheader1\nheader2\nblock-a\nblock-b\nblock-c\nblock-d\nmiddle\nblock-a\nblock-b\nblock-c\nblock-d\nfooter\n";
3040 fs::write(tmp.path().join("dup.txt"), content).expect("write");
3041
3042 let patch = r"@@ -1,4 +1,4 @@
3043 -block-a
3044 -block-b
3045 -block-c
3046 -block-d
3047 +block-a-modified
3048 +block-b
3049 +block-c
3050 +block-d
3051 ";
3052
3053 let err = tool
3054 .execute(json!({"path": "dup.txt", "patch": patch, "fuzz": 1}), &ctx)
3055 .await
3056 .unwrap_err();
3057 let message = err.to_string();
3058 assert!(
3059 message.contains("multiple locations"),
3060 "expected ambiguity error, got: {message}"
3061 );
3062 // The two duplicate blocks start at 1-based lines 4 and 9.
3063 assert!(
3064 message.contains("lines 4, 9"),
3065 "expected candidate lines, got: {message}"
3066 );
3067 let unchanged = fs::read_to_string(tmp.path().join("dup.txt")).expect("read");
3068 assert_eq!(
3069 unchanged, content,
3070 "ambiguous hunk must not modify the file"
3071 );
3072 }
3073 #[tokio::test]
3074 async fn test_apply_patch_relocates_after_crlf_chinese_edit_shift() {
3075 // #5003 - issue scenario: a C-style file with CRLF line endings and
3076 // Chinese comments. A first edit shifts line numbers; a second patch
3077 // still uses stale line numbers but its context is unique in the
3078 // file, so apply_patch relocates instead of failing.
3079 let tmp = tempdir().expect("tempdir");
3080 let ctx = ToolContext::new(tmp.path().to_path_buf());
3081 let tool = ApplyPatchTool;
3082
3083 let mut lines = vec!["/* 扭矩控制模块 */".to_string()];
3084 for i in 0..60 {
3085 lines.push(format!("int cfg_{i} = {i}; // 配置项 {i}"));
3086 }
3087 let content = lines.join("\r\n") + "\r\n";
3088 fs::write(tmp.path().join("app_foc.c"), &content).expect("write");
3089
3090 // First edit: insert 8 lines right after cfg_9 (old line 11). This
3091 // shifts every later line number by +8.
3092 let patch1 = r"@@ -11,1 +11,9 @@
3093 int cfg_9 = 9; // 配置项 9
3094 +int new_a = 100; // 新增配置 A
3095 +int new_b = 101; // 新增配置 B
3096 +int new_c = 102; // 新增配置 C
3097 +int new_d = 103; // 新增配置 D
3098 +int new_e = 104; // 新增配置 E
3099 +int new_f = 105; // 新增配置 F
3100 +int new_g = 106; // 新增配置 G
3101 +int new_h = 107; // 新增配置 H
3102 ";
3103 let r1 = tool
3104 .execute(
3105 json!({"path": "app_foc.c", "patch": patch1, "fuzz": 0}),
3106 &ctx,
3107 )
3108 .await
3109 .expect("first patch");
3110 assert!(r1.success, "first patch should apply: {}", r1.content);
3111
3112 // Second edit: claims line 31 (stale - cfg_21 now), but the replaced
3113 // block cfg_30..cfg_34 actually lives at lines 40-44. Positional
3114 // search with fuzz=1 fails; the unique whole-file anchor relocates.
3115 let patch2 = r"@@ -31,5 +31,5 @@
3116 int cfg_30 = 30; // 配置项 30
3117 int cfg_31 = 31; // 配置项 31
3118 -int cfg_32 = 32; // 配置项 32
3119 +int cfg_32 = 3200; // 配置项 32 已修改
3120 int cfg_33 = 33; // 配置项 33
3121 int cfg_34 = 34; // 配置项 34
3122 ";
3123 let r2 = tool
3124 .execute(
3125 json!({"path": "app_foc.c", "patch": patch2, "fuzz": 1}),
3126 &ctx,
3127 )
3128 .await
3129 .expect("second patch");
3130 assert!(r2.success, "second patch should relocate: {}", r2.content);
3131 let pr2 = parse_patch_result(r2);
3132 assert_eq!(pr2.hunks_relocated, 1, "second patch must be relocated");
3133
3134 let edited = fs::read_to_string(tmp.path().join("app_foc.c")).expect("read");
3135 assert!(
3136 edited.contains("int cfg_32 = 3200;"),
3137 "replacement must land"
3138 );
3139 assert!(
3140 edited.contains("int cfg_33 = 33;"),
3141 "context after the edit must stay intact"
3142 );
3143 assert!(
3144 edited.contains("int new_h = 107;"),
3145 "first edit must survive"
3146 );
3147 }
3148
3149 // === Content-hash patch guard (#3979) ===
3150 //
3151 // The guard is a whole-patch precondition on the patch target, not a
3152 // per-file check — see `verify_patch_expected_hash`. These pin that it
3153 // fires before anything is written.
3154
3155 const GUARD_PATCH: &str = "--- a/test.txt
3156 +++ b/test.txt
3157 @@ -1,3 +1,3 @@
3158 line1
3159 -line2
3160 +modified
3161 line3
3162 ";
3163
3164 #[tokio::test]
3165 async fn patch_with_matching_expected_hash_proceeds() {
3166 let tmp = tempdir().expect("tempdir");
3167 let ctx = ToolContext::new(tmp.path().to_path_buf());
3168 let body = "line1\nline2\nline3\n";
3169 fs::write(tmp.path().join("test.txt"), body).expect("write");
3170
3171 ApplyPatchTool
3172 .execute(
3173 json!({
3174 "path": "test.txt",
3175 "patch": GUARD_PATCH,
3176 "expected_hash": content_hash(body.as_bytes()),
3177 }),
3178 &ctx,
3179 )
3180 .await
3181 .expect("matching hash must not block the patch");
3182
3183 let updated = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
3184 assert!(updated.contains("modified"), "{updated}");
3185 }
3186
3187 #[tokio::test]
3188 async fn patch_with_stale_expected_hash_rejects_without_writing() {
3189 let tmp = tempdir().expect("tempdir");
3190 let ctx = ToolContext::new(tmp.path().to_path_buf());
3191 let body = "line1\nline2\nline3\n";
3192 fs::write(tmp.path().join("test.txt"), body).expect("write");
3193
3194 let err = ApplyPatchTool
3195 .execute(
3196 json!({
3197 "path": "test.txt",
3198 "patch": GUARD_PATCH,
3199 "expected_hash": content_hash(b"a different file entirely\n"),
3200 }),
3201 &ctx,
3202 )
3203 .await
3204 .expect_err("stale hash must reject");
3205
3206 let message = err.to_string();
3207 assert!(message.contains("changed since it was read"), "{message}");
3208 assert!(message.contains("nothing was written"), "{message}");
3209 assert_eq!(
3210 fs::read_to_string(tmp.path().join("test.txt")).expect("read"),
3211 body,
3212 "a rejected patch must not modify the file"
3213 );
3214 }
3215
3216 #[tokio::test]
3217 async fn patch_without_expected_hash_is_unchanged() {
3218 let tmp = tempdir().expect("tempdir");
3219 let ctx = ToolContext::new(tmp.path().to_path_buf());
3220 fs::write(tmp.path().join("test.txt"), "line1\nline2\nline3\n").expect("write");
3221
3222 ApplyPatchTool
3223 .execute(json!({ "path": "test.txt", "patch": GUARD_PATCH }), &ctx)
3224 .await
3225 .expect("absent expected_hash keeps the pre-#3979 behavior");
3226
3227 let updated = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
3228 assert!(updated.contains("modified"), "{updated}");
3229 }
3230
3231 #[tokio::test]
3232 async fn multi_file_patch_guards_the_first_file_and_writes_nothing_on_mismatch() {
3233 let tmp = tempdir().expect("tempdir");
3234 let ctx = ToolContext::new(tmp.path().to_path_buf());
3235 fs::write(tmp.path().join("one.txt"), "one\n").expect("write");
3236 fs::write(tmp.path().join("two.txt"), "two\n").expect("write");
3237
3238 let patch = "diff --git a/one.txt b/one.txt
3239 --- a/one.txt
3240 +++ b/one.txt
3241 @@ -1 +1 @@
3242 -one
3243 +ONE
3244 diff --git a/two.txt b/two.txt
3245 --- a/two.txt
3246 +++ b/two.txt
3247 @@ -1 +1 @@
3248 -two
3249 +TWO
3250 ";
3251
3252 let err = ApplyPatchTool
3253 .execute(
3254 json!({
3255 "patch": patch,
3256 "expected_hash": content_hash(b"stale\n"),
3257 }),
3258 &ctx,
3259 )
3260 .await
3261 .expect_err("stale hash on the first file must reject the whole patch");
3262 assert!(err.to_string().contains("one.txt"), "{err}");
3263
3264 // Transactional: the unguarded second file must not have been
3265 // written either.
3266 assert_eq!(
3267 fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
3268 "one\n"
3269 );
3270 assert_eq!(
3271 fs::read_to_string(tmp.path().join("two.txt")).unwrap(),
3272 "two\n"
3273 );
3274 }
3275
3276 #[tokio::test]
3277 async fn patch_expected_hash_on_a_missing_target_fails_closed() {
3278 let tmp = tempdir().expect("tempdir");
3279 let ctx = ToolContext::new(tmp.path().to_path_buf());
3280
3281 let err = ApplyPatchTool
3282 .execute(
3283 json!({
3284 "path": "absent.txt",
3285 "patch": GUARD_PATCH,
3286 "create_if_missing": true,
3287 "expected_hash": content_hash(b"anything"),
3288 }),
3289 &ctx,
3290 )
3291 .await
3292 .expect_err("guarded patch of a missing file must fail closed");
3293
3294 assert!(err.to_string().contains("does not exist"), "{err}");
3295 assert!(!tmp.path().join("absent.txt").exists());
3296 }
3297
3298 /// B7: two sections for the same file both land; the second applies on
3299 /// top of the first instead of re-reading the disk and dropping it.
3300 #[tokio::test]
3301 async fn two_sections_for_one_file_apply_both() {
3302 let tmp = tempdir().expect("tempdir");
3303 let ctx = ToolContext::new(tmp.path().to_path_buf());
3304 fs::write(tmp.path().join("f.txt"), "a1\na2\na3\nmid\nb1\nb2\nb3\n").expect("write");
3305 let patch = r"--- a/f.txt
3306 +++ b/f.txt
3307 @@ -1,3 +1,3 @@
3308 a1
3309 -a2
3310 +A2
3311 a3
3312 --- a/f.txt
3313 +++ b/f.txt
3314 @@ -5,3 +5,3 @@
3315 b1
3316 -b2
3317 +B2
3318 b3
3319 ";
3320 let result = ApplyPatchTool
3321 .execute(json!({"patch": patch}), &ctx)
3322 .await
3323 .expect("execute");
3324 assert!(result.success, "{}", result.content);
3325 assert_eq!(
3326 fs::read_to_string(tmp.path().join("f.txt")).expect("read"),
3327 "a1\nA2\na3\nmid\nb1\nB2\nb3\n"
3328 );
3329 let summary = parse_patch_result(result);
3330 assert_eq!(summary.files_total, 1, "one file, two sections");
3331 assert_eq!(summary.files_applied, 1);
3332 assert_eq!(summary.hunks_applied, 2);
3333 }
3334
3335 #[tokio::test]
3336 async fn creation_patch_on_an_existing_file_is_refused() {
3337 let tmp = tempdir().expect("tempdir");
3338 let ctx = ToolContext::new(tmp.path().to_path_buf());
3339 fs::write(tmp.path().join("foo.rs"), "fn keep() {}\n").expect("write");
3340 let patch = "--- /dev/null\n+++ b/foo.rs\n@@ -0,0 +1,2 @@\n+fn a() {}\n+fn b() {}\n";
3341
3342 let err = ApplyPatchTool
3343 .execute(json!({"patch": patch}), &ctx)
3344 .await
3345 .expect_err("a /dev/null creation must not land on an existing file");
3346
3347 assert!(err.to_string().contains("already exists"), "{err}");
3348 assert_eq!(
3349 fs::read_to_string(tmp.path().join("foo.rs")).expect("read"),
3350 "fn keep() {}\n"
3351 );
3352 }
3353
3354 #[tokio::test]
3355 async fn pure_insertion_hunk_lands_after_the_named_line() {
3356 let tmp = tempdir().expect("tempdir");
3357 let ctx = ToolContext::new(tmp.path().to_path_buf());
3358 fs::write(tmp.path().join("f.txt"), "l1\nl2\nl3\nl4\n").expect("write");
3359 // `git diff -U0` form: insert after line 3.
3360 let patch = "@@ -3,0 +4,1 @@\n+inserted\n";
3361
3362 ApplyPatchTool
3363 .execute(json!({"path": "f.txt", "patch": patch}), &ctx)
3364 .await
3365 .expect("execute");
3366
3367 assert_eq!(
3368 fs::read_to_string(tmp.path().join("f.txt")).expect("read"),
3369 "l1\nl2\nl3\ninserted\nl4\n"
3370 );
3371 }
3372
3373 #[tokio::test]
3374 async fn delete_section_covering_part_of_a_file_is_refused() {
3375 let tmp = tempdir().expect("tempdir");
3376 let ctx = ToolContext::new(tmp.path().to_path_buf());
3377 let original = "1\n2\n3\n4\n5\n";
3378 fs::write(tmp.path().join("big.txt"), original).expect("write");
3379 let patch = "--- a/big.txt\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-1\n-2\n-3\n";
3380
3381 let err = ApplyPatchTool
3382 .execute(json!({"patch": patch}), &ctx)
3383 .await
3384 .expect_err("a partial delete must not remove the file");
3385
3386 assert!(err.to_string().contains("2 line(s)"), "{err}");
3387 assert_eq!(
3388 fs::read_to_string(tmp.path().join("big.txt")).expect("still there"),
3389 original
3390 );
3391 }
3392
3393 #[tokio::test]
3394 async fn creation_patch_with_a_path_override_on_an_existing_file_is_refused() {
3395 let tmp = tempdir().expect("tempdir");
3396 let ctx = ToolContext::new(tmp.path().to_path_buf());
3397 fs::write(tmp.path().join("foo.rs"), "fn keep() {}\n").expect("write");
3398 let patch = "--- /dev/null\n+++ b/foo.rs\n@@ -0,0 +1,2 @@\n+fn a() {}\n+fn b() {}\n";
3399
3400 let err = ApplyPatchTool
3401 .execute(json!({"path": "foo.rs", "patch": patch}), &ctx)
3402 .await
3403 .expect_err("`path` must not turn a /dev/null creation into a prepend");
3404
3405 assert!(err.to_string().contains("already exists"), "{err}");
3406 assert_eq!(
3407 fs::read_to_string(tmp.path().join("foo.rs")).expect("read"),
3408 "fn keep() {}\n"
3409 );
3410 }
3411
3412 #[tokio::test]
3413 async fn creation_section_after_an_edit_of_the_same_file_is_refused() {
3414 let tmp = tempdir().expect("tempdir");
3415 let ctx = ToolContext::new(tmp.path().to_path_buf());
3416 fs::write(tmp.path().join("foo.txt"), "a\nb\n").expect("write");
3417 let patch = "--- a/foo.txt\n+++ b/foo.txt\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n\
3418 --- /dev/null\n+++ b/foo.txt\n@@ -0,0 +1,1 @@\n+new\n";
3419
3420 let err = ApplyPatchTool
3421 .execute(json!({"patch": patch}), &ctx)
3422 .await
3423 .expect_err("a second, creating section must not prepend to the edited file");
3424
3425 assert!(err.to_string().contains("already exists"), "{err}");
3426 assert_eq!(
3427 fs::read_to_string(tmp.path().join("foo.txt")).expect("read"),
3428 "a\nb\n"
3429 );
3430 }
3431
3432 #[tokio::test]
3433 async fn insertion_hunks_without_a_zero_old_count_keep_the_before_anchor() {
3434 let tmp = tempdir().expect("tempdir");
3435 let ctx = ToolContext::new(tmp.path().to_path_buf());
3436 for (patch, expected) in [
3437 // Omitted old count (defaults to 1): before line 2, as before.
3438 ("@@ -2 +2,1 @@\n+x\n", "l1\nx\nl2\nl3\n"),
3439 // Hand-written "new text starts at line 1": at the top.
3440 ("@@ -1,0 +1,1 @@\n+x\n", "x\nl1\nl2\nl3\n"),
3441 // `git diff -U0` form: after line 1.
3442 ("@@ -1,0 +2,1 @@\n+x\n", "l1\nx\nl2\nl3\n"),
3443 ] {
3444 fs::write(tmp.path().join("f.txt"), "l1\nl2\nl3\n").expect("write");
3445 ApplyPatchTool
3446 .execute(json!({"path": "f.txt", "patch": patch}), &ctx)
3447 .await
3448 .expect("execute");
3449 assert_eq!(
3450 fs::read_to_string(tmp.path().join("f.txt")).expect("read"),
3451 expected,
3452 "{patch:?}"
3453 );
3454 }
3455 }
3456 }
3457
3457 lines RUST