返回 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::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::{PATCH_PARAMS, PATH_ALIASES, apply_param_aliases};
17 use super::spec::{
18 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
19 lsp_diagnostics_for_paths, optional_bool, optional_str, optional_u64,
20 };
21
22 /// Maximum lines of context for fuzzy matching (increased for better tolerance)
23 const MAX_FUZZ: usize = 50;
24 /// Default fuzz when the caller does not specify one. Matches the tool schema's
25 /// documented default. Previously the default was `MAX_FUZZ` (50), so a hunk
26 /// with no `fuzz` argument could silently apply up to 50 lines from its stated
27 /// position — landing in the wrong region of a file with repeated blocks.
28 const DEFAULT_FUZZ: usize = 3;
29
30 /// Minimum number of expected lines (context + removed) required before a hunk
31 /// may be relocated to a unique whole-file context match when its stated line
32 /// numbers are stale (#5003). Short anchors — a lone `}` or a 1-2 line snippet
33 /// — appear in too many places to relocate safely.
34 const MIN_ANCHOR_LINES: usize = 4;
35
36 /// Reassemble hunk-processed logical lines back into file content, preserving
37 /// the base file's line-ending style (CRLF vs LF) and its trailing-newline
38 /// state. Processing round-trips through `str::lines()`, which strips both the
39 /// trailing `\n` and any `\r`; naively `join("\n")`-ing would silently delete
40 /// the file's final newline and flip a CRLF file to LF on every patch.
41 fn reassemble_preserving_newlines(lines: &[String], base_content: &str) -> String {
42 if lines.is_empty() {
43 return String::new();
44 }
45 let terminator = if base_content.contains("\r\n") {
46 "\r\n"
47 } else {
48 "\n"
49 };
50 // A newly created file (empty base) gets a conventional trailing newline;
51 // an existing file preserves whether it had one.
52 let trailing = base_content.is_empty() || base_content.ends_with('\n');
53 let mut out = lines.join(terminator);
54 if trailing {
55 out.push_str(terminator);
56 }
57 out
58 }
59 /// Limit how much context we print in error messages.
60 const HUNK_PREVIEW_LINES: usize = 4;
61 const SNIPPET_RADIUS: usize = 2;
62 const FILE_LIST_LIMIT: usize = 6;
63
64 // === Types ===
65
66 /// Result of applying a patch
67 #[derive(Debug, Clone, Serialize, Deserialize)]
68 pub struct PatchResult {
69 pub success: bool,
70 pub files_applied: usize,
71 pub files_total: usize,
72 pub hunks_applied: usize,
73 pub hunks_total: usize,
74 pub fuzz_used: usize,
75 #[serde(default)]
76 pub hunks_with_fuzz: usize,
77 #[serde(default, skip_serializing_if = "is_zero")]
78 pub hunks_relocated: usize,
79 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub touched_files: Vec<String>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub file_summaries: Vec<FileSummary>,
83 pub message: String,
84 }
85
86 /// Per-file summary for patch application output.
87 #[derive(Debug, Clone, Serialize, Deserialize)]
88 pub struct FileSummary {
89 pub path: String,
90 pub hunks: usize,
91 pub hunks_applied: usize,
92 pub fuzz_used: usize,
93 pub hunks_with_fuzz: usize,
94 #[serde(default, skip_serializing_if = "is_zero")]
95 pub hunks_relocated: usize,
96 pub created: bool,
97 pub deleted: bool,
98 }
99
100 /// No-mutation summary of what an `apply_patch` input intends to touch.
101 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102 pub struct ApplyPatchPreflight {
103 pub touched_files: Vec<String>,
104 pub files_total: usize,
105 pub hunks_total: usize,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub creates: Vec<String>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub deletes: Vec<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub path_override: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub header_path_mismatch: Option<String>,
114 }
115
116 /// A single hunk in a unified diff
117 #[derive(Debug, Clone)]
118 pub struct Hunk {
119 pub old_start: usize,
120 #[allow(dead_code)]
121 pub old_count: usize,
122 #[allow(dead_code)]
123 pub new_start: usize,
124 #[allow(dead_code)]
125 pub new_count: usize,
126 pub lines: Vec<HunkLine>,
127 }
128
129 /// A line in a hunk
130 #[derive(Debug, Clone)]
131 pub enum HunkLine {
132 Context(String),
133 Add(String),
134 Remove(String),
135 }
136
137 /// Tool for applying unified diff patches to files
138 pub struct ApplyPatchTool;
139
140 #[derive(Debug, Clone)]
141 struct FilePatch {
142 path: String,
143 hunks: Vec<Hunk>,
144 delete_after: bool,
145 create_if_missing: bool,
146 }
147
148 #[derive(Debug, Clone)]
149 struct PendingWrite {
150 path: PathBuf,
151 content: Option<String>,
152 original: Option<String>,
153 }
154
155 #[derive(Debug, Default, Clone, Copy)]
156 struct PatchStats {
157 files_applied: usize,
158 files_total: usize,
159 hunks_applied: usize,
160 hunks_total: usize,
161 fuzz_used: usize,
162 hunks_with_fuzz: usize,
163 hunks_relocated: usize,
164 }
165
166 fn is_zero(value: &usize) -> bool {
167 *value == 0
168 }
169
170 #[derive(Debug, Default, Clone)]
171 struct PatchStatsExt {
172 stats: PatchStats,
173 touched_files: Vec<String>,
174 file_summaries: Vec<FileSummary>,
175 header_path_mismatch: Option<String>,
176 }
177
178 #[derive(Debug, Default, Clone)]
179 struct PatchShape {
180 has_hunks: bool,
181 header_files: Vec<String>,
182 }
183
184 impl PatchShape {
185 fn file_count(&self) -> usize {
186 self.header_files.len()
187 }
188 }
189
190 #[derive(Debug, Default, Clone, Copy)]
191 struct HunkApplyStats {
192 hunks_applied: usize,
193 fuzz_used: usize,
194 hunks_with_fuzz: usize,
195 hunks_relocated: usize,
196 }
197
198 /// Result of applying a single hunk: how much positional fuzz was used, and
199 /// whether the hunk had to be relocated to a unique whole-file context match
200 /// (stale line numbers after earlier edits, #5003).
201 #[derive(Debug, Default, Clone, Copy)]
202 struct HunkApplyOutcome {
203 fuzz_used: usize,
204 relocated: bool,
205 }
206
207 #[derive(Debug, Clone)]
208 enum ApplyPatchPreflightKind {
209 Replace,
210 PathOverride { path: String, hunks: Vec<Hunk> },
211 FilePatches(Vec<FilePatch>),
212 }
213
214 /// Canonicalized `apply_patch` payload mode.
215 ///
216 /// `replace` is the preferred spelling for full-file replacements. `changes`
217 /// remains a compatibility alias for callers that learned the original tool
218 /// schema before the clearer name was introduced.
219 #[derive(Debug, Clone, Copy)]
220 pub(crate) enum NormalizedApplyPatchInput<'a> {
221 Patch(&'a str),
222 Replacement {
223 entries: &'a [Value],
224 source_field: &'static str,
225 },
226 }
227
228 /// Validate mutual exclusivity and normalize the legacy `changes` alias.
229 ///
230 /// This is the single parser used by execution, preflight, policy, approval,
231 /// and UI consumers so every surface agrees on the accepted input contract.
232 pub(crate) fn normalize_apply_patch_input(
233 input: &Value,
234 ) -> Result<NormalizedApplyPatchInput<'_>, ToolError> {
235 let provided: Vec<&'static str> = ["patch", "replace", "changes"]
236 .into_iter()
237 .filter(|field| input.get(*field).is_some())
238 .collect();
239
240 if provided.len() > 1 {
241 let fields = provided
242 .iter()
243 .map(|field| format!("`{field}`"))
244 .collect::<Vec<_>>()
245 .join(", ");
246 return Err(ToolError::invalid_input(format!(
247 "Cannot use {fields} simultaneously. Choose exactly one of `patch`, `replace`, or the deprecated `changes` alias."
248 )));
249 }
250
251 let Some(field) = provided.first().copied() else {
252 return Err(ToolError::missing_field(
253 "patch, replace, or deprecated changes",
254 ));
255 };
256
257 if field == "patch" {
258 let patch = input
259 .get(field)
260 .and_then(Value::as_str)
261 .ok_or_else(|| ToolError::invalid_input("`patch` must be a string"))?;
262 return Ok(NormalizedApplyPatchInput::Patch(patch));
263 }
264
265 let entries = input.get(field).and_then(Value::as_array).ok_or_else(|| {
266 ToolError::invalid_input(format!(
267 "`{field}` must be an array of objects like {{path, content}}"
268 ))
269 })?;
270 if entries.is_empty() {
271 return Err(ToolError::invalid_input(format!(
272 "`{field}` cannot be empty"
273 )));
274 }
275
276 Ok(NormalizedApplyPatchInput::Replacement {
277 entries,
278 source_field: field,
279 })
280 }
281
282 #[derive(Debug, Clone)]
283 struct ApplyPatchPreflightPlan {
284 summary: ApplyPatchPreflight,
285 kind: ApplyPatchPreflightKind,
286 }
287
288 // === Errors ===
289
290 #[derive(Debug, Error)]
291 enum ApplyHunkError {
292 #[error(
293 "Failed to find matching location for hunk (expected at line {expected_line}, adjusted to {adjusted_line} with offset {offset:+})"
294 )]
295 NoMatch {
296 expected_line: usize,
297 adjusted_line: usize,
298 offset: isize,
299 },
300 #[error(
301 "Hunk context is ambiguous: matches at multiple locations {candidate_lines:?}, expected around line {expected_line}"
302 )]
303 ContextAmbiguous {
304 expected_line: usize,
305 candidate_lines: Vec<usize>,
306 },
307 }
308
309 #[async_trait]
310 impl ToolSpec for ApplyPatchTool {
311 fn name(&self) -> &'static str {
312 "apply_patch"
313 }
314
315 fn model_visible(&self) -> bool {
316 false
317 }
318
319 fn description(&self) -> &'static str {
320 "Apply a unified-diff patch (multi-hunk, multi-file). Use this instead of `git apply`, `patch`, or repeated `edit_file` calls in `Bash` — single transactional change with fuzzy matching and a rendered diff."
321 }
322
323 fn input_schema(&self) -> Value {
324 json!({
325 "type": "object",
326 "properties": {
327 "path": {
328 "type": "string",
329 "description": "Path to the file to patch (relative to workspace)"
330 },
331 "patch": {
332 "type": "string",
333 "description": "Unified diff patch content"
334 },
335 "replace": {
336 "type": "array",
337 "description": "Optional full file replacements (path + content).",
338 "items": {
339 "type": "object",
340 "properties": {
341 "path": { "type": "string" },
342 "content": { "type": "string" }
343 },
344 "required": ["path", "content"]
345 }
346 },
347 "changes": {
348 "type": "array",
349 "description": "Deprecated compatibility alias for `replace` (full file replacements by path + content).",
350 "items": {
351 "type": "object",
352 "properties": {
353 "path": { "type": "string" },
354 "content": { "type": "string" }
355 },
356 "required": ["path", "content"]
357 }
358 },
359 "fuzz": {
360 "type": "integer",
361 "description": "Maximum fuzz factor for fuzzy matching (default: 3)"
362 },
363 "create_if_missing": {
364 "type": "boolean",
365 "description": "Create the file if it doesn't exist (for new file patches)"
366 }
367 },
368 "oneOf": [
369 { "required": ["patch"] },
370 { "required": ["replace"] },
371 { "required": ["changes"] }
372 ]
373 })
374 }
375
376 fn capabilities(&self) -> Vec<ToolCapability> {
377 vec![
378 ToolCapability::WritesFiles,
379 ToolCapability::Sandboxable,
380 ToolCapability::RequiresApproval,
381 ]
382 }
383
384 fn approval_requirement(&self) -> ApprovalRequirement {
385 ApprovalRequirement::Suggest
386 }
387
388 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
389 let mut input = input;
390 apply_param_aliases(&mut input, PATH_ALIASES, "File patch")?;
391 PATCH_PARAMS.reject_unknown(&input)?;
392 let input = input;
393
394 let fuzz = optional_u64(&input, "fuzz", DEFAULT_FUZZ as u64)?.min(MAX_FUZZ as u64);
395 let fuzz = usize::try_from(fuzz).unwrap_or(DEFAULT_FUZZ);
396 let normalized = normalize_apply_patch_input(&input)?;
397 let create_if_missing = optional_bool(&input, "create_if_missing", false)?;
398 let preflight = preflight_apply_patch_plan(&input, normalized)?;
399
400 if let NormalizedApplyPatchInput::Replacement {
401 entries,
402 source_field,
403 } = normalized
404 {
405 let (pending, stats) =
406 build_pending_writes_from_replace(entries, source_field, context)?;
407 apply_pending_writes(&pending)?;
408 // Resolve absolute paths for LSP diagnostics query.
409 let abs_paths: Vec<PathBuf> = pending.iter().map(|p| p.path.clone()).collect();
410 let diag_block = lsp_diagnostics_for_paths(context, &abs_paths).await;
411 let result = PatchResult {
412 success: true,
413 files_applied: stats.stats.files_applied,
414 files_total: stats.stats.files_total,
415 hunks_applied: stats.stats.hunks_applied,
416 hunks_total: stats.stats.hunks_total,
417 fuzz_used: stats.stats.fuzz_used,
418 hunks_with_fuzz: stats.stats.hunks_with_fuzz,
419 hunks_relocated: stats.stats.hunks_relocated,
420 touched_files: stats.touched_files.clone(),
421 file_summaries: stats.file_summaries.clone(),
422 message: build_summary_message(&stats),
423 };
424 let mut tool_result = ToolResult::json(&result)
425 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
426 tool_result = tool_result.with_metadata(apply_patch_result_metadata(
427 &preflight.summary,
428 &pending,
429 &stats,
430 ));
431 if !diag_block.is_empty() {
432 tool_result.content.push('\n');
433 tool_result.content.push_str(&diag_block);
434 }
435 return Ok(tool_result);
436 }
437
438 let file_patches = match preflight.kind {
439 ApplyPatchPreflightKind::Replace => {
440 unreachable!("replace input returned before patch execution")
441 }
442 ApplyPatchPreflightKind::PathOverride { path, hunks } => vec![FilePatch {
443 path,
444 hunks,
445 delete_after: false,
446 create_if_missing,
447 }],
448 ApplyPatchPreflightKind::FilePatches(file_patches) => file_patches,
449 };
450
451 let (pending, mut stats) = build_pending_writes_from_patches(file_patches, context, fuzz)?;
452 stats.header_path_mismatch = preflight.summary.header_path_mismatch.clone();
453 apply_pending_writes(&pending)?;
454 // Resolve absolute paths for LSP diagnostics query.
455 let abs_paths: Vec<PathBuf> = pending
456 .iter()
457 .filter(|p| p.content.is_some()) // skip deleted files
458 .map(|p| p.path.clone())
459 .collect();
460 let diag_block = lsp_diagnostics_for_paths(context, &abs_paths).await;
461 let result = PatchResult {
462 success: true,
463 files_applied: stats.stats.files_applied,
464 files_total: stats.stats.files_total,
465 hunks_applied: stats.stats.hunks_applied,
466 hunks_total: stats.stats.hunks_total,
467 fuzz_used: stats.stats.fuzz_used,
468 hunks_with_fuzz: stats.stats.hunks_with_fuzz,
469 hunks_relocated: stats.stats.hunks_relocated,
470 touched_files: stats.touched_files.clone(),
471 file_summaries: stats.file_summaries.clone(),
472 message: build_summary_message(&stats),
473 };
474 let mut tool_result =
475 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
476 tool_result = tool_result.with_metadata(apply_patch_result_metadata(
477 &preflight.summary,
478 &pending,
479 &stats,
480 ));
481 if !diag_block.is_empty() {
482 tool_result.content.push('\n');
483 tool_result.content.push_str(&diag_block);
484 }
485 Ok(tool_result)
486 }
487 }
488
489 /// Parse `apply_patch` input into a reusable, no-mutation preflight summary.
490 ///
491 /// This deliberately stops before workspace resolution or file reads. It is
492 /// suitable for policy checks, audit logs, diagnostics hooks, and future undo
493 /// planning that must know the target files before mutation.
494 pub fn preflight_apply_patch(input: &Value) -> Result<ApplyPatchPreflight, ToolError> {
495 let normalized = normalize_apply_patch_input(input)?;
496 Ok(preflight_apply_patch_plan(input, normalized)?.summary)
497 }
498
499 fn preflight_apply_patch_plan(
500 input: &Value,
501 normalized: NormalizedApplyPatchInput<'_>,
502 ) -> Result<ApplyPatchPreflightPlan, ToolError> {
503 let create_if_missing = optional_bool(input, "create_if_missing", false)?;
504
505 if let NormalizedApplyPatchInput::Replacement {
506 entries,
507 source_field,
508 } = normalized
509 {
510 return Ok(ApplyPatchPreflightPlan {
511 summary: preflight_replace(entries, source_field)?,
512 kind: ApplyPatchPreflightKind::Replace,
513 });
514 }
515
516 let NormalizedApplyPatchInput::Patch(patch_text) = normalized else {
517 unreachable!("replacement input returned before patch parsing")
518 };
519 let path_override = optional_str(input, "path")?;
520 let patch_shape = inspect_patch_shape(patch_text);
521 validate_patch_shape(&patch_shape, path_override)?;
522 let header_path_mismatch =
523 path_override.and_then(|path| diff_header_mismatch(path, &patch_shape));
524
525 if let Some(path) = path_override {
526 let hunks = parse_unified_diff(patch_text)?;
527 if hunks.is_empty() {
528 return Err(ToolError::invalid_input(
529 "Patch did not contain any hunks (`@@ ... @@`). Provide a unified diff hunk.",
530 ));
531 }
532 return Ok(ApplyPatchPreflightPlan {
533 summary: ApplyPatchPreflight {
534 touched_files: vec![path.to_string()],
535 files_total: 1,
536 hunks_total: hunks.len(),
537 creates: if create_if_missing {
538 vec![path.to_string()]
539 } else {
540 Vec::new()
541 },
542 deletes: Vec::new(),
543 path_override: Some(path.to_string()),
544 header_path_mismatch,
545 },
546 kind: ApplyPatchPreflightKind::PathOverride {
547 path: path.to_string(),
548 hunks,
549 },
550 });
551 }
552
553 let file_patches = parse_unified_diff_files(patch_text, create_if_missing)?;
554 if file_patches.is_empty() {
555 return Err(ToolError::invalid_input(
556 "No valid file patches found. Ensure the patch includes `---`/`+++` headers or provide `path`.",
557 ));
558 }
559
560 let mut touched_files = Vec::new();
561 let mut creates = Vec::new();
562 let mut deletes = Vec::new();
563 let mut hunks_total = 0;
564 for file_patch in &file_patches {
565 if file_patch.hunks.is_empty() {
566 return Err(ToolError::invalid_input(format!(
567 "Patch section for `{}` has no hunks (`@@ ... @@`).",
568 file_patch.path
569 )));
570 }
571 push_unique(&mut touched_files, file_patch.path.clone());
572 hunks_total += file_patch.hunks.len();
573 if file_patch.create_if_missing && !file_patch.delete_after {
574 push_unique(&mut creates, file_patch.path.clone());
575 }
576 if file_patch.delete_after {
577 push_unique(&mut deletes, file_patch.path.clone());
578 }
579 }
580
581 Ok(ApplyPatchPreflightPlan {
582 summary: ApplyPatchPreflight {
583 files_total: file_patches.len(),
584 touched_files,
585 hunks_total,
586 creates,
587 deletes,
588 path_override: None,
589 header_path_mismatch,
590 },
591 kind: ApplyPatchPreflightKind::FilePatches(file_patches),
592 })
593 }
594
595 fn preflight_replace(
596 changes: &[Value],
597 source_field: &str,
598 ) -> Result<ApplyPatchPreflight, ToolError> {
599 let mut touched_files = Vec::new();
600 for change in changes {
601 let path = change
602 .get("path")
603 .and_then(Value::as_str)
604 .ok_or_else(|| ToolError::missing_field(format!("{source_field}[].path")))?;
605 let _content = change
606 .get("content")
607 .and_then(Value::as_str)
608 .ok_or_else(|| ToolError::missing_field(format!("{source_field}[].content")))?;
609 push_unique(&mut touched_files, path.to_string());
610 }
611
612 Ok(ApplyPatchPreflight {
613 files_total: changes.len(),
614 touched_files,
615 hunks_total: 0,
616 creates: Vec::new(),
617 deletes: Vec::new(),
618 path_override: None,
619 header_path_mismatch: None,
620 })
621 }
622
623 fn apply_patch_result_metadata(
624 preflight: &ApplyPatchPreflight,
625 pending: &[PendingWrite],
626 stats: &PatchStatsExt,
627 ) -> Value {
628 let mut metadata =
629 serde_json::to_value(preflight).expect("ApplyPatchPreflight should serialize");
630 if let Some(object) = metadata.as_object_mut() {
631 object.insert("event".to_string(), json!("apply_patch.preflight"));
632 object.insert(
633 "mutation".to_string(),
634 build_mutation_metadata(pending, &stats.file_summaries),
635 );
636 }
637 metadata
638 }
639
640 /// Preserve the exact applied before/after diff independently from approval
641 /// presentation. The TUI consumes this success-only metadata for its calm
642 /// File receipt; the normal model-facing result remains compact JSON.
643 fn build_mutation_metadata(pending: &[PendingWrite], summaries: &[FileSummary]) -> Value {
644 let mut matched = HashSet::new();
645 let mut renames = Vec::new();
646
647 for (delete_index, (deleted, delete_summary)) in pending.iter().zip(summaries).enumerate() {
648 if !delete_summary.deleted || matched.contains(&delete_index) {
649 continue;
650 }
651 let Some(old_content) = deleted.original.as_deref() else {
652 continue;
653 };
654 let Some((create_index, (_, create_summary))) = pending
655 .iter()
656 .zip(summaries)
657 .enumerate()
658 .find(|(index, (created, summary))| {
659 !matched.contains(index)
660 && summary.created
661 && created.content.as_deref() == Some(old_content)
662 })
663 else {
664 continue;
665 };
666 matched.insert(delete_index);
667 matched.insert(create_index);
668 renames.push(json!({
669 "from": delete_summary.path,
670 "to": create_summary.path,
671 }));
672 }
673
674 let mut files = Vec::new();
675 for (index, summary) in summaries.iter().enumerate() {
676 if matched.contains(&index) {
677 continue;
678 }
679 let outcome = if summary.created {
680 "created"
681 } else if summary.deleted {
682 "deleted"
683 } else {
684 "updated"
685 };
686 files.push(json!({ "path": summary.path, "outcome": outcome }));
687 }
688
689 let mut diff_parts = Vec::new();
690 for rename in &renames {
691 let from = rename["from"].as_str().unwrap_or("<file>");
692 let to = rename["to"].as_str().unwrap_or("<file>");
693 diff_parts.push(format!(
694 "diff --git a/{from} b/{to}\nsimilarity index 100%\nrename from {from}\nrename to {to}\n"
695 ));
696 }
697 for (index, (write, summary)) in pending.iter().zip(summaries).enumerate() {
698 if matched.contains(&index) {
699 continue;
700 }
701 let old = write.original.as_deref().unwrap_or("");
702 let new = write.content.as_deref().unwrap_or("");
703 let diff = make_unified_diff(&summary.path, old, new);
704 if !diff.is_empty() {
705 diff_parts.push(format!(
706 "diff --git a/{path} b/{path}\n{diff}",
707 path = summary.path
708 ));
709 }
710 }
711
712 json!({
713 "diff": diff_parts.join("\n"),
714 "files": files,
715 "renames": renames,
716 })
717 }
718
719 /// Parse a unified diff into hunks
720 fn parse_unified_diff(patch: &str) -> Result<Vec<Hunk>, ToolError> {
721 let mut hunks = Vec::new();
722 let mut lines = patch.lines().peekable();
723
724 // Skip header lines (---, +++ etc)
725 while let Some(line) = lines.peek() {
726 if line.starts_with("@@") {
727 break;
728 }
729 lines.next();
730 }
731
732 // Parse hunks
733 while let Some(line) = lines.next() {
734 if line.starts_with("@@") {
735 let hunk = parse_hunk_header(line, &mut lines)?;
736 hunks.push(hunk);
737 }
738 }
739
740 Ok(hunks)
741 }
742
743 fn parse_unified_diff_files(
744 patch: &str,
745 create_if_missing: bool,
746 ) -> Result<Vec<FilePatch>, ToolError> {
747 let mut files = Vec::new();
748 let mut lines = patch.lines().peekable();
749 let mut current: Option<FilePatch> = None;
750 let mut old_path: Option<String> = None;
751
752 while let Some(line) = lines.next() {
753 if line.starts_with("diff --git ") {
754 if let Some(file) = current.take() {
755 files.push(file);
756 }
757 old_path = None;
758 continue;
759 }
760
761 if let Some(stripped) = line.strip_prefix("--- ") {
762 old_path = Some(stripped.trim().to_string());
763 continue;
764 }
765
766 if let Some(stripped) = line.strip_prefix("+++ ") {
767 let new_path = Some(stripped.trim().to_string());
768 let (path, delete_after, create_flag) =
769 resolve_diff_paths(old_path.as_deref(), new_path.as_deref(), create_if_missing)?;
770 old_path = None;
771 if let Some(file) = current.take() {
772 files.push(file);
773 }
774 current = Some(FilePatch {
775 path,
776 hunks: Vec::new(),
777 delete_after,
778 create_if_missing: create_flag,
779 });
780 continue;
781 }
782
783 if line.starts_with("@@") {
784 let Some(file) = current.as_mut() else {
785 if let Some(path) = old_path.as_deref() {
786 return Err(ToolError::invalid_input(format!(
787 "Patch hunk encountered after `--- {path}` but before a matching `+++` header. Each file section must include both headers."
788 )));
789 }
790 return Err(ToolError::invalid_input(
791 "Patch hunk encountered before any file header. Add `---`/`+++` headers or provide `path`.",
792 ));
793 };
794 let hunk = parse_hunk_header(line, &mut lines)?;
795 file.hunks.push(hunk);
796 }
797 }
798
799 if let Some(file) = current {
800 files.push(file);
801 }
802
803 Ok(files)
804 }
805
806 fn resolve_diff_paths(
807 old_path: Option<&str>,
808 new_path: Option<&str>,
809 create_if_missing: bool,
810 ) -> Result<(String, bool, bool), ToolError> {
811 let old_norm = old_path.and_then(normalize_diff_path);
812 let new_norm = new_path.and_then(normalize_diff_path);
813 let delete_after = new_norm.is_none();
814 let create_flag = create_if_missing || old_norm.is_none();
815 let path = new_norm
816 .or(old_norm)
817 .ok_or_else(|| ToolError::invalid_input("Patch is missing both old and new file paths"))?;
818 Ok((path, delete_after, create_flag))
819 }
820
821 fn normalize_diff_path(raw: &str) -> Option<String> {
822 let raw = raw.split_once('\t').map_or(raw, |(path, _timestamp)| path);
823 let raw = raw.trim();
824 if raw.is_empty() {
825 return None;
826 }
827 if raw == "/dev/null" || raw == "dev/null" {
828 return None;
829 }
830 let raw = raw
831 .strip_prefix("a/")
832 .or_else(|| raw.strip_prefix("b/"))
833 .unwrap_or(raw);
834 Some(raw.to_string())
835 }
836
837 /// Parse a hunk header and its content
838 fn parse_hunk_header<'a, I>(
839 header: &str,
840 lines: &mut std::iter::Peekable<I>,
841 ) -> Result<Hunk, ToolError>
842 where
843 I: Iterator<Item = &'a str>,
844 {
845 // Parse @@ -old_start,old_count +new_start,new_count @@
846 let parts: Vec<&str> = header.split_whitespace().collect();
847 if parts.len() < 3 {
848 return Err(ToolError::invalid_input(format!(
849 "Invalid hunk header: {header}. Expected numeric unified-diff form `@@ -old_start,old_count +new_start,new_count @@` (example: `@@ -12,3 +12,5 @@`)."
850 )));
851 }
852
853 let old_range = parts[1].trim_start_matches('-');
854 let new_range = parts[2].trim_start_matches('+');
855
856 let (old_start, old_count) = parse_range(old_range)?;
857 let (new_start, new_count) = parse_range(new_range)?;
858
859 // Parse hunk lines
860 let mut hunk_lines = Vec::new();
861 let expected_lines = old_count.max(new_count) + old_count.min(new_count);
862
863 for _ in 0..expected_lines * 2 {
864 // Allow for more lines than expected
865 match lines.peek() {
866 Some(line) if line.starts_with("@@") => break,
867 Some(line) if line.starts_with('-') => {
868 hunk_lines.push(HunkLine::Remove(line[1..].to_string()));
869 lines.next();
870 }
871 Some(line) if line.starts_with('+') => {
872 hunk_lines.push(HunkLine::Add(line[1..].to_string()));
873 lines.next();
874 }
875 Some(line) if line.starts_with(' ') || line.is_empty() => {
876 let content = if line.is_empty() { "" } else { &line[1..] };
877 hunk_lines.push(HunkLine::Context(content.to_string()));
878 lines.next();
879 }
880 Some(line)
881 if line.starts_with("diff ")
882 || line.starts_with("--- ")
883 || line.starts_with("+++ ") =>
884 {
885 // Start of a new file patch - don't consume, let outer loop handle it
886 break;
887 }
888 Some(line) if !line.starts_with('\\') => {
889 // Treat as context line without leading space
890 hunk_lines.push(HunkLine::Context((*line).to_string()));
891 lines.next();
892 }
893 Some(_) => {
894 lines.next(); // Skip "\ No newline at end of file" etc
895 }
896 None => break,
897 }
898 }
899
900 Ok(Hunk {
901 old_start,
902 old_count,
903 new_start,
904 new_count,
905 lines: hunk_lines,
906 })
907 }
908
909 /// Parse a range like "10,5" or "10" into (start, count)
910 fn parse_range(range: &str) -> Result<(usize, usize), ToolError> {
911 let parts: Vec<&str> = range.split(',').collect();
912 let start = parts[0].parse::<usize>().map_err(|_| {
913 ToolError::invalid_input(format!(
914 "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`.",
915 parts[0]
916 ))
917 })?;
918 let count = if parts.len() > 1 {
919 parts[1].parse::<usize>().map_err(|_| {
920 ToolError::invalid_input(format!(
921 "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`.",
922 parts[1]
923 ))
924 })?
925 } else {
926 1
927 };
928 Ok((start, count))
929 }
930
931 fn inspect_patch_shape(patch: &str) -> PatchShape {
932 let mut shape = PatchShape::default();
933 let mut seen = HashSet::new();
934 let mut old_path: Option<String> = None;
935 let mut hunk_old_remaining = 0usize;
936 let mut hunk_new_remaining = 0usize;
937
938 for line in patch.lines() {
939 if line.starts_with("@@") {
940 shape.has_hunks = true;
941 if let Some((old_count, new_count)) = hunk_line_counts_for_shape(line) {
942 hunk_old_remaining = old_count;
943 hunk_new_remaining = new_count;
944 }
945 continue;
946 }
947
948 if hunk_old_remaining > 0 || hunk_new_remaining > 0 {
949 advance_hunk_shape_counts(line, &mut hunk_old_remaining, &mut hunk_new_remaining);
950 continue;
951 }
952
953 if let Some(stripped) = line.strip_prefix("--- ") {
954 old_path = normalize_diff_path(stripped);
955 continue;
956 }
957
958 if let Some(stripped) = line.strip_prefix("+++ ") {
959 let new_path = normalize_diff_path(stripped);
960 let resolved = new_path.or(old_path.clone());
961 if let Some(path) = resolved
962 && seen.insert(path.clone())
963 {
964 shape.header_files.push(path);
965 }
966 old_path = None;
967 }
968 }
969
970 shape
971 }
972
973 fn hunk_line_counts_for_shape(header: &str) -> Option<(usize, usize)> {
974 let parts: Vec<&str> = header.split_whitespace().collect();
975 if parts.len() < 3 {
976 return None;
977 }
978 let (_, old_count) = parse_range(parts[1].trim_start_matches('-')).ok()?;
979 let (_, new_count) = parse_range(parts[2].trim_start_matches('+')).ok()?;
980 Some((old_count, new_count))
981 }
982
983 fn advance_hunk_shape_counts(line: &str, old_remaining: &mut usize, new_remaining: &mut usize) {
984 if line.starts_with('\\') {
985 return;
986 }
987 if line.starts_with('+') {
988 *new_remaining = new_remaining.saturating_sub(1);
989 } else if line.starts_with('-') {
990 *old_remaining = old_remaining.saturating_sub(1);
991 } else {
992 *old_remaining = old_remaining.saturating_sub(1);
993 *new_remaining = new_remaining.saturating_sub(1);
994 }
995 }
996
997 fn validate_patch_shape(shape: &PatchShape, path_override: Option<&str>) -> Result<(), ToolError> {
998 if !shape.has_hunks {
999 return Err(ToolError::invalid_input(
1000 "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 @@`).",
1001 ));
1002 }
1003
1004 match path_override {
1005 Some(_) if shape.file_count() > 1 => Err(ToolError::invalid_input(format!(
1006 "Patch references multiple files ({}) but `path` was provided. Remove `path` to apply a multi-file patch, or provide a single-file patch.",
1007 format_file_list(&shape.header_files),
1008 ))),
1009 None if shape.file_count() == 0 => Err(ToolError::invalid_input(
1010 "Patch contains hunks but no file headers (`---`/`+++`). Provide `path` or add headers.",
1011 )),
1012 _ => Ok(()),
1013 }
1014 }
1015
1016 fn diff_header_mismatch(path_override: &str, shape: &PatchShape) -> Option<String> {
1017 if shape.file_count() != 1 {
1018 return None;
1019 }
1020 let header_path = &shape.header_files[0];
1021 let override_norm = normalize_diff_path(path_override).unwrap_or_else(|| path_override.into());
1022 if &override_norm == header_path {
1023 None
1024 } else {
1025 Some(format!(
1026 "Note: patch headers reference `{header_path}` but `path` overrides to `{override_norm}`."
1027 ))
1028 }
1029 }
1030
1031 fn build_summary_message(stats: &PatchStatsExt) -> String {
1032 let mut parts = Vec::new();
1033 if stats.stats.hunks_total > 0 {
1034 parts.push(format!(
1035 "Applied {}/{} hunks across {} file(s).",
1036 stats.stats.hunks_applied, stats.stats.hunks_total, stats.stats.files_applied
1037 ));
1038 } else {
1039 parts.push(format!(
1040 "Applied {} file change(s).",
1041 stats.stats.files_applied
1042 ));
1043 }
1044
1045 if !stats.touched_files.is_empty() {
1046 parts.push(format!(
1047 "Files: {}.",
1048 format_file_list(&stats.touched_files)
1049 ));
1050 }
1051
1052 if stats.stats.fuzz_used > 0 {
1053 parts.push(format!(
1054 "Fuzz used on {} hunk(s) (total fuzz: {}).",
1055 stats.stats.hunks_with_fuzz, stats.stats.fuzz_used
1056 ));
1057 }
1058
1059 if stats.stats.hunks_relocated > 0 {
1060 parts.push(format!(
1061 "{} hunk(s) applied with stale line numbers (auto-relocated to unique context).",
1062 stats.stats.hunks_relocated
1063 ));
1064 }
1065
1066 if let Some(note) = stats.header_path_mismatch.as_deref() {
1067 parts.push(note.to_string());
1068 }
1069
1070 parts.join(" ")
1071 }
1072
1073 fn format_file_list(files: &[String]) -> String {
1074 if files.is_empty() {
1075 return "<none>".to_string();
1076 }
1077 let mut shown: Vec<String> = files.iter().take(FILE_LIST_LIMIT).cloned().collect();
1078 let remaining = files.len().saturating_sub(shown.len());
1079 if remaining > 0 {
1080 shown.push(format!("... (+{remaining} more)"));
1081 }
1082 shown.join(", ")
1083 }
1084
1085 fn push_unique(target: &mut Vec<String>, value: String) {
1086 if !target.iter().any(|existing| existing == &value) {
1087 target.push(value);
1088 }
1089 }
1090
1091 fn build_pending_writes_from_replace(
1092 changes: &[Value],
1093 source_field: &str,
1094 context: &ToolContext,
1095 ) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
1096 let mut pending = Vec::new();
1097 let mut stats = PatchStatsExt::default();
1098 for change in changes {
1099 let path = change
1100 .get("path")
1101 .and_then(Value::as_str)
1102 .ok_or_else(|| ToolError::missing_field(format!("{source_field}[].path")))?;
1103 let content = change
1104 .get("content")
1105 .and_then(Value::as_str)
1106 .ok_or_else(|| ToolError::missing_field(format!("{source_field}[].content")))?;
1107
1108 let resolved = context.resolve_path(path)?;
1109 let original = if resolved.exists() {
1110 Some(read_file_content(&resolved)?)
1111 } else {
1112 None
1113 };
1114 let created = original.is_none();
1115
1116 pending.push(PendingWrite {
1117 path: resolved,
1118 content: Some(content.to_string()),
1119 original,
1120 });
1121
1122 stats.stats.files_total += 1;
1123 stats.stats.files_applied += 1;
1124 push_unique(&mut stats.touched_files, path.to_string());
1125 stats.file_summaries.push(FileSummary {
1126 path: path.to_string(),
1127 hunks: 0,
1128 hunks_applied: 0,
1129 fuzz_used: 0,
1130 hunks_with_fuzz: 0,
1131 hunks_relocated: 0,
1132 created,
1133 deleted: false,
1134 });
1135 }
1136
1137 Ok((pending, stats))
1138 }
1139
1140 fn build_pending_writes_from_patches(
1141 file_patches: Vec<FilePatch>,
1142 context: &ToolContext,
1143 fuzz: usize,
1144 ) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
1145 let mut pending = Vec::new();
1146 let mut stats = PatchStatsExt::default();
1147 stats.stats.files_total = file_patches.len();
1148
1149 for file_patch in file_patches {
1150 if file_patch.hunks.is_empty() {
1151 return Err(ToolError::invalid_input(format!(
1152 "Patch section for `{}` has no hunks (`@@ ... @@`).",
1153 file_patch.path
1154 )));
1155 }
1156
1157 let resolved = context.resolve_path(&file_patch.path)?;
1158 let original = if resolved.exists() {
1159 Some(read_file_content(&resolved)?)
1160 } else {
1161 None
1162 };
1163
1164 if original.is_none() && !file_patch.create_if_missing {
1165 return Err(ToolError::execution_failed(format!(
1166 "File `{}` does not exist at `{}`. Set create_if_missing=true for new files or include headers for file creation.",
1167 file_patch.path,
1168 resolved.display(),
1169 )));
1170 }
1171
1172 if file_patch.delete_after && original.is_none() {
1173 return Err(ToolError::execution_failed(format!(
1174 "File `{}` does not exist at `{}` to delete.",
1175 file_patch.path,
1176 resolved.display(),
1177 )));
1178 }
1179
1180 let base_content = original.clone().unwrap_or_default();
1181 let mut lines: Vec<String> = if base_content.is_empty() {
1182 Vec::new()
1183 } else {
1184 base_content.lines().map(String::from).collect()
1185 };
1186
1187 let apply_stats =
1188 apply_hunks_to_lines(&mut lines, &file_patch.hunks, fuzz, &file_patch.path)?;
1189 stats.stats.hunks_applied += apply_stats.hunks_applied;
1190 stats.stats.hunks_total += file_patch.hunks.len();
1191 stats.stats.fuzz_used += apply_stats.fuzz_used;
1192 stats.stats.hunks_with_fuzz += apply_stats.hunks_with_fuzz;
1193 stats.stats.hunks_relocated += apply_stats.hunks_relocated;
1194 stats.stats.files_applied += 1;
1195 push_unique(&mut stats.touched_files, file_patch.path.clone());
1196 stats.file_summaries.push(FileSummary {
1197 path: file_patch.path.clone(),
1198 hunks: file_patch.hunks.len(),
1199 hunks_applied: apply_stats.hunks_applied,
1200 fuzz_used: apply_stats.fuzz_used,
1201 hunks_with_fuzz: apply_stats.hunks_with_fuzz,
1202 hunks_relocated: apply_stats.hunks_relocated,
1203 created: original.is_none() && !file_patch.delete_after,
1204 deleted: file_patch.delete_after,
1205 });
1206
1207 if file_patch.delete_after {
1208 pending.push(PendingWrite {
1209 path: resolved,
1210 content: None,
1211 original,
1212 });
1213 } else {
1214 let new_content = reassemble_preserving_newlines(&lines, &base_content);
1215 pending.push(PendingWrite {
1216 path: resolved,
1217 content: Some(new_content),
1218 original,
1219 });
1220 }
1221 }
1222
1223 Ok((pending, stats))
1224 }
1225
1226 fn apply_pending_writes(pending: &[PendingWrite]) -> Result<(), ToolError> {
1227 let mut applied = Vec::new();
1228
1229 for entry in pending {
1230 let result = if let Some(content) = entry.content.as_ref() {
1231 let parent_result = if let Some(parent) = entry.path.parent() {
1232 fs::create_dir_all(parent).map_err(|e| {
1233 ToolError::execution_failed(format!(
1234 "Failed to create directory {}: {}",
1235 parent.display(),
1236 e
1237 ))
1238 })
1239 } else {
1240 Ok(())
1241 };
1242
1243 parent_result.and_then(|()| {
1244 crate::utils::write_atomic_workspace(&entry.path, content.as_bytes()).map_err(|e| {
1245 ToolError::execution_failed(format!(
1246 "Failed to write {}: {}",
1247 entry.path.display(),
1248 e
1249 ))
1250 })
1251 })
1252 } else if entry.path.exists() {
1253 fs::remove_file(&entry.path).map_err(|e| {
1254 ToolError::execution_failed(format!(
1255 "Failed to delete {}: {}",
1256 entry.path.display(),
1257 e
1258 ))
1259 })
1260 } else {
1261 Ok(())
1262 };
1263
1264 if let Err(err) = result {
1265 rollback_pending_writes(&applied);
1266 return Err(err);
1267 }
1268
1269 applied.push(entry.clone());
1270 }
1271
1272 Ok(())
1273 }
1274
1275 fn rollback_pending_writes(applied: &[PendingWrite]) {
1276 for entry in applied.iter().rev() {
1277 match entry.original.as_ref() {
1278 Some(content) => {
1279 let _ = crate::utils::write_atomic_workspace(&entry.path, content.as_bytes());
1280 }
1281 None => {
1282 let _ = fs::remove_file(&entry.path);
1283 }
1284 }
1285 }
1286 }
1287
1288 fn read_file_content(path: &PathBuf) -> Result<String, ToolError> {
1289 fs::read_to_string(path).map_err(|e| {
1290 ToolError::execution_failed(format!("Failed to read {}: {}", path.display(), e))
1291 })
1292 }
1293
1294 fn preview_expected_lines(hunk: &Hunk, limit: usize) -> Vec<String> {
1295 let mut preview = Vec::new();
1296 for line in hunk.lines.iter().filter_map(|line| match line {
1297 HunkLine::Context(s) => Some((" ", s)),
1298 HunkLine::Remove(s) => Some(("-", s)),
1299 HunkLine::Add(_) => None,
1300 }) {
1301 if preview.len() >= limit {
1302 break;
1303 }
1304 preview.push(format!(" {}{}", line.0, line.1));
1305 }
1306 if preview.is_empty() {
1307 preview.push(" <no context lines in hunk>".to_string());
1308 }
1309 preview
1310 }
1311
1312 fn snippet_around(lines: &[String], line_1_based: usize, radius: usize) -> Vec<String> {
1313 if lines.is_empty() {
1314 return vec![" <empty file>".to_string()];
1315 }
1316
1317 let center = line_1_based
1318 .saturating_sub(1)
1319 .min(lines.len().saturating_sub(1));
1320 let start = center.saturating_sub(radius);
1321 let end = (center + radius).min(lines.len().saturating_sub(1));
1322
1323 lines[start..=end]
1324 .iter()
1325 .enumerate()
1326 .map(|(idx, line)| {
1327 let line_no = start + idx + 1;
1328 format!(" {line_no:>4}: {line}")
1329 })
1330 .collect()
1331 }
1332
1333 fn format_hunk_no_match_error(
1334 lines: &[String],
1335 hunk: &Hunk,
1336 err: &ApplyHunkError,
1337 max_fuzz: usize,
1338 ) -> String {
1339 match err {
1340 ApplyHunkError::NoMatch {
1341 expected_line,
1342 adjusted_line,
1343 offset,
1344 } => {
1345 let expected_preview = preview_expected_lines(hunk, HUNK_PREVIEW_LINES).join("\n");
1346 let file_preview = snippet_around(lines, *adjusted_line, SNIPPET_RADIUS).join("\n");
1347 format!(
1348 "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."
1349 )
1350 }
1351 ApplyHunkError::ContextAmbiguous {
1352 expected_line,
1353 candidate_lines,
1354 } => {
1355 let candidates = candidate_lines
1356 .iter()
1357 .map(|line| line.to_string())
1358 .collect::<Vec<_>>()
1359 .join(", ");
1360 format!(
1361 "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."
1362 )
1363 }
1364 }
1365 }
1366
1367 fn apply_hunks_to_lines(
1368 lines: &mut Vec<String>,
1369 hunks: &[Hunk],
1370 fuzz: usize,
1371 file_label: &str,
1372 ) -> Result<HunkApplyStats, ToolError> {
1373 let mut stats = HunkApplyStats::default();
1374 let mut cumulative_offset: isize = 0;
1375
1376 for (idx, hunk) in hunks.iter().enumerate() {
1377 match apply_hunk(lines, hunk, fuzz, &mut cumulative_offset) {
1378 Ok(outcome) => {
1379 stats.hunks_applied += 1;
1380 if outcome.fuzz_used > 0 {
1381 stats.fuzz_used += outcome.fuzz_used;
1382 stats.hunks_with_fuzz += 1;
1383 }
1384 if outcome.relocated {
1385 stats.hunks_relocated += 1;
1386 }
1387 }
1388 Err(e) => {
1389 let detail = format_hunk_no_match_error(lines, hunk, &e, fuzz);
1390 return Err(ToolError::execution_failed(format!(
1391 "Failed to apply hunk {}/{} for `{}`: {}",
1392 idx + 1,
1393 hunks.len(),
1394 file_label,
1395 detail
1396 )));
1397 }
1398 }
1399 }
1400
1401 Ok(stats)
1402 }
1403
1404 /// Apply a hunk to the file content with fuzzy matching
1405 fn apply_hunk(
1406 lines: &mut Vec<String>,
1407 hunk: &Hunk,
1408 max_fuzz: usize,
1409 cumulative_offset: &mut isize,
1410 ) -> Result<HunkApplyOutcome, ApplyHunkError> {
1411 // Build expected old lines from hunk
1412 let old_lines: Vec<&str> = hunk
1413 .lines
1414 .iter()
1415 .filter_map(|line| match line {
1416 HunkLine::Context(s) | HunkLine::Remove(s) => Some(s.as_str()),
1417 HunkLine::Add(_) => None,
1418 })
1419 .collect();
1420
1421 // Build new lines from hunk
1422 let new_lines: Vec<String> = hunk
1423 .lines
1424 .iter()
1425 .filter_map(|line| match line {
1426 HunkLine::Context(s) | HunkLine::Add(s) => Some(s.clone()),
1427 HunkLine::Remove(_) => None,
1428 })
1429 .collect();
1430
1431 // Try to find the location with fuzzy matching
1432 // Apply cumulative offset from previous hunks, clamping to valid range.
1433 let base_idx = if hunk.old_start > 0 {
1434 hunk.old_start - 1
1435 } else {
1436 0
1437 };
1438 // Use checked_add_signed to safely handle negative offsets without
1439 // risking isize overflow on adversarial input.
1440 let start_idx = base_idx
1441 .checked_add_signed(*cumulative_offset)
1442 .unwrap_or(0)
1443 .min(lines.len());
1444
1445 for fuzz in 0..=max_fuzz {
1446 // Try at exact position first, then nearby
1447 let search_range = if fuzz == 0 {
1448 vec![start_idx]
1449 } else {
1450 let min = start_idx.saturating_sub(fuzz);
1451 let max = (start_idx + fuzz).min(lines.len());
1452 (min..=max).collect()
1453 };
1454
1455 for pos in search_range {
1456 if matches_at_position(lines, &old_lines, pos) {
1457 // Apply the hunk
1458 let end_pos = pos + old_lines.len();
1459 lines.splice(pos..end_pos, new_lines.clone());
1460
1461 // Update cumulative offset: new lines added minus old lines removed
1462 let delta = new_lines.len() as isize - old_lines.len() as isize;
1463 *cumulative_offset += delta;
1464
1465 return Ok(HunkApplyOutcome {
1466 fuzz_used: fuzz,
1467 relocated: false,
1468 });
1469 }
1470 }
1471 }
1472
1473 // Special case: adding to empty file or new hunk at end
1474 if old_lines.is_empty() && (lines.is_empty() || start_idx >= lines.len()) {
1475 let delta = new_lines.len() as isize;
1476 lines.extend(new_lines);
1477 *cumulative_offset += delta;
1478 return Ok(HunkApplyOutcome {
1479 fuzz_used: 0,
1480 relocated: false,
1481 });
1482 }
1483
1484 // #5003 — positional search failed. The line numbers are probably stale
1485 // because an earlier edit (this patch or a previous one) shifted the file
1486 // and the model regenerated the patch from outdated read_file output.
1487 // If the hunk carries enough anchor lines, look for a unique whole-file
1488 // content match and relocate there; anything that matched within `fuzz`
1489 // of `start_idx` was already tried above, so any unique match found here
1490 // is genuinely relocated. Ambiguous matches are refused (applying to the
1491 // wrong copy of a repeated block would corrupt the file).
1492 let anchor_matches: Vec<usize> = if old_lines.len() >= MIN_ANCHOR_LINES {
1493 (0..=lines.len().saturating_sub(old_lines.len()))
1494 .filter(|&pos| matches_at_position(lines, &old_lines, pos))
1495 .collect()
1496 } else {
1497 Vec::new()
1498 };
1499 match anchor_matches.as_slice() {
1500 [pos] => {
1501 let end_pos = pos + old_lines.len();
1502 lines.splice(*pos..end_pos, new_lines.clone());
1503 let delta = new_lines.len() as isize - old_lines.len() as isize;
1504 *cumulative_offset += delta;
1505 Ok(HunkApplyOutcome {
1506 fuzz_used: 0,
1507 relocated: true,
1508 })
1509 }
1510 [] => Err(ApplyHunkError::NoMatch {
1511 expected_line: hunk.old_start,
1512 adjusted_line: start_idx + 1, // Convert back to 1-indexed
1513 offset: *cumulative_offset,
1514 }),
1515 multiple => Err(ApplyHunkError::ContextAmbiguous {
1516 expected_line: hunk.old_start,
1517 candidate_lines: multiple.iter().map(|&p| p + 1).collect(),
1518 }),
1519 }
1520 }
1521
1522 /// Check if `old_lines` match at the given position
1523 fn matches_at_position(lines: &[String], old_lines: &[&str], pos: usize) -> bool {
1524 if pos + old_lines.len() > lines.len() {
1525 return false;
1526 }
1527
1528 for (i, old_line) in old_lines.iter().enumerate() {
1529 // Normalize whitespace for comparison
1530 let file_line = lines[pos + i].trim_end();
1531 let expected = old_line.trim_end();
1532 if file_line != expected {
1533 return false;
1534 }
1535 }
1536
1537 true
1538 }
1539
1540 // === Unit Tests ===
1541
1542 #[cfg(test)]
1543 mod tests {
1544 use super::*;
1545 use tempfile::tempdir;
1546
1547 fn parse_patch_result(result: ToolResult) -> PatchResult {
1548 serde_json::from_str(&result.content).expect("patch result json")
1549 }
1550
1551 #[test]
1552 fn test_parse_range() {
1553 assert_eq!(parse_range("10,5").unwrap(), (10, 5));
1554 assert_eq!(parse_range("10").unwrap(), (10, 1));
1555 assert_eq!(parse_range("1,0").unwrap(), (1, 0));
1556 }
1557
1558 #[test]
1559 fn test_parse_unified_diff() {
1560 let patch = r"--- a/test.txt
1561 +++ b/test.txt
1562 @@ -1,3 +1,3 @@
1563 line1
1564 -line2
1565 +modified line2
1566 line3
1567 ";
1568
1569 let hunks = parse_unified_diff(patch).unwrap();
1570 assert_eq!(hunks.len(), 1);
1571 assert_eq!(hunks[0].old_start, 1);
1572 assert_eq!(hunks[0].old_count, 3);
1573 assert_eq!(hunks[0].new_start, 1);
1574 assert_eq!(hunks[0].new_count, 3);
1575 }
1576
1577 #[test]
1578 fn input_schema_exposes_replace_and_deprecated_changes_alias() {
1579 let schema = ApplyPatchTool.input_schema();
1580
1581 assert_eq!(schema["properties"]["replace"]["type"], "array");
1582 assert_eq!(schema["properties"]["changes"]["type"], "array");
1583 assert!(
1584 schema["properties"]["changes"]["description"]
1585 .as_str()
1586 .is_some_and(|description| description.contains("Deprecated"))
1587 );
1588 assert_eq!(
1589 schema["oneOf"],
1590 json!([
1591 { "required": ["patch"] },
1592 { "required": ["replace"] },
1593 { "required": ["changes"] }
1594 ])
1595 );
1596 }
1597
1598 #[test]
1599 fn test_preflight_apply_patch_with_path_override() {
1600 let patch = r"@@ -1,2 +1,2 @@
1601 old
1602 -value
1603 +new-value
1604 ";
1605
1606 let preflight = preflight_apply_patch(&json!({
1607 "path": "src/lib.rs",
1608 "patch": patch
1609 }))
1610 .expect("preflight");
1611
1612 assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
1613 assert_eq!(preflight.files_total, 1);
1614 assert_eq!(preflight.hunks_total, 1);
1615 assert_eq!(preflight.path_override.as_deref(), Some("src/lib.rs"));
1616 }
1617
1618 #[test]
1619 fn test_preflight_apply_patch_multi_file_create_and_delete() {
1620 let patch = r"diff --git a/new.rs b/new.rs
1621 --- /dev/null
1622 +++ b/new.rs
1623 @@ -0,0 +1 @@
1624 +fn added() {}
1625 diff --git a/old.rs b/old.rs
1626 --- a/old.rs
1627 +++ /dev/null
1628 @@ -1 +0,0 @@
1629 -fn old() {}
1630 ";
1631
1632 let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
1633
1634 assert_eq!(preflight.touched_files, vec!["new.rs", "old.rs"]);
1635 assert_eq!(preflight.files_total, 2);
1636 assert_eq!(preflight.hunks_total, 2);
1637 assert_eq!(preflight.creates, vec!["new.rs"]);
1638 assert_eq!(preflight.deletes, vec!["old.rs"]);
1639 }
1640
1641 #[test]
1642 fn test_preflight_apply_patch_timestamp_headers_strip_metadata() {
1643 let patch = "diff --git a/src/lib.rs b/src/lib.rs\n\
1644 --- a/src/lib.rs\t2026-06-26 10:00:00 +0000\n\
1645 +++ b/src/lib.rs\t2026-06-26 10:01:00 +0000\n\
1646 @@ -1,1 +1,1 @@\n\
1647 -old\n\
1648 +new\n";
1649
1650 let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
1651
1652 assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
1653 assert_eq!(preflight.files_total, 1);
1654 assert_eq!(preflight.hunks_total, 1);
1655 }
1656
1657 #[test]
1658 fn test_preflight_apply_patch_ignores_forged_headers_inside_hunk_shape() {
1659 let patch = r"--- a/src/lib.rs
1660 +++ b/src/lib.rs
1661 @@ -1,3 +1,3 @@
1662 line1
1663 --- a/forged.rs
1664 +++ b/forged.rs
1665 line3
1666 ";
1667
1668 let preflight = preflight_apply_patch(&json!({
1669 "path": "src/lib.rs",
1670 "patch": patch
1671 }))
1672 .expect("preflight");
1673
1674 assert_eq!(preflight.touched_files, vec!["src/lib.rs"]);
1675 assert_eq!(preflight.header_path_mismatch, None);
1676 }
1677
1678 #[test]
1679 fn test_preflight_apply_patch_replace_list() {
1680 let canonical = preflight_apply_patch(&json!({
1681 "replace": [
1682 { "path": "one.txt", "content": "one" },
1683 { "path": "two.txt", "content": "two" }
1684 ]
1685 }))
1686 .expect("preflight");
1687
1688 let legacy = preflight_apply_patch(&json!({
1689 "changes": [
1690 { "path": "one.txt", "content": "one" },
1691 { "path": "two.txt", "content": "two" }
1692 ]
1693 }))
1694 .expect("legacy preflight");
1695
1696 assert_eq!(canonical.touched_files, vec!["one.txt", "two.txt"]);
1697 assert_eq!(canonical.files_total, 2);
1698 assert_eq!(canonical.hunks_total, 0);
1699 assert_eq!(legacy, canonical);
1700 }
1701
1702 #[test]
1703 fn test_preflight_replace_files_total_counts_entries() {
1704 let preflight = preflight_apply_patch(&json!({
1705 "replace": [
1706 { "path": "same.txt", "content": "one" },
1707 { "path": "same.txt", "content": "two" }
1708 ]
1709 }))
1710 .expect("preflight");
1711
1712 assert_eq!(preflight.touched_files, vec!["same.txt"]);
1713 assert_eq!(preflight.files_total, 2);
1714 }
1715
1716 #[test]
1717 fn test_preflight_patch_files_total_counts_sections() {
1718 let patch = r"diff --git a/same.txt b/same.txt
1719 --- a/same.txt
1720 +++ b/same.txt
1721 @@ -1,1 +1,1 @@
1722 -one
1723 +two
1724 diff --git a/same.txt b/same.txt
1725 --- a/same.txt
1726 +++ b/same.txt
1727 @@ -2,1 +2,1 @@
1728 -three
1729 +four
1730 ";
1731
1732 let preflight = preflight_apply_patch(&json!({ "patch": patch })).expect("preflight");
1733
1734 assert_eq!(preflight.touched_files, vec!["same.txt"]);
1735 assert_eq!(preflight.files_total, 2);
1736 assert_eq!(preflight.hunks_total, 2);
1737 }
1738
1739 #[test]
1740 fn test_apply_hunk_simple() {
1741 let mut lines = vec![
1742 "line1".to_string(),
1743 "line2".to_string(),
1744 "line3".to_string(),
1745 ];
1746
1747 let hunk = Hunk {
1748 old_start: 1,
1749 old_count: 3,
1750 new_start: 1,
1751 new_count: 3,
1752 lines: vec![
1753 HunkLine::Context("line1".to_string()),
1754 HunkLine::Remove("line2".to_string()),
1755 HunkLine::Add("modified".to_string()),
1756 HunkLine::Context("line3".to_string()),
1757 ],
1758 };
1759
1760 let mut offset: isize = 0;
1761 let outcome = apply_hunk(&mut lines, &hunk, 0, &mut offset).unwrap();
1762 assert_eq!(outcome.fuzz_used, 0);
1763 assert!(!outcome.relocated);
1764 assert_eq!(lines, vec!["line1", "modified", "line3"]);
1765 }
1766
1767 #[test]
1768 fn test_apply_hunk_with_fuzz() {
1769 let mut lines = vec![
1770 "line0".to_string(),
1771 "line1".to_string(),
1772 "line2".to_string(),
1773 "line3".to_string(),
1774 ];
1775
1776 // Hunk expects to start at line 1, but content is at line 2
1777 let hunk = Hunk {
1778 old_start: 1, // Wrong position
1779 old_count: 2,
1780 new_start: 1,
1781 new_count: 2,
1782 lines: vec![
1783 HunkLine::Remove("line1".to_string()),
1784 HunkLine::Add("modified".to_string()),
1785 HunkLine::Context("line2".to_string()),
1786 ],
1787 };
1788
1789 let mut offset: isize = 0;
1790 let outcome = apply_hunk(&mut lines, &hunk, 3, &mut offset).unwrap();
1791 assert!(outcome.fuzz_used > 0);
1792 assert!(!outcome.relocated);
1793 assert_eq!(lines, vec!["line0", "modified", "line2", "line3"]);
1794 }
1795
1796 #[test]
1797 fn test_apply_hunk_no_match_returns_error() {
1798 let mut lines = vec!["line1".to_string(), "line2".to_string()];
1799 let hunk = Hunk {
1800 old_start: 5,
1801 old_count: 1,
1802 new_start: 5,
1803 new_count: 1,
1804 lines: vec![
1805 HunkLine::Context("missing".to_string()),
1806 HunkLine::Add("new".to_string()),
1807 ],
1808 };
1809
1810 let mut offset: isize = 0;
1811 let err = apply_hunk(&mut lines, &hunk, 0, &mut offset).unwrap_err();
1812 assert!(matches!(
1813 err,
1814 ApplyHunkError::NoMatch {
1815 expected_line: 5,
1816 ..
1817 }
1818 ));
1819 }
1820
1821 #[tokio::test]
1822 async fn test_apply_patch_tool() {
1823 let tmp = tempdir().expect("tempdir");
1824 let ctx = ToolContext::new(tmp.path().to_path_buf());
1825
1826 // Create a test file
1827 fs::write(tmp.path().join("test.txt"), "line1\nline2\nline3\n").expect("write");
1828
1829 let patch = r"--- a/test.txt
1830 +++ b/test.txt
1831 @@ -1,3 +1,3 @@
1832 line1
1833 -line2
1834 +modified
1835 line3
1836 ";
1837
1838 let tool = ApplyPatchTool;
1839 let result = tool
1840 .execute(json!({"path": "test.txt", "patch": patch}), &ctx)
1841 .await
1842 .expect("execute");
1843
1844 assert!(result.success);
1845 assert_eq!(
1846 result.metadata.as_ref().unwrap()["event"],
1847 "apply_patch.preflight"
1848 );
1849 assert_eq!(
1850 result.metadata.as_ref().unwrap()["touched_files"],
1851 json!(["test.txt"])
1852 );
1853 assert!(
1854 result
1855 .metadata
1856 .as_ref()
1857 .unwrap()
1858 .get("header_path_mismatch")
1859 .is_none()
1860 );
1861 assert!(
1862 result
1863 .metadata
1864 .as_ref()
1865 .unwrap()
1866 .get("path_override")
1867 .is_some()
1868 );
1869 let mutation = &result.metadata.as_ref().unwrap()["mutation"];
1870 assert_eq!(
1871 mutation["files"],
1872 json!([{ "path": "test.txt", "outcome": "updated" }])
1873 );
1874 assert!(
1875 mutation["diff"]
1876 .as_str()
1877 .is_some_and(|diff| diff.contains("-line2") && diff.contains("+modified")),
1878 "{mutation}"
1879 );
1880 let patch_result = parse_patch_result(result);
1881 assert_eq!(patch_result.touched_files, vec!["test.txt"]);
1882 assert_eq!(patch_result.hunks_applied, 1);
1883
1884 // Verify the patch was applied
1885 let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
1886 assert!(content.contains("modified"));
1887 assert!(!content.contains("line2"));
1888 // Regression: the file's trailing newline must survive the patch.
1889 assert!(content.ends_with('\n'), "trailing newline was dropped");
1890 }
1891
1892 #[test]
1893 fn reassemble_preserving_newlines_keeps_style() {
1894 let lines = vec!["a".to_string(), "b".to_string()];
1895 // LF with trailing newline.
1896 assert_eq!(reassemble_preserving_newlines(&lines, "x\ny\n"), "a\nb\n");
1897 // LF without trailing newline.
1898 assert_eq!(reassemble_preserving_newlines(&lines, "x\ny"), "a\nb");
1899 // CRLF is preserved (endings and trailing).
1900 assert_eq!(
1901 reassemble_preserving_newlines(&lines, "x\r\ny\r\n"),
1902 "a\r\nb\r\n"
1903 );
1904 // New/empty file gets a conventional trailing newline.
1905 assert_eq!(reassemble_preserving_newlines(&lines, ""), "a\nb\n");
1906 // Empty result stays empty.
1907 assert_eq!(reassemble_preserving_newlines(&[], "x\n"), "");
1908 }
1909
1910 #[tokio::test]
1911 async fn apply_patch_preserves_crlf_line_endings() {
1912 let tmp = tempdir().expect("tempdir");
1913 let ctx = ToolContext::new(tmp.path().to_path_buf());
1914 fs::write(tmp.path().join("crlf.txt"), "line1\r\nline2\r\nline3\r\n").expect("write");
1915 let patch =
1916 "--- a/crlf.txt\n+++ b/crlf.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+modified\n line3\n";
1917 let result = ApplyPatchTool
1918 .execute(json!({"path": "crlf.txt", "patch": patch}), &ctx)
1919 .await
1920 .expect("execute");
1921 assert!(result.success);
1922 let content = fs::read_to_string(tmp.path().join("crlf.txt")).expect("read");
1923 assert!(content.contains("modified"));
1924 // Regression: a CRLF file must not be flipped to LF.
1925 assert!(
1926 content.contains("\r\n"),
1927 "CRLF was flipped to LF: {content:?}"
1928 );
1929 assert!(!content.contains("\n\n"), "spurious bare LF introduced");
1930 assert!(content.ends_with("\r\n"), "trailing CRLF dropped");
1931 }
1932
1933 #[tokio::test]
1934 async fn test_apply_patch_add_lines() {
1935 let tmp = tempdir().expect("tempdir");
1936 let ctx = ToolContext::new(tmp.path().to_path_buf());
1937
1938 fs::write(tmp.path().join("test.txt"), "line1\nline3\n").expect("write");
1939
1940 let patch = r"@@ -1,2 +1,3 @@
1941 line1
1942 +line2
1943 line3
1944 ";
1945
1946 let tool = ApplyPatchTool;
1947 let result = tool
1948 .execute(json!({"path": "test.txt", "patch": patch}), &ctx)
1949 .await
1950 .expect("execute");
1951
1952 assert!(result.success);
1953 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
1954 assert_eq!(
1955 mutation["files"],
1956 json!([{ "path": "test.txt", "outcome": "updated" }])
1957 );
1958 assert!(
1959 mutation["diff"]
1960 .as_str()
1961 .is_some_and(|diff| diff.contains("+line2")),
1962 "{mutation}"
1963 );
1964 let patch_result = parse_patch_result(result);
1965 assert_eq!(patch_result.touched_files, vec!["test.txt"]);
1966
1967 let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
1968 assert!(content.contains("line2"));
1969 }
1970
1971 #[tokio::test]
1972 async fn test_apply_patch_create_new_file() {
1973 let tmp = tempdir().expect("tempdir");
1974 let ctx = ToolContext::new(tmp.path().to_path_buf());
1975
1976 let patch = r"@@ -0,0 +1,3 @@
1977 +line1
1978 +line2
1979 +line3
1980 ";
1981
1982 let tool = ApplyPatchTool;
1983 let result = tool
1984 .execute(
1985 json!({"path": "new_file.txt", "patch": patch, "create_if_missing": true}),
1986 &ctx,
1987 )
1988 .await
1989 .expect("execute");
1990
1991 assert!(result.success);
1992 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
1993 assert_eq!(
1994 mutation["files"],
1995 json!([{ "path": "new_file.txt", "outcome": "created" }])
1996 );
1997 assert!(
1998 mutation["diff"]
1999 .as_str()
2000 .is_some_and(|diff| diff.contains("+line1")),
2001 "{mutation}"
2002 );
2003 let patch_result = parse_patch_result(result);
2004 assert_eq!(patch_result.touched_files, vec!["new_file.txt"]);
2005 assert!(patch_result.file_summaries.first().unwrap().created);
2006 assert!(tmp.path().join("new_file.txt").exists());
2007 }
2008
2009 #[tokio::test]
2010 async fn test_apply_patch_replace_list() {
2011 let tmp = tempdir().expect("tempdir");
2012 let ctx = ToolContext::new(tmp.path().to_path_buf());
2013
2014 fs::write(tmp.path().join("one.txt"), "old\n").expect("write");
2015
2016 let tool = ApplyPatchTool;
2017 let result = tool
2018 .execute(
2019 json!({
2020 "replace": [
2021 { "path": "one.txt", "content": "new\n" },
2022 { "path": "two.txt", "content": "second\n" }
2023 ]
2024 }),
2025 &ctx,
2026 )
2027 .await
2028 .expect("execute");
2029
2030 assert!(result.success);
2031 let metadata = result.metadata.as_ref().expect("metadata");
2032 assert_eq!(metadata["event"], "apply_patch.preflight");
2033 assert_eq!(metadata["touched_files"], json!(["one.txt", "two.txt"]));
2034 assert_eq!(metadata["files_total"], 2);
2035 assert_eq!(metadata["hunks_total"], 0);
2036 assert!(metadata.get("path_override").is_none());
2037 assert_eq!(
2038 metadata["mutation"]["files"],
2039 json!([
2040 { "path": "one.txt", "outcome": "updated" },
2041 { "path": "two.txt", "outcome": "created" }
2042 ])
2043 );
2044 let mutation_diff = metadata["mutation"]["diff"]
2045 .as_str()
2046 .expect("mutation diff");
2047 assert!(mutation_diff.contains("diff --git a/one.txt b/one.txt"));
2048 assert!(mutation_diff.contains("diff --git a/two.txt b/two.txt"));
2049 assert!(mutation_diff.contains("--- a/one.txt"), "{mutation_diff}");
2050 assert!(mutation_diff.contains("+++ b/two.txt"), "{mutation_diff}");
2051 let patch_result = parse_patch_result(result);
2052 let mut touched = patch_result.touched_files.clone();
2053 touched.sort();
2054 assert_eq!(touched, vec!["one.txt", "two.txt"]);
2055 assert_eq!(patch_result.hunks_total, 0);
2056 assert_eq!(
2057 fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
2058 "new\n"
2059 );
2060 assert_eq!(
2061 fs::read_to_string(tmp.path().join("two.txt")).unwrap(),
2062 "second\n"
2063 );
2064 }
2065
2066 #[tokio::test]
2067 async fn test_apply_patch_legacy_changes_list() {
2068 let tmp = tempdir().expect("tempdir");
2069 let ctx = ToolContext::new(tmp.path().to_path_buf());
2070 fs::write(tmp.path().join("legacy.txt"), "old\n").expect("write");
2071
2072 let result = ApplyPatchTool
2073 .execute(
2074 json!({
2075 "changes": [
2076 { "path": "legacy.txt", "content": "new\n" }
2077 ]
2078 }),
2079 &ctx,
2080 )
2081 .await
2082 .expect("legacy changes alias should execute");
2083
2084 assert!(result.success);
2085 assert_eq!(
2086 fs::read_to_string(tmp.path().join("legacy.txt")).unwrap(),
2087 "new\n"
2088 );
2089 }
2090
2091 #[tokio::test]
2092 async fn apply_patch_rejects_every_mixed_mode_before_writing() {
2093 let tmp = tempdir().expect("tempdir");
2094 let ctx = ToolContext::new(tmp.path().to_path_buf());
2095 fs::write(tmp.path().join("guard.txt"), "old\n").expect("write");
2096 let patch = "--- a/guard.txt\n+++ b/guard.txt\n@@ -1 +1 @@\n-old\n+patched\n";
2097 let replacement = json!([{
2098 "path": "guard.txt",
2099 "content": "replaced\n"
2100 }]);
2101 let cases = [
2102 (
2103 ["patch", "replace"],
2104 json!({"patch": patch, "replace": replacement.clone()}),
2105 ),
2106 (
2107 ["patch", "changes"],
2108 json!({"patch": patch, "changes": replacement.clone()}),
2109 ),
2110 (
2111 ["replace", "changes"],
2112 json!({
2113 "replace": replacement.clone(),
2114 "changes": replacement.clone()
2115 }),
2116 ),
2117 ];
2118
2119 for (fields, input) in cases {
2120 let err = ApplyPatchTool
2121 .execute(input, &ctx)
2122 .await
2123 .expect_err("mixed modes must be rejected");
2124 let ToolError::InvalidInput { message } = err else {
2125 panic!("mixed modes should be invalid input, got: {err}");
2126 };
2127 assert!(message.contains("simultaneously"), "{message}");
2128 for field in fields {
2129 assert!(message.contains(field), "{message}");
2130 }
2131 assert_eq!(
2132 fs::read_to_string(tmp.path().join("guard.txt")).unwrap(),
2133 "old\n",
2134 "mixed modes must be rejected before the first write"
2135 );
2136 }
2137 }
2138
2139 #[tokio::test]
2140 async fn test_apply_patch_replace_list_rolls_back_on_write_failure() {
2141 let tmp = tempdir().expect("tempdir");
2142 let ctx = ToolContext::new(tmp.path().to_path_buf());
2143
2144 fs::write(tmp.path().join("one.txt"), "old\n").expect("write");
2145 fs::write(tmp.path().join("blocked"), "not a dir\n").expect("write blocker");
2146
2147 let tool = ApplyPatchTool;
2148 let err = tool
2149 .execute(
2150 json!({
2151 "replace": [
2152 { "path": "one.txt", "content": "new\n" },
2153 { "path": "blocked/two.txt", "content": "second\n" }
2154 ]
2155 }),
2156 &ctx,
2157 )
2158 .await
2159 .expect_err("second write should fail");
2160
2161 let message = err.to_string();
2162 assert!(message.contains("blocked"), "{message}");
2163 assert_eq!(
2164 fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
2165 "old\n"
2166 );
2167 assert!(!tmp.path().join("blocked").join("two.txt").exists());
2168 }
2169
2170 #[tokio::test]
2171 async fn test_apply_patch_multi_file_diff() {
2172 let tmp = tempdir().expect("tempdir");
2173 let ctx = ToolContext::new(tmp.path().to_path_buf());
2174
2175 fs::write(tmp.path().join("a.txt"), "line1\nline2\n").expect("write");
2176 fs::write(tmp.path().join("b.txt"), "alpha\nbeta\n").expect("write");
2177
2178 let patch = r"diff --git a/a.txt b/a.txt
2179 --- a/a.txt
2180 +++ b/a.txt
2181 @@ -1,2 +1,2 @@
2182 line1
2183 -line2
2184 +line2-mod
2185 diff --git a/b.txt b/b.txt
2186 --- a/b.txt
2187 +++ b/b.txt
2188 @@ -1,2 +1,3 @@
2189 alpha
2190 +beta2
2191 beta
2192 ";
2193
2194 let tool = ApplyPatchTool;
2195 let result = tool
2196 .execute(json!({"patch": patch}), &ctx)
2197 .await
2198 .expect("execute");
2199
2200 assert!(result.success);
2201 let metadata = result.metadata.as_ref().expect("metadata");
2202 assert_eq!(metadata["event"], "apply_patch.preflight");
2203 assert_eq!(metadata["touched_files"], json!(["a.txt", "b.txt"]));
2204 assert_eq!(metadata["files_total"], 2);
2205 assert_eq!(metadata["hunks_total"], 2);
2206 assert!(metadata.get("path_override").is_none());
2207 let patch_result = parse_patch_result(result);
2208 let mut touched = patch_result.touched_files.clone();
2209 touched.sort();
2210 assert_eq!(touched, vec!["a.txt", "b.txt"]);
2211 assert_eq!(patch_result.files_applied, 2);
2212 let a = fs::read_to_string(tmp.path().join("a.txt")).unwrap();
2213 let b = fs::read_to_string(tmp.path().join("b.txt")).unwrap();
2214 assert!(a.contains("line2-mod"));
2215 assert!(b.contains("beta2"));
2216 }
2217
2218 #[tokio::test]
2219 async fn mutation_receipt_covers_delete_rename_and_multifile_outcomes() {
2220 let tmp = tempdir().expect("tempdir");
2221 let ctx = ToolContext::new(tmp.path().to_path_buf());
2222 fs::write(tmp.path().join("old.txt"), "same\n").expect("old");
2223 fs::write(tmp.path().join("update.txt"), "before\n").expect("update");
2224 fs::write(tmp.path().join("delete.txt"), "gone\n").expect("delete");
2225
2226 let patch = r"diff --git a/old.txt b/old.txt
2227 --- a/old.txt
2228 +++ /dev/null
2229 @@ -1 +0,0 @@
2230 -same
2231 diff --git a/new.txt b/new.txt
2232 --- /dev/null
2233 +++ b/new.txt
2234 @@ -0,0 +1 @@
2235 +same
2236 diff --git a/update.txt b/update.txt
2237 --- a/update.txt
2238 +++ b/update.txt
2239 @@ -1 +1 @@
2240 -before
2241 +after
2242 diff --git a/create.txt b/create.txt
2243 --- /dev/null
2244 +++ b/create.txt
2245 @@ -0,0 +1 @@
2246 +fresh
2247 diff --git a/delete.txt b/delete.txt
2248 --- a/delete.txt
2249 +++ /dev/null
2250 @@ -1 +0,0 @@
2251 -gone
2252 ";
2253
2254 let result = ApplyPatchTool
2255 .execute(json!({"patch": patch}), &ctx)
2256 .await
2257 .expect("execute");
2258 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
2259 assert_eq!(
2260 mutation["files"],
2261 json!([
2262 { "path": "update.txt", "outcome": "updated" },
2263 { "path": "create.txt", "outcome": "created" },
2264 { "path": "delete.txt", "outcome": "deleted" }
2265 ])
2266 );
2267 assert_eq!(
2268 mutation["renames"],
2269 json!([{ "from": "old.txt", "to": "new.txt" }])
2270 );
2271 let exact = mutation["diff"].as_str().expect("exact mutation diff");
2272 assert!(exact.contains("rename from old.txt"), "{exact}");
2273 assert!(exact.contains("rename to new.txt"), "{exact}");
2274 assert!(exact.contains("--- a/update.txt"), "{exact}");
2275 assert!(exact.contains("+++ b/create.txt"), "{exact}");
2276 assert!(exact.contains("--- a/delete.txt"), "{exact}");
2277
2278 assert!(!tmp.path().join("old.txt").exists());
2279 assert_eq!(
2280 fs::read_to_string(tmp.path().join("new.txt")).expect("renamed target"),
2281 "same\n"
2282 );
2283 assert_eq!(
2284 fs::read_to_string(tmp.path().join("update.txt")).expect("updated"),
2285 "after\n"
2286 );
2287 assert!(tmp.path().join("create.txt").exists());
2288 assert!(!tmp.path().join("delete.txt").exists());
2289 }
2290
2291 #[tokio::test]
2292 async fn test_apply_patch_requires_headers_without_path() {
2293 let tmp = tempdir().expect("tempdir");
2294 let ctx = ToolContext::new(tmp.path().to_path_buf());
2295 let tool = ApplyPatchTool;
2296
2297 let patch = r"@@ -1,1 +1,1 @@
2298 -old
2299 +new
2300 ";
2301
2302 let err = tool
2303 .execute(json!({"patch": patch}), &ctx)
2304 .await
2305 .unwrap_err();
2306 match err {
2307 ToolError::InvalidInput { message } => {
2308 assert!(message.contains("no file headers"));
2309 assert!(message.contains("Provide `path`"));
2310 }
2311 other => panic!("expected invalid input, got: {other}"),
2312 }
2313 }
2314
2315 #[tokio::test]
2316 async fn test_path_override_rejects_multi_file_diff() {
2317 let tmp = tempdir().expect("tempdir");
2318 let ctx = ToolContext::new(tmp.path().to_path_buf());
2319 let tool = ApplyPatchTool;
2320
2321 let patch = r"diff --git a/a.txt b/a.txt
2322 --- a/a.txt
2323 +++ b/a.txt
2324 @@ -1,1 +1,1 @@
2325 -one
2326 +one-mod
2327 diff --git a/b.txt b/b.txt
2328 --- a/b.txt
2329 +++ b/b.txt
2330 @@ -1,1 +1,1 @@
2331 -two
2332 +two-mod
2333 ";
2334
2335 let err = tool
2336 .execute(json!({"path": "a.txt", "patch": patch}), &ctx)
2337 .await
2338 .unwrap_err();
2339 match err {
2340 ToolError::InvalidInput { message } => {
2341 assert!(message.contains("multiple files"));
2342 assert!(message.contains("a.txt"));
2343 assert!(message.contains("b.txt"));
2344 }
2345 other => panic!("expected invalid input, got: {other}"),
2346 }
2347 }
2348
2349 #[tokio::test]
2350 async fn test_apply_patch_summary_reports_fuzz() {
2351 let tmp = tempdir().expect("tempdir");
2352 let ctx = ToolContext::new(tmp.path().to_path_buf());
2353 let tool = ApplyPatchTool;
2354
2355 fs::write(tmp.path().join("test.txt"), "line0\nline1\nline2\nline3\n").expect("write");
2356
2357 let patch = r"@@ -1,2 +1,2 @@
2358 -line1
2359 +modified
2360 line2
2361 ";
2362
2363 let result = tool
2364 .execute(json!({"path": "test.txt", "patch": patch, "fuzz": 3}), &ctx)
2365 .await
2366 .expect("execute");
2367 assert!(result.success);
2368 let patch_result = parse_patch_result(result);
2369 assert_eq!(patch_result.hunks_with_fuzz, 1);
2370 assert!(patch_result.fuzz_used > 0);
2371 assert!(patch_result.message.contains("Fuzz used"));
2372 let summary = patch_result.file_summaries.first().unwrap();
2373 assert_eq!(summary.hunks_with_fuzz, 1);
2374 }
2375
2376 #[tokio::test]
2377 async fn test_path_override_header_mismatch_note() {
2378 let tmp = tempdir().expect("tempdir");
2379 let ctx = ToolContext::new(tmp.path().to_path_buf());
2380 let tool = ApplyPatchTool;
2381
2382 fs::write(tmp.path().join("override.txt"), "old\n").expect("write");
2383
2384 let patch = r"--- a/other.txt
2385 +++ b/other.txt
2386 @@ -1,1 +1,1 @@
2387 -old
2388 +new
2389 ";
2390
2391 let result = tool
2392 .execute(json!({"path": "override.txt", "patch": patch}), &ctx)
2393 .await
2394 .expect("execute");
2395 let metadata = result.metadata.as_ref().expect("metadata");
2396 assert!(
2397 metadata["header_path_mismatch"]
2398 .as_str()
2399 .unwrap()
2400 .contains("headers reference `other.txt`")
2401 );
2402 let patch_result = parse_patch_result(result);
2403 assert!(
2404 patch_result
2405 .message
2406 .contains("headers reference `other.txt`")
2407 );
2408 assert!(
2409 patch_result
2410 .message
2411 .contains("path` overrides to `override.txt`")
2412 );
2413 }
2414
2415 #[test]
2416 fn test_apply_patch_tool_properties() {
2417 let tool = ApplyPatchTool;
2418 assert_eq!(tool.name(), "apply_patch");
2419 assert!(!tool.is_read_only());
2420 assert!(tool.is_sandboxable());
2421 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
2422 }
2423
2424 #[test]
2425 fn test_multi_hunk_offset_tracking() {
2426 // File with 6 lines
2427 let mut lines: Vec<String> = vec![
2428 "line1".to_string(),
2429 "line2".to_string(),
2430 "line3".to_string(),
2431 "line4".to_string(),
2432 "line5".to_string(),
2433 "line6".to_string(),
2434 ];
2435
2436 // Hunk 1: Add 2 lines after line1 (offset becomes +2)
2437 let hunk1 = Hunk {
2438 old_start: 1,
2439 old_count: 2,
2440 new_start: 1,
2441 new_count: 4,
2442 lines: vec![
2443 HunkLine::Context("line1".to_string()),
2444 HunkLine::Add("new_a".to_string()),
2445 HunkLine::Add("new_b".to_string()),
2446 HunkLine::Context("line2".to_string()),
2447 ],
2448 };
2449
2450 // Hunk 2: Modify line5 (originally at position 5, now at position 7 due to +2 offset)
2451 let hunk2 = Hunk {
2452 old_start: 5, // Original position in the diff
2453 old_count: 1,
2454 new_start: 7,
2455 new_count: 1,
2456 lines: vec![
2457 HunkLine::Remove("line5".to_string()),
2458 HunkLine::Add("modified5".to_string()),
2459 ],
2460 };
2461
2462 let mut offset: isize = 0;
2463
2464 // Apply first hunk
2465 let outcome1 = apply_hunk(&mut lines, &hunk1, 3, &mut offset).unwrap();
2466 assert_eq!(outcome1.fuzz_used, 0);
2467 assert!(!outcome1.relocated);
2468 assert_eq!(offset, 2); // Added 2 lines (4 new - 2 old)
2469 assert_eq!(
2470 lines,
2471 vec![
2472 "line1", "new_a", "new_b", "line2", "line3", "line4", "line5", "line6"
2473 ]
2474 );
2475
2476 // Apply second hunk - this would fail without offset tracking!
2477 let outcome2 = apply_hunk(&mut lines, &hunk2, 3, &mut offset).unwrap();
2478 assert_eq!(outcome2.fuzz_used, 0);
2479 assert!(!outcome2.relocated);
2480 assert!(lines.contains(&"modified5".to_string()));
2481 assert!(!lines.contains(&"line5".to_string()));
2482 }
2483
2484 #[test]
2485 fn test_apply_hunk_relocates_to_unique_context_match() {
2486 // #5003 - stale line numbers (hunk says line 1, content is at line 20).
2487 let mut lines: Vec<String> = (0..25).map(|i| format!("line{i}")).collect();
2488 let hunk = Hunk {
2489 old_start: 1, // stale line number
2490 old_count: 5,
2491 new_start: 1,
2492 new_count: 5,
2493 lines: vec![
2494 HunkLine::Context("line19".to_string()),
2495 HunkLine::Context("line20".to_string()),
2496 HunkLine::Remove("line21".to_string()),
2497 HunkLine::Add("line21-modified".to_string()),
2498 HunkLine::Context("line22".to_string()),
2499 HunkLine::Context("line23".to_string()),
2500 ],
2501 };
2502
2503 let mut offset: isize = 0;
2504 let outcome = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap();
2505 assert!(
2506 outcome.relocated,
2507 "expected relocation for stale line numbers"
2508 );
2509 assert_eq!(outcome.fuzz_used, 0);
2510 assert_eq!(lines[21], "line21-modified");
2511 assert!(!lines.contains(&"line21".to_string()));
2512 }
2513
2514 #[test]
2515 fn test_apply_hunk_ambiguous_context_reports_candidates() {
2516 // Two identical block-a..d blocks: anchor is not unique -> ContextAmbiguous.
2517 let mut lines: Vec<String> = [
2518 "header0", "header1", "header2", "block-a", "block-b", "block-c", "block-d", "middle",
2519 "block-a", "block-b", "block-c", "block-d", "footer",
2520 ]
2521 .iter()
2522 .map(|s| s.to_string())
2523 .collect();
2524
2525 let hunk = Hunk {
2526 old_start: 1,
2527 old_count: 4,
2528 new_start: 1,
2529 new_count: 4,
2530 lines: vec![
2531 HunkLine::Remove("block-a".to_string()),
2532 HunkLine::Remove("block-b".to_string()),
2533 HunkLine::Remove("block-c".to_string()),
2534 HunkLine::Remove("block-d".to_string()),
2535 HunkLine::Add("block-a-modified".to_string()),
2536 HunkLine::Add("block-b".to_string()),
2537 HunkLine::Add("block-c".to_string()),
2538 HunkLine::Add("block-d".to_string()),
2539 ],
2540 };
2541
2542 let mut offset: isize = 0;
2543 let err = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap_err();
2544 match err {
2545 ApplyHunkError::ContextAmbiguous {
2546 expected_line,
2547 candidate_lines,
2548 } => {
2549 assert_eq!(expected_line, 1);
2550 // The two duplicate blocks start at 1-based lines 4 and 9.
2551 assert_eq!(candidate_lines, vec![4, 9]);
2552 }
2553 other => panic!("expected ContextAmbiguous, got: {other:?}"),
2554 }
2555 }
2556
2557 #[test]
2558 fn test_apply_hunk_short_anchor_not_relocated() {
2559 // Anchor too short (1 line < MIN_ANCHOR_LINES): keep NoMatch behavior.
2560 let mut lines: Vec<String> = ["zero", "one", "two", "three", "four"]
2561 .iter()
2562 .map(|s| s.to_string())
2563 .collect();
2564 let hunk = Hunk {
2565 old_start: 1,
2566 old_count: 1,
2567 new_start: 1,
2568 new_count: 1,
2569 lines: vec![
2570 HunkLine::Remove("four".to_string()),
2571 HunkLine::Add("four-modified".to_string()),
2572 ],
2573 };
2574
2575 let mut offset: isize = 0;
2576 let err = apply_hunk(&mut lines, &hunk, 1, &mut offset).unwrap_err();
2577 assert!(
2578 matches!(err, ApplyHunkError::NoMatch { .. }),
2579 "short anchors must not be relocated"
2580 );
2581 }
2582
2583 #[tokio::test]
2584 async fn test_apply_patch_relocates_stale_line_numbers() {
2585 // #5003 - integration: hunk claims line 1 but content is at line 21.
2586 let tmp = tempdir().expect("tempdir");
2587 let ctx = ToolContext::new(tmp.path().to_path_buf());
2588 let tool = ApplyPatchTool;
2589
2590 let content = (0..30)
2591 .map(|i| format!("line{i}"))
2592 .collect::<Vec<_>>()
2593 .join("\n")
2594 + "\n";
2595 fs::write(tmp.path().join("stale.txt"), &content).expect("write");
2596
2597 let patch = r"@@ -1,5 +1,5 @@
2598 line19
2599 line20
2600 -line21
2601 +line21-modified
2602 line22
2603 line23
2604 ";
2605
2606 let result = tool
2607 .execute(
2608 json!({"path": "stale.txt", "patch": patch, "fuzz": 1}),
2609 &ctx,
2610 )
2611 .await
2612 .expect("execute");
2613 assert!(result.success);
2614 let patch_result = parse_patch_result(result);
2615 assert_eq!(patch_result.hunks_relocated, 1);
2616 assert!(
2617 patch_result.message.contains("stale line numbers"),
2618 "message: {}",
2619 patch_result.message
2620 );
2621 let summary = patch_result.file_summaries.first().unwrap();
2622 assert_eq!(summary.hunks_relocated, 1);
2623
2624 let edited = fs::read_to_string(tmp.path().join("stale.txt")).expect("read");
2625 assert!(edited.contains("line21-modified"));
2626 assert!(!edited.contains("\nline21\n"));
2627 }
2628
2629 #[tokio::test]
2630 async fn test_apply_patch_ambiguous_context_reports_candidates() {
2631 // #5003 - integration: duplicate context blocks, ambiguous relocation.
2632 let tmp = tempdir().expect("tempdir");
2633 let ctx = ToolContext::new(tmp.path().to_path_buf());
2634 let tool = ApplyPatchTool;
2635
2636 let content = "header0\nheader1\nheader2\nblock-a\nblock-b\nblock-c\nblock-d\nmiddle\nblock-a\nblock-b\nblock-c\nblock-d\nfooter\n";
2637 fs::write(tmp.path().join("dup.txt"), content).expect("write");
2638
2639 let patch = r"@@ -1,4 +1,4 @@
2640 -block-a
2641 -block-b
2642 -block-c
2643 -block-d
2644 +block-a-modified
2645 +block-b
2646 +block-c
2647 +block-d
2648 ";
2649
2650 let err = tool
2651 .execute(json!({"path": "dup.txt", "patch": patch, "fuzz": 1}), &ctx)
2652 .await
2653 .unwrap_err();
2654 let message = err.to_string();
2655 assert!(
2656 message.contains("multiple locations"),
2657 "expected ambiguity error, got: {message}"
2658 );
2659 // The two duplicate blocks start at 1-based lines 4 and 9.
2660 assert!(
2661 message.contains("lines 4, 9"),
2662 "expected candidate lines, got: {message}"
2663 );
2664 let unchanged = fs::read_to_string(tmp.path().join("dup.txt")).expect("read");
2665 assert_eq!(
2666 unchanged, content,
2667 "ambiguous hunk must not modify the file"
2668 );
2669 }
2670 #[tokio::test]
2671 async fn test_apply_patch_relocates_after_crlf_chinese_edit_shift() {
2672 // #5003 - issue scenario: a C-style file with CRLF line endings and
2673 // Chinese comments. A first edit shifts line numbers; a second patch
2674 // still uses stale line numbers but its context is unique in the
2675 // file, so apply_patch relocates instead of failing.
2676 let tmp = tempdir().expect("tempdir");
2677 let ctx = ToolContext::new(tmp.path().to_path_buf());
2678 let tool = ApplyPatchTool;
2679
2680 let mut lines = vec!["/* 扭矩控制模块 */".to_string()];
2681 for i in 0..60 {
2682 lines.push(format!("int cfg_{i} = {i}; // 配置项 {i}"));
2683 }
2684 let content = lines.join("\r\n") + "\r\n";
2685 fs::write(tmp.path().join("app_foc.c"), &content).expect("write");
2686
2687 // First edit: insert 8 lines right after cfg_9 (old line 11). This
2688 // shifts every later line number by +8.
2689 let patch1 = r"@@ -11,1 +11,9 @@
2690 int cfg_9 = 9; // 配置项 9
2691 +int new_a = 100; // 新增配置 A
2692 +int new_b = 101; // 新增配置 B
2693 +int new_c = 102; // 新增配置 C
2694 +int new_d = 103; // 新增配置 D
2695 +int new_e = 104; // 新增配置 E
2696 +int new_f = 105; // 新增配置 F
2697 +int new_g = 106; // 新增配置 G
2698 +int new_h = 107; // 新增配置 H
2699 ";
2700 let r1 = tool
2701 .execute(
2702 json!({"path": "app_foc.c", "patch": patch1, "fuzz": 0}),
2703 &ctx,
2704 )
2705 .await
2706 .expect("first patch");
2707 assert!(r1.success, "first patch should apply: {}", r1.content);
2708
2709 // Second edit: claims line 31 (stale - cfg_21 now), but the replaced
2710 // block cfg_30..cfg_34 actually lives at lines 40-44. Positional
2711 // search with fuzz=1 fails; the unique whole-file anchor relocates.
2712 let patch2 = r"@@ -31,5 +31,5 @@
2713 int cfg_30 = 30; // 配置项 30
2714 int cfg_31 = 31; // 配置项 31
2715 -int cfg_32 = 32; // 配置项 32
2716 +int cfg_32 = 3200; // 配置项 32 已修改
2717 int cfg_33 = 33; // 配置项 33
2718 int cfg_34 = 34; // 配置项 34
2719 ";
2720 let r2 = tool
2721 .execute(
2722 json!({"path": "app_foc.c", "patch": patch2, "fuzz": 1}),
2723 &ctx,
2724 )
2725 .await
2726 .expect("second patch");
2727 assert!(r2.success, "second patch should relocate: {}", r2.content);
2728 let pr2 = parse_patch_result(r2);
2729 assert_eq!(pr2.hunks_relocated, 1, "second patch must be relocated");
2730
2731 let edited = fs::read_to_string(tmp.path().join("app_foc.c")).expect("read");
2732 assert!(
2733 edited.contains("int cfg_32 = 3200;"),
2734 "replacement must land"
2735 );
2736 assert!(
2737 edited.contains("int cfg_33 = 33;"),
2738 "context after the edit must stay intact"
2739 );
2740 assert!(
2741 edited.contains("int new_h = 107;"),
2742 "first edit must survive"
2743 );
2744 }
2745 }
2746
2746 lines RUST