返回 DeepSeek-TUI-2026
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::spec::{
16 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
17 lsp_diagnostics_for_paths, optional_bool, optional_str, optional_u64, required_str,
18 };
19
20 /// Maximum lines of context for fuzzy matching (increased for better tolerance)
21 const MAX_FUZZ: usize = 50;
22 /// Limit how much context we print in error messages.
23 const HUNK_PREVIEW_LINES: usize = 4;
24 const SNIPPET_RADIUS: usize = 2;
25 const FILE_LIST_LIMIT: usize = 6;
26
27 // === Types ===
28
29 /// Result of applying a patch
30 #[derive(Debug, Clone, Serialize, Deserialize)]
31 pub struct PatchResult {
32 pub success: bool,
33 pub files_applied: usize,
34 pub files_total: usize,
35 pub hunks_applied: usize,
36 pub hunks_total: usize,
37 pub fuzz_used: usize,
38 #[serde(default)]
39 pub hunks_with_fuzz: usize,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub touched_files: Vec<String>,
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub file_summaries: Vec<FileSummary>,
44 pub message: String,
45 }
46
47 /// Per-file summary for patch application output.
48 #[derive(Debug, Clone, Serialize, Deserialize)]
49 pub struct FileSummary {
50 pub path: String,
51 pub hunks: usize,
52 pub hunks_applied: usize,
53 pub fuzz_used: usize,
54 pub hunks_with_fuzz: usize,
55 pub created: bool,
56 pub deleted: bool,
57 }
58
59 /// A single hunk in a unified diff
60 #[derive(Debug, Clone)]
61 pub struct Hunk {
62 pub old_start: usize,
63 #[allow(dead_code)]
64 pub old_count: usize,
65 #[allow(dead_code)]
66 pub new_start: usize,
67 #[allow(dead_code)]
68 pub new_count: usize,
69 pub lines: Vec<HunkLine>,
70 }
71
72 /// A line in a hunk
73 #[derive(Debug, Clone)]
74 pub enum HunkLine {
75 Context(String),
76 Add(String),
77 Remove(String),
78 }
79
80 /// Tool for applying unified diff patches to files
81 pub struct ApplyPatchTool;
82
83 #[derive(Debug, Clone)]
84 struct FilePatch {
85 path: String,
86 hunks: Vec<Hunk>,
87 delete_after: bool,
88 create_if_missing: bool,
89 }
90
91 #[derive(Debug, Clone)]
92 struct PendingWrite {
93 path: PathBuf,
94 content: Option<String>,
95 original: Option<String>,
96 }
97
98 #[derive(Debug, Default, Clone, Copy)]
99 struct PatchStats {
100 files_applied: usize,
101 files_total: usize,
102 hunks_applied: usize,
103 hunks_total: usize,
104 fuzz_used: usize,
105 hunks_with_fuzz: usize,
106 }
107
108 #[derive(Debug, Default, Clone)]
109 struct PatchStatsExt {
110 stats: PatchStats,
111 touched_files: Vec<String>,
112 file_summaries: Vec<FileSummary>,
113 header_path_mismatch: Option<String>,
114 }
115
116 #[derive(Debug, Default, Clone)]
117 struct PatchShape {
118 has_hunks: bool,
119 header_files: Vec<String>,
120 }
121
122 impl PatchShape {
123 fn file_count(&self) -> usize {
124 self.header_files.len()
125 }
126 }
127
128 #[derive(Debug, Default, Clone, Copy)]
129 struct HunkApplyStats {
130 hunks_applied: usize,
131 fuzz_used: usize,
132 hunks_with_fuzz: usize,
133 }
134
135 // === Errors ===
136
137 #[derive(Debug, Error)]
138 enum ApplyHunkError {
139 #[error(
140 "Failed to find matching location for hunk (expected at line {expected_line}, adjusted to {adjusted_line} with offset {offset:+})"
141 )]
142 NoMatch {
143 expected_line: usize,
144 adjusted_line: usize,
145 offset: isize,
146 },
147 }
148
149 #[async_trait]
150 impl ToolSpec for ApplyPatchTool {
151 fn name(&self) -> &'static str {
152 "apply_patch"
153 }
154
155 fn description(&self) -> &'static str {
156 "Apply a unified diff patch to a file. Supports multi-hunk patches with fuzzy matching."
157 }
158
159 fn input_schema(&self) -> Value {
160 json!({
161 "type": "object",
162 "properties": {
163 "path": {
164 "type": "string",
165 "description": "Path to the file to patch (relative to workspace)"
166 },
167 "patch": {
168 "type": "string",
169 "description": "Unified diff patch content"
170 },
171 "changes": {
172 "type": "array",
173 "description": "Optional full file replacements (path + content).",
174 "items": {
175 "type": "object",
176 "properties": {
177 "path": { "type": "string" },
178 "content": { "type": "string" }
179 },
180 "required": ["path", "content"]
181 }
182 },
183 "fuzz": {
184 "type": "integer",
185 "description": "Maximum fuzz factor for fuzzy matching (default: 3)"
186 },
187 "create_if_missing": {
188 "type": "boolean",
189 "description": "Create the file if it doesn't exist (for new file patches)"
190 }
191 },
192 "oneOf": [
193 { "required": ["patch"] },
194 { "required": ["changes"] }
195 ]
196 })
197 }
198
199 fn capabilities(&self) -> Vec<ToolCapability> {
200 vec![
201 ToolCapability::WritesFiles,
202 ToolCapability::Sandboxable,
203 ToolCapability::RequiresApproval,
204 ]
205 }
206
207 fn approval_requirement(&self) -> ApprovalRequirement {
208 ApprovalRequirement::Suggest
209 }
210
211 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
212 let fuzz = optional_u64(&input, "fuzz", MAX_FUZZ as u64).min(MAX_FUZZ as u64);
213 let fuzz = usize::try_from(fuzz).unwrap_or(MAX_FUZZ);
214 let create_if_missing = optional_bool(&input, "create_if_missing", false);
215
216 if let Some(changes_value) = input.get("changes") {
217 let (pending, stats) = build_pending_writes_from_changes(changes_value, context)?;
218 apply_pending_writes(&pending)?;
219 // Resolve absolute paths for LSP diagnostics query.
220 let abs_paths: Vec<PathBuf> = pending.iter().map(|p| p.path.clone()).collect();
221 let diag_block = lsp_diagnostics_for_paths(context, &abs_paths).await;
222 let result = PatchResult {
223 success: true,
224 files_applied: stats.stats.files_applied,
225 files_total: stats.stats.files_total,
226 hunks_applied: stats.stats.hunks_applied,
227 hunks_total: stats.stats.hunks_total,
228 fuzz_used: stats.stats.fuzz_used,
229 hunks_with_fuzz: stats.stats.hunks_with_fuzz,
230 touched_files: stats.touched_files.clone(),
231 file_summaries: stats.file_summaries.clone(),
232 message: build_summary_message(&stats),
233 };
234 let mut tool_result = ToolResult::json(&result)
235 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
236 if !diag_block.is_empty() {
237 tool_result.content.push('\n');
238 tool_result.content.push_str(&diag_block);
239 }
240 return Ok(tool_result);
241 }
242
243 let patch_text = required_str(&input, "patch")?;
244 let path_override = optional_str(&input, "path");
245 let patch_shape = inspect_patch_shape(patch_text);
246 validate_patch_shape(&patch_shape, path_override)?;
247 let mismatch_note = path_override.and_then(|path| diff_header_mismatch(path, &patch_shape));
248 let file_patches = if let Some(path) = path_override {
249 let hunks = parse_unified_diff(patch_text)?;
250 if hunks.is_empty() {
251 return Err(ToolError::invalid_input(
252 "Patch did not contain any hunks (`@@ ... @@`). Provide a unified diff hunk.",
253 ));
254 }
255 vec![FilePatch {
256 path: path.to_string(),
257 hunks,
258 delete_after: false,
259 create_if_missing,
260 }]
261 } else {
262 let file_patches = parse_unified_diff_files(patch_text, create_if_missing)?;
263 if file_patches.is_empty() {
264 return Err(ToolError::invalid_input(
265 "No valid file patches found. Ensure the patch includes `---`/`+++` headers or provide `path`.",
266 ));
267 }
268 file_patches
269 };
270
271 let (pending, mut stats) = build_pending_writes_from_patches(file_patches, context, fuzz)?;
272 if stats.header_path_mismatch.is_none() {
273 stats.header_path_mismatch = mismatch_note;
274 }
275 apply_pending_writes(&pending)?;
276 // Resolve absolute paths for LSP diagnostics query.
277 let abs_paths: Vec<PathBuf> = pending
278 .iter()
279 .filter(|p| p.content.is_some()) // skip deleted files
280 .map(|p| p.path.clone())
281 .collect();
282 let diag_block = lsp_diagnostics_for_paths(context, &abs_paths).await;
283 let result = PatchResult {
284 success: true,
285 files_applied: stats.stats.files_applied,
286 files_total: stats.stats.files_total,
287 hunks_applied: stats.stats.hunks_applied,
288 hunks_total: stats.stats.hunks_total,
289 fuzz_used: stats.stats.fuzz_used,
290 hunks_with_fuzz: stats.stats.hunks_with_fuzz,
291 touched_files: stats.touched_files.clone(),
292 file_summaries: stats.file_summaries.clone(),
293 message: build_summary_message(&stats),
294 };
295 let mut tool_result =
296 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
297 if !diag_block.is_empty() {
298 tool_result.content.push('\n');
299 tool_result.content.push_str(&diag_block);
300 }
301 Ok(tool_result)
302 }
303 }
304
305 /// Parse a unified diff into hunks
306 fn parse_unified_diff(patch: &str) -> Result<Vec<Hunk>, ToolError> {
307 let mut hunks = Vec::new();
308 let mut lines = patch.lines().peekable();
309
310 // Skip header lines (---, +++ etc)
311 while let Some(line) = lines.peek() {
312 if line.starts_with("@@") {
313 break;
314 }
315 lines.next();
316 }
317
318 // Parse hunks
319 while let Some(line) = lines.next() {
320 if line.starts_with("@@") {
321 let hunk = parse_hunk_header(line, &mut lines)?;
322 hunks.push(hunk);
323 }
324 }
325
326 Ok(hunks)
327 }
328
329 fn parse_unified_diff_files(
330 patch: &str,
331 create_if_missing: bool,
332 ) -> Result<Vec<FilePatch>, ToolError> {
333 let mut files = Vec::new();
334 let mut lines = patch.lines().peekable();
335 let mut current: Option<FilePatch> = None;
336 let mut old_path: Option<String> = None;
337
338 while let Some(line) = lines.next() {
339 if line.starts_with("diff --git ") {
340 if let Some(file) = current.take() {
341 files.push(file);
342 }
343 old_path = None;
344 continue;
345 }
346
347 if let Some(stripped) = line.strip_prefix("--- ") {
348 old_path = Some(stripped.trim().to_string());
349 continue;
350 }
351
352 if let Some(stripped) = line.strip_prefix("+++ ") {
353 let new_path = Some(stripped.trim().to_string());
354 let (path, delete_after, create_flag) =
355 resolve_diff_paths(old_path.as_deref(), new_path.as_deref(), create_if_missing)?;
356 old_path = None;
357 if let Some(file) = current.take() {
358 files.push(file);
359 }
360 current = Some(FilePatch {
361 path,
362 hunks: Vec::new(),
363 delete_after,
364 create_if_missing: create_flag,
365 });
366 continue;
367 }
368
369 if line.starts_with("@@") {
370 let Some(file) = current.as_mut() else {
371 if let Some(path) = old_path.as_deref() {
372 return Err(ToolError::invalid_input(format!(
373 "Patch hunk encountered after `--- {path}` but before a matching `+++` header. Each file section must include both headers."
374 )));
375 }
376 return Err(ToolError::invalid_input(
377 "Patch hunk encountered before any file header. Add `---`/`+++` headers or provide `path`.",
378 ));
379 };
380 let hunk = parse_hunk_header(line, &mut lines)?;
381 file.hunks.push(hunk);
382 }
383 }
384
385 if let Some(file) = current {
386 files.push(file);
387 }
388
389 Ok(files)
390 }
391
392 fn resolve_diff_paths(
393 old_path: Option<&str>,
394 new_path: Option<&str>,
395 create_if_missing: bool,
396 ) -> Result<(String, bool, bool), ToolError> {
397 let old_norm = old_path.and_then(normalize_diff_path);
398 let new_norm = new_path.and_then(normalize_diff_path);
399 let delete_after = new_norm.is_none();
400 let create_flag = create_if_missing || old_norm.is_none();
401 let path = new_norm
402 .or(old_norm)
403 .ok_or_else(|| ToolError::invalid_input("Patch is missing both old and new file paths"))?;
404 Ok((path, delete_after, create_flag))
405 }
406
407 fn normalize_diff_path(raw: &str) -> Option<String> {
408 let raw = raw.trim();
409 if raw.is_empty() {
410 return None;
411 }
412 if raw == "/dev/null" || raw == "dev/null" {
413 return None;
414 }
415 let raw = raw
416 .strip_prefix("a/")
417 .or_else(|| raw.strip_prefix("b/"))
418 .unwrap_or(raw);
419 Some(raw.to_string())
420 }
421
422 /// Parse a hunk header and its content
423 fn parse_hunk_header<'a, I>(
424 header: &str,
425 lines: &mut std::iter::Peekable<I>,
426 ) -> Result<Hunk, ToolError>
427 where
428 I: Iterator<Item = &'a str>,
429 {
430 // Parse @@ -old_start,old_count +new_start,new_count @@
431 let parts: Vec<&str> = header.split_whitespace().collect();
432 if parts.len() < 3 {
433 return Err(ToolError::invalid_input(format!(
434 "Invalid hunk header: {header}. Expected `@@ -start,count +start,count @@`."
435 )));
436 }
437
438 let old_range = parts[1].trim_start_matches('-');
439 let new_range = parts[2].trim_start_matches('+');
440
441 let (old_start, old_count) = parse_range(old_range)?;
442 let (new_start, new_count) = parse_range(new_range)?;
443
444 // Parse hunk lines
445 let mut hunk_lines = Vec::new();
446 let expected_lines = old_count.max(new_count) + old_count.min(new_count);
447
448 for _ in 0..expected_lines * 2 {
449 // Allow for more lines than expected
450 match lines.peek() {
451 Some(line) if line.starts_with("@@") => break,
452 Some(line) if line.starts_with('-') => {
453 hunk_lines.push(HunkLine::Remove(line[1..].to_string()));
454 lines.next();
455 }
456 Some(line) if line.starts_with('+') => {
457 hunk_lines.push(HunkLine::Add(line[1..].to_string()));
458 lines.next();
459 }
460 Some(line) if line.starts_with(' ') || line.is_empty() => {
461 let content = if line.is_empty() { "" } else { &line[1..] };
462 hunk_lines.push(HunkLine::Context(content.to_string()));
463 lines.next();
464 }
465 Some(line)
466 if line.starts_with("diff ")
467 || line.starts_with("--- ")
468 || line.starts_with("+++ ") =>
469 {
470 // Start of a new file patch - don't consume, let outer loop handle it
471 break;
472 }
473 Some(line) if !line.starts_with('\\') => {
474 // Treat as context line without leading space
475 hunk_lines.push(HunkLine::Context((*line).to_string()));
476 lines.next();
477 }
478 Some(_) => {
479 lines.next(); // Skip "\ No newline at end of file" etc
480 }
481 None => break,
482 }
483 }
484
485 Ok(Hunk {
486 old_start,
487 old_count,
488 new_start,
489 new_count,
490 lines: hunk_lines,
491 })
492 }
493
494 /// Parse a range like "10,5" or "10" into (start, count)
495 fn parse_range(range: &str) -> Result<(usize, usize), ToolError> {
496 let parts: Vec<&str> = range.split(',').collect();
497 let start = parts[0].parse::<usize>().map_err(|_| {
498 ToolError::invalid_input(format!(
499 "Invalid line number `{}` in hunk header. Use positive integers like `12` or `12,3`.",
500 parts[0]
501 ))
502 })?;
503 let count = if parts.len() > 1 {
504 parts[1].parse::<usize>().map_err(|_| {
505 ToolError::invalid_input(format!(
506 "Invalid line count `{}` in hunk header. Use positive integers like `3`.",
507 parts[1]
508 ))
509 })?
510 } else {
511 1
512 };
513 Ok((start, count))
514 }
515
516 fn inspect_patch_shape(patch: &str) -> PatchShape {
517 let mut shape = PatchShape::default();
518 let mut seen = HashSet::new();
519 let mut old_path: Option<String> = None;
520
521 for line in patch.lines() {
522 if line.starts_with("@@") {
523 shape.has_hunks = true;
524 }
525
526 if let Some(stripped) = line.strip_prefix("--- ") {
527 old_path = normalize_diff_path(stripped);
528 continue;
529 }
530
531 if let Some(stripped) = line.strip_prefix("+++ ") {
532 let new_path = normalize_diff_path(stripped);
533 let resolved = new_path.or(old_path.clone());
534 if let Some(path) = resolved
535 && seen.insert(path.clone())
536 {
537 shape.header_files.push(path);
538 }
539 old_path = None;
540 }
541 }
542
543 shape
544 }
545
546 fn validate_patch_shape(shape: &PatchShape, path_override: Option<&str>) -> Result<(), ToolError> {
547 if !shape.has_hunks {
548 return Err(ToolError::invalid_input(
549 "Patch must include at least one hunk header (`@@ -start,count +start,count @@`).",
550 ));
551 }
552
553 match path_override {
554 Some(_) if shape.file_count() > 1 => Err(ToolError::invalid_input(format!(
555 "Patch references multiple files ({}) but `path` was provided. Remove `path` to apply a multi-file patch, or provide a single-file patch.",
556 format_file_list(&shape.header_files),
557 ))),
558 None if shape.file_count() == 0 => Err(ToolError::invalid_input(
559 "Patch contains hunks but no file headers (`---`/`+++`). Provide `path` or add headers.",
560 )),
561 _ => Ok(()),
562 }
563 }
564
565 fn diff_header_mismatch(path_override: &str, shape: &PatchShape) -> Option<String> {
566 if shape.file_count() != 1 {
567 return None;
568 }
569 let header_path = &shape.header_files[0];
570 let override_norm = normalize_diff_path(path_override).unwrap_or_else(|| path_override.into());
571 if &override_norm == header_path {
572 None
573 } else {
574 Some(format!(
575 "Note: patch headers reference `{header_path}` but `path` overrides to `{override_norm}`."
576 ))
577 }
578 }
579
580 fn build_summary_message(stats: &PatchStatsExt) -> String {
581 let mut parts = Vec::new();
582 if stats.stats.hunks_total > 0 {
583 parts.push(format!(
584 "Applied {}/{} hunks across {} file(s).",
585 stats.stats.hunks_applied, stats.stats.hunks_total, stats.stats.files_applied
586 ));
587 } else {
588 parts.push(format!(
589 "Applied {} file change(s).",
590 stats.stats.files_applied
591 ));
592 }
593
594 if !stats.touched_files.is_empty() {
595 parts.push(format!(
596 "Files: {}.",
597 format_file_list(&stats.touched_files)
598 ));
599 }
600
601 if stats.stats.fuzz_used > 0 {
602 parts.push(format!(
603 "Fuzz used on {} hunk(s) (total fuzz: {}).",
604 stats.stats.hunks_with_fuzz, stats.stats.fuzz_used
605 ));
606 }
607
608 if let Some(note) = stats.header_path_mismatch.as_deref() {
609 parts.push(note.to_string());
610 }
611
612 parts.join(" ")
613 }
614
615 fn format_file_list(files: &[String]) -> String {
616 if files.is_empty() {
617 return "<none>".to_string();
618 }
619 let mut shown: Vec<String> = files.iter().take(FILE_LIST_LIMIT).cloned().collect();
620 let remaining = files.len().saturating_sub(shown.len());
621 if remaining > 0 {
622 shown.push(format!("... (+{remaining} more)"));
623 }
624 shown.join(", ")
625 }
626
627 fn push_unique(target: &mut Vec<String>, value: String) {
628 if !target.iter().any(|existing| existing == &value) {
629 target.push(value);
630 }
631 }
632
633 fn build_pending_writes_from_changes(
634 changes_value: &Value,
635 context: &ToolContext,
636 ) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
637 let changes = changes_value.as_array().ok_or_else(|| {
638 ToolError::invalid_input("`changes` must be an array of objects like {path, content}")
639 })?;
640 if changes.is_empty() {
641 return Err(ToolError::invalid_input("`changes` cannot be empty"));
642 }
643
644 let mut pending = Vec::new();
645 let mut stats = PatchStatsExt::default();
646 for change in changes {
647 let path = change
648 .get("path")
649 .and_then(Value::as_str)
650 .ok_or_else(|| ToolError::missing_field("changes[].path"))?;
651 let content = change
652 .get("content")
653 .and_then(Value::as_str)
654 .ok_or_else(|| ToolError::missing_field("changes[].content"))?;
655
656 let resolved = context.resolve_path(path)?;
657 let original = if resolved.exists() {
658 Some(read_file_content(&resolved)?)
659 } else {
660 None
661 };
662 let created = original.is_none();
663
664 pending.push(PendingWrite {
665 path: resolved,
666 content: Some(content.to_string()),
667 original,
668 });
669
670 stats.stats.files_total += 1;
671 stats.stats.files_applied += 1;
672 push_unique(&mut stats.touched_files, path.to_string());
673 stats.file_summaries.push(FileSummary {
674 path: path.to_string(),
675 hunks: 0,
676 hunks_applied: 0,
677 fuzz_used: 0,
678 hunks_with_fuzz: 0,
679 created,
680 deleted: false,
681 });
682 }
683
684 Ok((pending, stats))
685 }
686
687 fn build_pending_writes_from_patches(
688 file_patches: Vec<FilePatch>,
689 context: &ToolContext,
690 fuzz: usize,
691 ) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
692 let mut pending = Vec::new();
693 let mut stats = PatchStatsExt::default();
694 stats.stats.files_total = file_patches.len();
695
696 for file_patch in file_patches {
697 if file_patch.hunks.is_empty() {
698 return Err(ToolError::invalid_input(format!(
699 "Patch section for `{}` has no hunks (`@@ ... @@`).",
700 file_patch.path
701 )));
702 }
703
704 let resolved = context.resolve_path(&file_patch.path)?;
705 let original = if resolved.exists() {
706 Some(read_file_content(&resolved)?)
707 } else {
708 None
709 };
710
711 if original.is_none() && !file_patch.create_if_missing {
712 return Err(ToolError::execution_failed(format!(
713 "File `{}` does not exist at `{}`. Set create_if_missing=true for new files or include headers for file creation.",
714 file_patch.path,
715 resolved.display(),
716 )));
717 }
718
719 if file_patch.delete_after && original.is_none() {
720 return Err(ToolError::execution_failed(format!(
721 "File `{}` does not exist at `{}` to delete.",
722 file_patch.path,
723 resolved.display(),
724 )));
725 }
726
727 let base_content = original.clone().unwrap_or_default();
728 let mut lines: Vec<String> = if base_content.is_empty() {
729 Vec::new()
730 } else {
731 base_content.lines().map(String::from).collect()
732 };
733
734 let apply_stats =
735 apply_hunks_to_lines(&mut lines, &file_patch.hunks, fuzz, &file_patch.path)?;
736 stats.stats.hunks_applied += apply_stats.hunks_applied;
737 stats.stats.hunks_total += file_patch.hunks.len();
738 stats.stats.fuzz_used += apply_stats.fuzz_used;
739 stats.stats.hunks_with_fuzz += apply_stats.hunks_with_fuzz;
740 stats.stats.files_applied += 1;
741 push_unique(&mut stats.touched_files, file_patch.path.clone());
742 stats.file_summaries.push(FileSummary {
743 path: file_patch.path.clone(),
744 hunks: file_patch.hunks.len(),
745 hunks_applied: apply_stats.hunks_applied,
746 fuzz_used: apply_stats.fuzz_used,
747 hunks_with_fuzz: apply_stats.hunks_with_fuzz,
748 created: original.is_none() && !file_patch.delete_after,
749 deleted: file_patch.delete_after,
750 });
751
752 if file_patch.delete_after {
753 pending.push(PendingWrite {
754 path: resolved,
755 content: None,
756 original,
757 });
758 } else {
759 let new_content = lines.join("\n");
760 pending.push(PendingWrite {
761 path: resolved,
762 content: Some(new_content),
763 original,
764 });
765 }
766 }
767
768 Ok((pending, stats))
769 }
770
771 fn apply_pending_writes(pending: &[PendingWrite]) -> Result<(), ToolError> {
772 let mut applied = Vec::new();
773
774 for entry in pending {
775 let result = if let Some(content) = entry.content.as_ref() {
776 if let Some(parent) = entry.path.parent() {
777 fs::create_dir_all(parent).map_err(|e| {
778 ToolError::execution_failed(format!(
779 "Failed to create directory {}: {}",
780 parent.display(),
781 e
782 ))
783 })?;
784 }
785 fs::write(&entry.path, content).map_err(|e| {
786 ToolError::execution_failed(format!(
787 "Failed to write {}: {}",
788 entry.path.display(),
789 e
790 ))
791 })
792 } else if entry.path.exists() {
793 fs::remove_file(&entry.path).map_err(|e| {
794 ToolError::execution_failed(format!(
795 "Failed to delete {}: {}",
796 entry.path.display(),
797 e
798 ))
799 })
800 } else {
801 Ok(())
802 };
803
804 if let Err(err) = result {
805 rollback_pending_writes(&applied);
806 return Err(err);
807 }
808
809 applied.push(entry.clone());
810 }
811
812 Ok(())
813 }
814
815 fn rollback_pending_writes(applied: &[PendingWrite]) {
816 for entry in applied.iter().rev() {
817 match entry.original.as_ref() {
818 Some(content) => {
819 let _ = fs::write(&entry.path, content);
820 }
821 None => {
822 let _ = fs::remove_file(&entry.path);
823 }
824 }
825 }
826 }
827
828 fn read_file_content(path: &PathBuf) -> Result<String, ToolError> {
829 fs::read_to_string(path).map_err(|e| {
830 ToolError::execution_failed(format!("Failed to read {}: {}", path.display(), e))
831 })
832 }
833
834 fn preview_expected_lines(hunk: &Hunk, limit: usize) -> Vec<String> {
835 let mut preview = Vec::new();
836 for line in hunk.lines.iter().filter_map(|line| match line {
837 HunkLine::Context(s) => Some((" ", s)),
838 HunkLine::Remove(s) => Some(("-", s)),
839 HunkLine::Add(_) => None,
840 }) {
841 if preview.len() >= limit {
842 break;
843 }
844 preview.push(format!(" {}{}", line.0, line.1));
845 }
846 if preview.is_empty() {
847 preview.push(" <no context lines in hunk>".to_string());
848 }
849 preview
850 }
851
852 fn snippet_around(lines: &[String], line_1_based: usize, radius: usize) -> Vec<String> {
853 if lines.is_empty() {
854 return vec![" <empty file>".to_string()];
855 }
856
857 let center = line_1_based
858 .saturating_sub(1)
859 .min(lines.len().saturating_sub(1));
860 let start = center.saturating_sub(radius);
861 let end = (center + radius).min(lines.len().saturating_sub(1));
862
863 lines[start..=end]
864 .iter()
865 .enumerate()
866 .map(|(idx, line)| {
867 let line_no = start + idx + 1;
868 format!(" {line_no:>4}: {line}")
869 })
870 .collect()
871 }
872
873 fn format_hunk_no_match_error(
874 lines: &[String],
875 hunk: &Hunk,
876 err: &ApplyHunkError,
877 max_fuzz: usize,
878 ) -> String {
879 match err {
880 ApplyHunkError::NoMatch {
881 expected_line,
882 adjusted_line,
883 offset,
884 } => {
885 let expected_preview = preview_expected_lines(hunk, HUNK_PREVIEW_LINES).join("\n");
886 let file_preview = snippet_around(lines, *adjusted_line, SNIPPET_RADIUS).join("\n");
887 format!(
888 "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: ensure the patch matches the current file contents, increase `fuzz`, or regenerate the patch."
889 )
890 }
891 }
892 }
893
894 fn apply_hunks_to_lines(
895 lines: &mut Vec<String>,
896 hunks: &[Hunk],
897 fuzz: usize,
898 file_label: &str,
899 ) -> Result<HunkApplyStats, ToolError> {
900 let mut stats = HunkApplyStats::default();
901 let mut cumulative_offset: isize = 0;
902
903 for (idx, hunk) in hunks.iter().enumerate() {
904 match apply_hunk(lines, hunk, fuzz, &mut cumulative_offset) {
905 Ok(fuzz_used) => {
906 stats.fuzz_used += fuzz_used;
907 stats.hunks_applied += 1;
908 if fuzz_used > 0 {
909 stats.hunks_with_fuzz += 1;
910 }
911 }
912 Err(e) => {
913 let detail = format_hunk_no_match_error(lines, hunk, &e, fuzz);
914 return Err(ToolError::execution_failed(format!(
915 "Failed to apply hunk {}/{} for `{}`: {}",
916 idx + 1,
917 hunks.len(),
918 file_label,
919 detail
920 )));
921 }
922 }
923 }
924
925 Ok(stats)
926 }
927
928 /// Apply a hunk to the file content with fuzzy matching
929 fn apply_hunk(
930 lines: &mut Vec<String>,
931 hunk: &Hunk,
932 max_fuzz: usize,
933 cumulative_offset: &mut isize,
934 ) -> Result<usize, ApplyHunkError> {
935 // Build expected old lines from hunk
936 let old_lines: Vec<&str> = hunk
937 .lines
938 .iter()
939 .filter_map(|line| match line {
940 HunkLine::Context(s) | HunkLine::Remove(s) => Some(s.as_str()),
941 HunkLine::Add(_) => None,
942 })
943 .collect();
944
945 // Build new lines from hunk
946 let new_lines: Vec<String> = hunk
947 .lines
948 .iter()
949 .filter_map(|line| match line {
950 HunkLine::Context(s) | HunkLine::Add(s) => Some(s.clone()),
951 HunkLine::Remove(_) => None,
952 })
953 .collect();
954
955 // Try to find the location with fuzzy matching
956 // Apply cumulative offset from previous hunks
957 let base_idx = if hunk.old_start > 0 {
958 hunk.old_start - 1
959 } else {
960 0
961 };
962 let start_idx = ((base_idx as isize) + *cumulative_offset).max(0) as usize;
963
964 for fuzz in 0..=max_fuzz {
965 // Try at exact position first, then nearby
966 let search_range = if fuzz == 0 {
967 vec![start_idx]
968 } else {
969 let min = start_idx.saturating_sub(fuzz);
970 let max = (start_idx + fuzz).min(lines.len());
971 (min..=max).collect()
972 };
973
974 for pos in search_range {
975 if matches_at_position(lines, &old_lines, pos) {
976 // Apply the hunk
977 let end_pos = pos + old_lines.len();
978 lines.splice(pos..end_pos, new_lines.clone());
979
980 // Update cumulative offset: new lines added minus old lines removed
981 let delta = new_lines.len() as isize - old_lines.len() as isize;
982 *cumulative_offset += delta;
983
984 return Ok(fuzz);
985 }
986 }
987 }
988
989 // Special case: adding to empty file or new hunk at end
990 if old_lines.is_empty() && (lines.is_empty() || start_idx >= lines.len()) {
991 let delta = new_lines.len() as isize;
992 lines.extend(new_lines);
993 *cumulative_offset += delta;
994 return Ok(0);
995 }
996
997 Err(ApplyHunkError::NoMatch {
998 expected_line: hunk.old_start,
999 adjusted_line: start_idx + 1, // Convert back to 1-indexed
1000 offset: *cumulative_offset,
1001 })
1002 }
1003
1004 /// Check if `old_lines` match at the given position
1005 fn matches_at_position(lines: &[String], old_lines: &[&str], pos: usize) -> bool {
1006 if pos + old_lines.len() > lines.len() {
1007 return false;
1008 }
1009
1010 for (i, old_line) in old_lines.iter().enumerate() {
1011 // Normalize whitespace for comparison
1012 let file_line = lines[pos + i].trim_end();
1013 let expected = old_line.trim_end();
1014 if file_line != expected {
1015 return false;
1016 }
1017 }
1018
1019 true
1020 }
1021
1022 // === Unit Tests ===
1023
1024 #[cfg(test)]
1025 mod tests {
1026 use super::*;
1027 use tempfile::tempdir;
1028
1029 fn parse_patch_result(result: ToolResult) -> PatchResult {
1030 serde_json::from_str(&result.content).expect("patch result json")
1031 }
1032
1033 #[test]
1034 fn test_parse_range() {
1035 assert_eq!(parse_range("10,5").unwrap(), (10, 5));
1036 assert_eq!(parse_range("10").unwrap(), (10, 1));
1037 assert_eq!(parse_range("1,0").unwrap(), (1, 0));
1038 }
1039
1040 #[test]
1041 fn test_parse_unified_diff() {
1042 let patch = r"--- a/test.txt
1043 +++ b/test.txt
1044 @@ -1,3 +1,3 @@
1045 line1
1046 -line2
1047 +modified line2
1048 line3
1049 ";
1050
1051 let hunks = parse_unified_diff(patch).unwrap();
1052 assert_eq!(hunks.len(), 1);
1053 assert_eq!(hunks[0].old_start, 1);
1054 assert_eq!(hunks[0].old_count, 3);
1055 assert_eq!(hunks[0].new_start, 1);
1056 assert_eq!(hunks[0].new_count, 3);
1057 }
1058
1059 #[test]
1060 fn test_apply_hunk_simple() {
1061 let mut lines = vec![
1062 "line1".to_string(),
1063 "line2".to_string(),
1064 "line3".to_string(),
1065 ];
1066
1067 let hunk = Hunk {
1068 old_start: 1,
1069 old_count: 3,
1070 new_start: 1,
1071 new_count: 3,
1072 lines: vec![
1073 HunkLine::Context("line1".to_string()),
1074 HunkLine::Remove("line2".to_string()),
1075 HunkLine::Add("modified".to_string()),
1076 HunkLine::Context("line3".to_string()),
1077 ],
1078 };
1079
1080 let mut offset: isize = 0;
1081 let fuzz = apply_hunk(&mut lines, &hunk, 0, &mut offset).unwrap();
1082 assert_eq!(fuzz, 0);
1083 assert_eq!(lines, vec!["line1", "modified", "line3"]);
1084 }
1085
1086 #[test]
1087 fn test_apply_hunk_with_fuzz() {
1088 let mut lines = vec![
1089 "line0".to_string(),
1090 "line1".to_string(),
1091 "line2".to_string(),
1092 "line3".to_string(),
1093 ];
1094
1095 // Hunk expects to start at line 1, but content is at line 2
1096 let hunk = Hunk {
1097 old_start: 1, // Wrong position
1098 old_count: 2,
1099 new_start: 1,
1100 new_count: 2,
1101 lines: vec![
1102 HunkLine::Remove("line1".to_string()),
1103 HunkLine::Add("modified".to_string()),
1104 HunkLine::Context("line2".to_string()),
1105 ],
1106 };
1107
1108 let mut offset: isize = 0;
1109 let fuzz = apply_hunk(&mut lines, &hunk, 3, &mut offset).unwrap();
1110 assert!(fuzz > 0);
1111 assert_eq!(lines, vec!["line0", "modified", "line2", "line3"]);
1112 }
1113
1114 #[test]
1115 fn test_apply_hunk_no_match_returns_error() {
1116 let mut lines = vec!["line1".to_string(), "line2".to_string()];
1117 let hunk = Hunk {
1118 old_start: 5,
1119 old_count: 1,
1120 new_start: 5,
1121 new_count: 1,
1122 lines: vec![
1123 HunkLine::Context("missing".to_string()),
1124 HunkLine::Add("new".to_string()),
1125 ],
1126 };
1127
1128 let mut offset: isize = 0;
1129 let err = apply_hunk(&mut lines, &hunk, 0, &mut offset).unwrap_err();
1130 assert!(matches!(
1131 err,
1132 ApplyHunkError::NoMatch {
1133 expected_line: 5,
1134 ..
1135 }
1136 ));
1137 }
1138
1139 #[tokio::test]
1140 async fn test_apply_patch_tool() {
1141 let tmp = tempdir().expect("tempdir");
1142 let ctx = ToolContext::new(tmp.path().to_path_buf());
1143
1144 // Create a test file
1145 fs::write(tmp.path().join("test.txt"), "line1\nline2\nline3\n").expect("write");
1146
1147 let patch = r"--- a/test.txt
1148 +++ b/test.txt
1149 @@ -1,3 +1,3 @@
1150 line1
1151 -line2
1152 +modified
1153 line3
1154 ";
1155
1156 let tool = ApplyPatchTool;
1157 let result = tool
1158 .execute(json!({"path": "test.txt", "patch": patch}), &ctx)
1159 .await
1160 .expect("execute");
1161
1162 assert!(result.success);
1163 let patch_result = parse_patch_result(result);
1164 assert_eq!(patch_result.touched_files, vec!["test.txt"]);
1165 assert_eq!(patch_result.hunks_applied, 1);
1166
1167 // Verify the patch was applied
1168 let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
1169 assert!(content.contains("modified"));
1170 assert!(!content.contains("line2"));
1171 }
1172
1173 #[tokio::test]
1174 async fn test_apply_patch_add_lines() {
1175 let tmp = tempdir().expect("tempdir");
1176 let ctx = ToolContext::new(tmp.path().to_path_buf());
1177
1178 fs::write(tmp.path().join("test.txt"), "line1\nline3\n").expect("write");
1179
1180 let patch = r"@@ -1,2 +1,3 @@
1181 line1
1182 +line2
1183 line3
1184 ";
1185
1186 let tool = ApplyPatchTool;
1187 let result = tool
1188 .execute(json!({"path": "test.txt", "patch": patch}), &ctx)
1189 .await
1190 .expect("execute");
1191
1192 assert!(result.success);
1193 let patch_result = parse_patch_result(result);
1194 assert_eq!(patch_result.touched_files, vec!["test.txt"]);
1195
1196 let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
1197 assert!(content.contains("line2"));
1198 }
1199
1200 #[tokio::test]
1201 async fn test_apply_patch_create_new_file() {
1202 let tmp = tempdir().expect("tempdir");
1203 let ctx = ToolContext::new(tmp.path().to_path_buf());
1204
1205 let patch = r"@@ -0,0 +1,3 @@
1206 +line1
1207 +line2
1208 +line3
1209 ";
1210
1211 let tool = ApplyPatchTool;
1212 let result = tool
1213 .execute(
1214 json!({"path": "new_file.txt", "patch": patch, "create_if_missing": true}),
1215 &ctx,
1216 )
1217 .await
1218 .expect("execute");
1219
1220 assert!(result.success);
1221 let patch_result = parse_patch_result(result);
1222 assert_eq!(patch_result.touched_files, vec!["new_file.txt"]);
1223 assert!(patch_result.file_summaries.first().unwrap().created);
1224 assert!(tmp.path().join("new_file.txt").exists());
1225 }
1226
1227 #[tokio::test]
1228 async fn test_apply_patch_changes_list() {
1229 let tmp = tempdir().expect("tempdir");
1230 let ctx = ToolContext::new(tmp.path().to_path_buf());
1231
1232 fs::write(tmp.path().join("one.txt"), "old\n").expect("write");
1233
1234 let tool = ApplyPatchTool;
1235 let result = tool
1236 .execute(
1237 json!({
1238 "changes": [
1239 { "path": "one.txt", "content": "new\n" },
1240 { "path": "two.txt", "content": "second\n" }
1241 ]
1242 }),
1243 &ctx,
1244 )
1245 .await
1246 .expect("execute");
1247
1248 assert!(result.success);
1249 let patch_result = parse_patch_result(result);
1250 let mut touched = patch_result.touched_files.clone();
1251 touched.sort();
1252 assert_eq!(touched, vec!["one.txt", "two.txt"]);
1253 assert_eq!(patch_result.hunks_total, 0);
1254 assert_eq!(
1255 fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
1256 "new\n"
1257 );
1258 assert_eq!(
1259 fs::read_to_string(tmp.path().join("two.txt")).unwrap(),
1260 "second\n"
1261 );
1262 }
1263
1264 #[tokio::test]
1265 async fn test_apply_patch_multi_file_diff() {
1266 let tmp = tempdir().expect("tempdir");
1267 let ctx = ToolContext::new(tmp.path().to_path_buf());
1268
1269 fs::write(tmp.path().join("a.txt"), "line1\nline2\n").expect("write");
1270 fs::write(tmp.path().join("b.txt"), "alpha\nbeta\n").expect("write");
1271
1272 let patch = r"diff --git a/a.txt b/a.txt
1273 --- a/a.txt
1274 +++ b/a.txt
1275 @@ -1,2 +1,2 @@
1276 line1
1277 -line2
1278 +line2-mod
1279 diff --git a/b.txt b/b.txt
1280 --- a/b.txt
1281 +++ b/b.txt
1282 @@ -1,2 +1,3 @@
1283 alpha
1284 +beta2
1285 beta
1286 ";
1287
1288 let tool = ApplyPatchTool;
1289 let result = tool
1290 .execute(json!({"patch": patch}), &ctx)
1291 .await
1292 .expect("execute");
1293
1294 assert!(result.success);
1295 let patch_result = parse_patch_result(result);
1296 let mut touched = patch_result.touched_files.clone();
1297 touched.sort();
1298 assert_eq!(touched, vec!["a.txt", "b.txt"]);
1299 assert_eq!(patch_result.files_applied, 2);
1300 let a = fs::read_to_string(tmp.path().join("a.txt")).unwrap();
1301 let b = fs::read_to_string(tmp.path().join("b.txt")).unwrap();
1302 assert!(a.contains("line2-mod"));
1303 assert!(b.contains("beta2"));
1304 }
1305
1306 #[tokio::test]
1307 async fn test_apply_patch_requires_headers_without_path() {
1308 let tmp = tempdir().expect("tempdir");
1309 let ctx = ToolContext::new(tmp.path().to_path_buf());
1310 let tool = ApplyPatchTool;
1311
1312 let patch = r"@@ -1,1 +1,1 @@
1313 -old
1314 +new
1315 ";
1316
1317 let err = tool
1318 .execute(json!({"patch": patch}), &ctx)
1319 .await
1320 .unwrap_err();
1321 match err {
1322 ToolError::InvalidInput { message } => {
1323 assert!(message.contains("no file headers"));
1324 assert!(message.contains("Provide `path`"));
1325 }
1326 other => panic!("expected invalid input, got: {other}"),
1327 }
1328 }
1329
1330 #[tokio::test]
1331 async fn test_path_override_rejects_multi_file_diff() {
1332 let tmp = tempdir().expect("tempdir");
1333 let ctx = ToolContext::new(tmp.path().to_path_buf());
1334 let tool = ApplyPatchTool;
1335
1336 let patch = r"diff --git a/a.txt b/a.txt
1337 --- a/a.txt
1338 +++ b/a.txt
1339 @@ -1,1 +1,1 @@
1340 -one
1341 +one-mod
1342 diff --git a/b.txt b/b.txt
1343 --- a/b.txt
1344 +++ b/b.txt
1345 @@ -1,1 +1,1 @@
1346 -two
1347 +two-mod
1348 ";
1349
1350 let err = tool
1351 .execute(json!({"path": "a.txt", "patch": patch}), &ctx)
1352 .await
1353 .unwrap_err();
1354 match err {
1355 ToolError::InvalidInput { message } => {
1356 assert!(message.contains("multiple files"));
1357 assert!(message.contains("a.txt"));
1358 assert!(message.contains("b.txt"));
1359 }
1360 other => panic!("expected invalid input, got: {other}"),
1361 }
1362 }
1363
1364 #[tokio::test]
1365 async fn test_apply_patch_summary_reports_fuzz() {
1366 let tmp = tempdir().expect("tempdir");
1367 let ctx = ToolContext::new(tmp.path().to_path_buf());
1368 let tool = ApplyPatchTool;
1369
1370 fs::write(tmp.path().join("test.txt"), "line0\nline1\nline2\nline3\n").expect("write");
1371
1372 let patch = r"@@ -1,2 +1,2 @@
1373 -line1
1374 +modified
1375 line2
1376 ";
1377
1378 let result = tool
1379 .execute(json!({"path": "test.txt", "patch": patch, "fuzz": 3}), &ctx)
1380 .await
1381 .expect("execute");
1382 assert!(result.success);
1383 let patch_result = parse_patch_result(result);
1384 assert_eq!(patch_result.hunks_with_fuzz, 1);
1385 assert!(patch_result.fuzz_used > 0);
1386 assert!(patch_result.message.contains("Fuzz used"));
1387 let summary = patch_result.file_summaries.first().unwrap();
1388 assert_eq!(summary.hunks_with_fuzz, 1);
1389 }
1390
1391 #[tokio::test]
1392 async fn test_path_override_header_mismatch_note() {
1393 let tmp = tempdir().expect("tempdir");
1394 let ctx = ToolContext::new(tmp.path().to_path_buf());
1395 let tool = ApplyPatchTool;
1396
1397 fs::write(tmp.path().join("override.txt"), "old\n").expect("write");
1398
1399 let patch = r"--- a/other.txt
1400 +++ b/other.txt
1401 @@ -1,1 +1,1 @@
1402 -old
1403 +new
1404 ";
1405
1406 let result = tool
1407 .execute(json!({"path": "override.txt", "patch": patch}), &ctx)
1408 .await
1409 .expect("execute");
1410 let patch_result = parse_patch_result(result);
1411 assert!(
1412 patch_result
1413 .message
1414 .contains("headers reference `other.txt`")
1415 );
1416 assert!(
1417 patch_result
1418 .message
1419 .contains("path` overrides to `override.txt`")
1420 );
1421 }
1422
1423 #[test]
1424 fn test_apply_patch_tool_properties() {
1425 let tool = ApplyPatchTool;
1426 assert_eq!(tool.name(), "apply_patch");
1427 assert!(!tool.is_read_only());
1428 assert!(tool.is_sandboxable());
1429 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
1430 }
1431
1432 #[test]
1433 fn test_multi_hunk_offset_tracking() {
1434 // File with 6 lines
1435 let mut lines: Vec<String> = vec![
1436 "line1".to_string(),
1437 "line2".to_string(),
1438 "line3".to_string(),
1439 "line4".to_string(),
1440 "line5".to_string(),
1441 "line6".to_string(),
1442 ];
1443
1444 // Hunk 1: Add 2 lines after line1 (offset becomes +2)
1445 let hunk1 = Hunk {
1446 old_start: 1,
1447 old_count: 2,
1448 new_start: 1,
1449 new_count: 4,
1450 lines: vec![
1451 HunkLine::Context("line1".to_string()),
1452 HunkLine::Add("new_a".to_string()),
1453 HunkLine::Add("new_b".to_string()),
1454 HunkLine::Context("line2".to_string()),
1455 ],
1456 };
1457
1458 // Hunk 2: Modify line5 (originally at position 5, now at position 7 due to +2 offset)
1459 let hunk2 = Hunk {
1460 old_start: 5, // Original position in the diff
1461 old_count: 1,
1462 new_start: 7,
1463 new_count: 1,
1464 lines: vec![
1465 HunkLine::Remove("line5".to_string()),
1466 HunkLine::Add("modified5".to_string()),
1467 ],
1468 };
1469
1470 let mut offset: isize = 0;
1471
1472 // Apply first hunk
1473 let fuzz1 = apply_hunk(&mut lines, &hunk1, 3, &mut offset).unwrap();
1474 assert_eq!(fuzz1, 0);
1475 assert_eq!(offset, 2); // Added 2 lines (4 new - 2 old)
1476 assert_eq!(
1477 lines,
1478 vec![
1479 "line1", "new_a", "new_b", "line2", "line3", "line4", "line5", "line6"
1480 ]
1481 );
1482
1483 // Apply second hunk - this would fail without offset tracking!
1484 let fuzz2 = apply_hunk(&mut lines, &hunk2, 3, &mut offset).unwrap();
1485 assert_eq!(fuzz2, 0);
1486 assert!(lines.contains(&"modified5".to_string()));
1487 assert!(!lines.contains(&"line5".to_string()));
1488 }
1489 }
1490
1490 lines RUST