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