1use crate::patch::matcher::{
7 find_nearest_miss, seek_sequence_tiered, NearestMiss, NearestMissSearch, SequenceMatch,
8 NEAREST_MISS_MAX_FILE_BYTES,
9};
10use crate::patch::parser::UpdateFileChunk;
11
12const NEAREST_MISS_RENDER_LINES: usize = 20;
13
14pub fn seek_sequence(
16 lines: &[&str],
17 pattern: &[&str],
18 start_index: usize,
19 eof: bool,
20) -> Option<usize> {
21 seek_sequence_tiered(lines, pattern, start_index, eof)
22 .map(|sequence_match| sequence_match.found)
23}
24
25fn line_refs(lines: &[String]) -> Vec<&str> {
26 lines.iter().map(String::as_str).collect()
27}
28
29fn seek_sequence_tiered_strings(
30 lines: &[String],
31 pattern: &[String],
32 start_index: usize,
33 eof: bool,
34) -> Option<SequenceMatch> {
35 let line_refs = line_refs(lines);
36 let pattern_refs: Vec<&str> = pattern.iter().map(String::as_str).collect();
37 seek_sequence_tiered(&line_refs, &pattern_refs, start_index, eof)
38}
39
40fn seek_sequence_strings(
41 lines: &[String],
42 pattern: &[String],
43 start_index: usize,
44 eof: bool,
45) -> Option<usize> {
46 let line_refs = line_refs(lines);
47 let pattern_refs: Vec<&str> = pattern.iter().map(String::as_str).collect();
48 seek_sequence(&line_refs, &pattern_refs, start_index, eof)
49}
50
51fn inline_code(value: &str) -> String {
52 format!("`{}`", value.replace('`', "\\`"))
53}
54
55fn render_found_nearest_miss(lines: &[&str], pattern: &[&str], nearest: NearestMiss) -> String {
56 let start_line = nearest.start + 1;
57 let end_line = nearest.end;
58 let mut rendered = format!(
59 "Nearest miss at lines {start_line}-{end_line} (matched {}/{} context lines):",
60 nearest.matched_lines,
61 pattern.len()
62 );
63 let line_number_width = end_line.to_string().len();
64 let available_lines = nearest.end.saturating_sub(nearest.start);
65 for (offset, line) in lines[nearest.start..nearest.end]
66 .iter()
67 .take(NEAREST_MISS_RENDER_LINES)
68 .enumerate()
69 {
70 let line_number = nearest.start + offset + 1;
71 rendered.push_str(&format!(
72 "\n {line_number:>line_number_width$} | {line}",
73 line_number_width = line_number_width
74 ));
75 }
76 if available_lines > NEAREST_MISS_RENDER_LINES {
77 rendered.push_str(&format!(
78 "\n ... ({} more candidate lines truncated)",
79 available_lines - NEAREST_MISS_RENDER_LINES
80 ));
81 }
82
83 if nearest.first_divergence < pattern.len() {
84 let wanted_line = pattern[nearest.first_divergence];
85 let file_line_number = nearest.start + nearest.first_divergence + 1;
86 let actual_line = lines
87 .get(nearest.start + nearest.first_divergence)
88 .copied()
89 .unwrap_or("<EOF>");
90 rendered.push_str(&format!(
91 "\nFirst divergence: wanted line {} {} vs file line {file_line_number} {}",
92 nearest.first_divergence + 1,
93 inline_code(wanted_line),
94 inline_code(actual_line)
95 ));
96 } else {
97 rendered.push_str(
98 "\nFirst divergence: none within the candidate window; the hunk placement constraint did not match.",
99 );
100 }
101
102 rendered
103}
104
105fn render_nearest_miss(lines: &[&str], pattern: &[&str], file_size_bytes: usize) -> String {
106 match find_nearest_miss(lines, pattern, file_size_bytes) {
107 NearestMissSearch::Found(nearest) => render_found_nearest_miss(lines, pattern, nearest),
108 NearestMissSearch::NoSimilarRegion => "Nearest miss: no similar region found.".to_owned(),
109 NearestMissSearch::SkippedLargeFile => format!(
110 "Nearest miss skipped: file is {file_size_bytes} bytes, above the {} MiB diagnostic limit.",
111 NEAREST_MISS_MAX_FILE_BYTES / (1024 * 1024)
112 ),
113 }
114}
115
116pub fn apply_update_chunks(
118 original_content: &str,
119 file_path: &str,
120 chunks: &[UpdateFileChunk],
121) -> Result<String, String> {
122 let mut original_lines: Vec<String> = original_content
123 .split('\n')
124 .map(ToOwned::to_owned)
125 .collect();
126
127 if original_lines.last().is_some_and(String::is_empty) {
128 original_lines.pop();
129 }
130
131 let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
132 let mut line_index = 0;
133
134 for chunk in chunks {
135 let change_context = chunk
136 .change_context
137 .as_ref()
138 .filter(|context| !context.is_empty());
139 if let Some(context) = change_context {
140 let line_refs = line_refs(&original_lines);
141 let context_pattern = [context.as_str()];
142 let Some(context_match) =
143 seek_sequence_tiered(&line_refs, &context_pattern, line_index, false)
144 else {
145 return Err(format!("Failed to find context '{context}' in {file_path}"));
146 };
147 line_index = context_match.found + context_match.line_count;
148 }
149
150 if chunk.old_lines.is_empty() {
151 let insertion_idx = if change_context.is_some() {
152 line_index
153 } else if original_lines.last().is_some_and(String::is_empty) {
154 original_lines.len() - 1
155 } else {
156 original_lines.len()
157 };
158 replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
159 continue;
160 }
161
162 let mut pattern = chunk.old_lines.clone();
163 let mut new_slice = chunk.new_lines.clone();
164 let mut matched = seek_sequence_tiered_strings(
165 &original_lines,
166 &pattern,
167 line_index,
168 chunk.is_end_of_file,
169 );
170
171 if matched.is_none() && pattern.last().is_some_and(String::is_empty) {
172 pattern.pop();
173 if new_slice.last().is_some_and(String::is_empty) {
174 new_slice.pop();
175 }
176 matched = seek_sequence_tiered_strings(
177 &original_lines,
178 &pattern,
179 line_index,
180 chunk.is_end_of_file,
181 );
182 }
183
184 if let Some(sequence_match) = matched {
185 replacements.push((sequence_match.found, sequence_match.line_count, new_slice));
186 line_index = sequence_match.found + sequence_match.line_count;
187 } else {
188 let new_slice_trimmed: Vec<String> = new_slice
189 .iter()
190 .filter(|line| !line.trim().is_empty())
191 .cloned()
192 .collect();
193 let already_applied = !new_slice_trimmed.is_empty()
194 && seek_sequence_strings(
195 &original_lines,
196 &new_slice_trimmed,
197 0,
198 chunk.is_end_of_file,
199 )
200 .is_some();
201
202 let line_refs = line_refs(&original_lines);
203 let pattern_refs: Vec<&str> = pattern.iter().map(String::as_str).collect();
204 let nearest_miss =
205 render_nearest_miss(&line_refs, &pattern_refs, original_content.len());
206 let tried_tiers =
207 "exact, trimEnd, trim, indent (tab/space), unicode, reflow (whitespace-normalized)";
208 let already_applied_hint = if already_applied {
209 "\n\nHint: the replacement content for this hunk already appears in the file. \
210 The patch may have been partially applied in a prior turn — re-read the file \
211 to confirm which hunks still need to apply."
212 } else {
213 ""
214 };
215
216 return Err(format!(
217 "Failed to find expected lines in {file_path}:\n{}\n\n\
218 Tried match tiers: {tried_tiers}.\n\n{nearest_miss}{already_applied_hint}",
219 chunk.old_lines.join("\n")
220 ));
221 }
222 }
223
224 replacements.sort_by(|left, right| left.0.cmp(&right.0));
225
226 let mut result = original_lines;
227 for (start_idx, old_len, new_segment) in replacements.into_iter().rev() {
228 result.splice(start_idx..start_idx + old_len, new_segment);
229 }
230
231 if result.last().is_none_or(|line| !line.is_empty()) {
232 result.push(String::new());
233 }
234
235 Ok(result.join("\n"))
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn chunk(old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
243 UpdateFileChunk {
244 old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
245 new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
246 change_context: None,
247 is_end_of_file: false,
248 }
249 }
250
251 fn context_chunk(context: &str, old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
252 UpdateFileChunk {
253 old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
254 new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
255 change_context: Some(context.to_owned()),
256 is_end_of_file: false,
257 }
258 }
259
260 fn eof_chunk(old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
261 UpdateFileChunk {
262 old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
263 new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
264 change_context: None,
265 is_end_of_file: true,
266 }
267 }
268
269 fn assert_apply_error(original: &str, file_path: &str, chunks: &[UpdateFileChunk]) -> String {
270 apply_update_chunks(original, file_path, chunks).unwrap_err()
271 }
272
273 #[test]
274 fn missing_change_context_matches_patch_parser_test_60_72() {
275 let chunks = [context_chunk("missing line", &["beta"], &["updated beta"])];
276 assert_eq!(
277 assert_apply_error("alpha\nbeta\n", "src/example.ts", &chunks),
278 "Failed to find context 'missing line' in src/example.ts"
279 );
280 }
281
282 #[test]
283 fn missing_old_lines_error_format_matches_patch_parser_test_74_85() {
284 let chunks = [chunk(&["missing line"], &["replacement line"])];
285 assert_eq!(
286 assert_apply_error("alpha\nbeta\n", "src/example.ts", &chunks),
287 "Failed to find expected lines in src/example.ts:\nmissing line\n\n\
288 Tried match tiers: exact, trimEnd, trim, indent (tab/space), unicode, reflow (whitespace-normalized).\n\n\
289 Nearest miss: no similar region found."
290 );
291 }
292
293 #[test]
294 fn already_applied_hint_matches_patch_parser_test_87_103() {
295 let chunks = [chunk(
296 &["const mainQuota = await getFreshMainQuota(auth.access, storage)"],
297 &["const mainQuota = await getMainQuotaForRouting(auth.access, storage)"],
298 )];
299 let file_with_rewrite_already_applied =
300 "alpha\nconst mainQuota = await getMainQuotaForRouting(auth.access, storage)\nbeta\n";
301
302 assert!(
303 assert_apply_error(file_with_rewrite_already_applied, "src/example.ts", &chunks)
304 .contains("already appears in the file")
305 );
306 }
307
308 #[test]
309 fn absent_old_and_new_lines_have_no_already_applied_hint_matches_patch_parser_test_105_122() {
310 let chunks = [chunk(&["missing old line"], &["missing new line"])];
311 let message = assert_apply_error("unrelated content\n", "src/example.ts", &chunks);
312 assert!(message.contains("Failed to find expected lines"));
313 assert!(!message.contains("already appears in the file"));
314 }
315
316 #[test]
317 fn spaces_patch_matches_tab_file_matches_patch_parser_test_124_147() {
318 let file = "function foo() {\n\treturn 42;\n}\n";
319 let chunks = [chunk(&[" return 42;"], &[" return 43;"])];
320
321 assert_eq!(
322 apply_update_chunks(file, "src/foo.ts", &chunks).unwrap(),
323 "function foo() {\n return 43;\n}\n"
324 );
325 }
326
327 #[test]
328 fn tab_patch_matches_spaces_file_matches_patch_parser_test_149_162() {
329 let file = "function foo() {\n return 42;\n}\n";
330 let chunks = [chunk(&["\treturn 42;"], &["\treturn 43;"])];
331
332 assert_eq!(
333 apply_update_chunks(file, "src/foo.ts", &chunks).unwrap(),
334 "function foo() {\n\treturn 43;\n}\n"
335 );
336 }
337
338 #[test]
339 fn closest_match_diagnostic_matches_patch_parser_test_164_195() {
340 let file =
341 "function foo() {\n const x = 1;\n const y = 2;\n const z = 3;\n return x + y + z;\n}\n";
342 let chunks = [chunk(
343 &[" const x = 1;", " const y = 2;", " const Q = 99;"],
344 &[" const x = 1;", " const y = 2;", " const Q = 100;"],
345 )];
346
347 let message = assert_apply_error(file, "src/foo.ts", &chunks);
348 assert!(message.contains("Nearest miss at lines 2-4 (matched 2/3 context lines):"));
349 assert!(message.contains(" 2 | const x = 1;"));
350 assert!(message.contains(" 3 | const y = 2;"));
351 assert!(message.contains(" 4 | const z = 3;"));
352 assert!(message.contains(
353 "First divergence: wanted line 3 ` const Q = 99;` vs file line 4 ` const z = 3;`"
354 ));
355 }
356
357 #[test]
358 fn tried_tiers_diagnostic_matches_patch_parser_test_197_221() {
359 let chunks = [chunk(&["completely unrelated line"], &["replacement"])];
360 let message = assert_apply_error("alpha\nbeta\ngamma\n", "src/foo.ts", &chunks);
361
362 assert!(message.contains("Tried match tiers:"));
363 assert!(message.contains("exact"));
364 assert!(message.contains("trim"));
365 assert!(message.contains("indent"));
366 assert!(message.contains("unicode"));
367 }
368
369 #[test]
370 fn oversized_file_failure_explains_that_nearest_miss_was_skipped() {
371 let synthetic_file = "x".repeat(NEAREST_MISS_MAX_FILE_BYTES + 1);
372 let chunks = [chunk(&["wanted content"], &["replacement"])];
373
374 let message = assert_apply_error(&synthetic_file, "src/large.txt", &chunks);
375 assert!(message.contains("Nearest miss skipped: file is"));
376 assert!(message.contains("above the 2 MiB diagnostic limit"));
377 }
378
379 #[test]
380 fn reflow_one_line_to_three_line_split_matches_patch_parser_test_225_240() {
381 let original =
382 "function demo() {\n const value = alpha +\n beta +\n gamma;\n return value;\n}\n";
383 let chunks = [chunk(
384 &[" const value = alpha + beta + gamma;"],
385 &[" const value = alpha + beta + delta;"],
386 )];
387
388 assert_eq!(
389 apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
390 "function demo() {\n const value = alpha + beta + delta;\n return value;\n}\n"
391 );
392 }
393
394 #[test]
395 fn reflow_three_line_to_one_line_join_matches_patch_parser_test_242_254() {
396 let original = "function demo() {\n const value = alpha + beta + gamma;\n}\n";
397 let chunks = [chunk(
398 &[" const value = alpha +", " beta +", " gamma;"],
399 &[" const value = alpha +", " beta +", " delta;"],
400 )];
401
402 assert_eq!(
403 apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
404 "function demo() {\n const value = alpha +\n beta +\n delta;\n}\n"
405 );
406 }
407
408 #[test]
409 fn ambiguous_reflow_rejects_matches_patch_parser_test_256_269() {
410 let original =
411 "const value = alpha +\n beta +\n gamma;\n\nconst value = alpha +\n beta +\n gamma;\n";
412 let chunks = [chunk(
413 &["const value = alpha + beta + gamma;"],
414 &["const value = alpha + beta + delta;"],
415 )];
416
417 assert!(assert_apply_error(original, "src/demo.ts", &chunks)
418 .contains("Failed to find expected lines in src/demo.ts"));
419 }
420
421 #[test]
422 fn reflow_near_miss_rejects_matches_patch_parser_test_271_282() {
423 let chunks = [chunk(
424 &["const value = alpha + beta + delta;"],
425 &["const value = alpha + beta + epsilon;"],
426 )];
427
428 assert!(assert_apply_error(
429 "const value = alpha +\n beta +\n gamma;\n",
430 "src/demo.ts",
431 &chunks,
432 )
433 .contains("Failed to find expected lines in src/demo.ts"));
434 }
435
436 #[test]
437 fn contiguous_match_wins_before_reflow_matches_patch_parser_test_284_299() {
438 let original =
439 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + gamma;\n";
440 let chunks = [chunk(
441 &["const value = alpha + beta + gamma;"],
442 &["const value = alpha + beta + delta;"],
443 )];
444
445 assert_eq!(
446 apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
447 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + delta;\n"
448 );
449 }
450
451 #[test]
452 fn strict_tiers_stay_ahead_of_reflow_matches_patch_parser_test_301_342() {
453 let cases = [
454 (
455 "src/rstrip.ts",
456 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + gamma; \n",
457 vec!["const value = alpha + beta + gamma;"],
458 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + delta;\n",
459 ),
460 (
461 "src/trim.ts",
462 "const value = alpha +\n beta +\n gamma;\n const value = alpha + beta + gamma;\n",
463 vec!["const value = alpha + beta + gamma;"],
464 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + delta;\n",
465 ),
466 (
467 "src/unicode.ts",
468 "const label =\n \"alpha\";\nconst label = “alpha”;\n",
469 vec!["const label = \"alpha\";"],
470 "const label =\n \"alpha\";\nconst value = alpha + beta + delta;\n",
471 ),
472 ];
473
474 for (file_path, original, old_lines, expected) in cases {
475 let chunks = [chunk(&old_lines, &["const value = alpha + beta + delta;"])];
476 assert_eq!(
477 apply_update_chunks(original, file_path, &chunks).unwrap(),
478 expected
479 );
480 }
481 }
482
483 #[test]
484 fn pure_insertion_with_context_matches_patch_parser_test_345_367() {
485 let original = "function foo() {\n return 1;\n}\n\nfunction bar() {\n return 2;\n}\n";
486 let chunks = [context_chunk("function foo() {", &[], &[" const x = 42;"])];
487
488 let result = apply_update_chunks(original, "src/example.ts", &chunks).unwrap();
489 assert_eq!(
490 result,
491 "function foo() {\n const x = 42;\n return 1;\n}\n\nfunction bar() {\n return 2;\n}\n"
492 );
493 assert!(!result.contains(" return 2;\n const x = 42;"));
494 }
495
496 #[test]
497 fn pure_insertion_without_context_matches_patch_parser_test_369_380() {
498 let original = "alpha\nbeta\n";
499 let chunks = [chunk(&[], &["gamma"])];
500
501 assert_eq!(
502 apply_update_chunks(original, "src/example.ts", &chunks).unwrap(),
503 "alpha\nbeta\ngamma\n"
504 );
505 }
506
507 #[test]
508 fn pure_insertion_does_not_short_circuit_matches_patch_parser_test_382_397() {
509 let original = "import a;\nimport b;\n\nconst x = 1;\n";
510 let chunks = [context_chunk("import a;", &[], &["import inserted;"])];
511
512 assert_eq!(
513 apply_update_chunks(original, "src/example.ts", &chunks).unwrap(),
514 "import a;\nimport inserted;\nimport b;\n\nconst x = 1;\n"
515 );
516 }
517
518 #[test]
519 fn eof_hunk_applies_final_occurrence_matches_patch_parser_test_400_414() {
520 let original = "header\nmarker\nold\nmiddle\nmarker\nold\n";
521 let chunks = [eof_chunk(&["marker", "old"], &["marker", "new"])];
522
523 assert_eq!(
524 apply_update_chunks(original, "src/eof.ts", &chunks).unwrap(),
525 "header\nmarker\nold\nmiddle\nmarker\nnew\n"
526 );
527 }
528
529 #[test]
530 fn eof_hunk_rejects_forward_scan_matches_patch_parser_test_416_429() {
531 let original = "header\nmarker\nold\nmiddle\nmarker\nchanged\n";
532 let chunks = [eof_chunk(&["marker", "old"], &["marker", "new"])];
533
534 assert!(assert_apply_error(original, "src/eof.ts", &chunks)
535 .contains("Failed to find expected lines in src/eof.ts"));
536 }
537
538 #[test]
539 fn trailing_empty_line_retry_matches_patch_parser_source_514_519() {
540 let chunks = [chunk(&["alpha", ""], &["beta", ""])];
541
542 assert_eq!(
543 apply_update_chunks("alpha\n", "src/trailing.ts", &chunks).unwrap(),
544 "beta\n"
545 );
546 }
547}