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
116fn dominant_line_ending(content: &str) -> &'static str {
117 let bytes = content.as_bytes();
118 let mut newline_count = 0usize;
119 let mut crlf_count = 0usize;
120
121 for (index, byte) in bytes.iter().enumerate() {
122 if *byte == b'\n' {
123 newline_count += 1;
124 if index > 0 && bytes[index - 1] == b'\r' {
125 crlf_count += 1;
126 }
127 }
128 }
129
130 if crlf_count > newline_count - crlf_count {
132 "\r\n"
133 } else {
134 "\n"
135 }
136}
137
138pub fn apply_update_chunks(
140 original_content: &str,
141 file_path: &str,
142 chunks: &[UpdateFileChunk],
143) -> Result<String, String> {
144 let line_ending = dominant_line_ending(original_content);
145 let had_trailing_newline = original_content.ends_with('\n');
151 let mut original_lines: Vec<String> = original_content
153 .split('\n')
154 .map(|line| line.strip_suffix('\r').unwrap_or(line).to_owned())
155 .collect();
156
157 if original_lines.last().is_some_and(String::is_empty) {
158 original_lines.pop();
159 }
160
161 let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
162 let mut line_index = 0;
163
164 for chunk in chunks {
165 let change_context = chunk
166 .change_context
167 .as_ref()
168 .filter(|context| !context.is_empty());
169 if let Some(context) = change_context {
170 let line_refs = line_refs(&original_lines);
171 let context_pattern = [context.as_str()];
172 let Some(context_match) =
173 seek_sequence_tiered(&line_refs, &context_pattern, line_index, false)
174 else {
175 return Err(format!("Failed to find context '{context}' in {file_path}"));
176 };
177 line_index = context_match.found + context_match.line_count;
178 }
179
180 if chunk.old_lines.is_empty() {
181 let insertion_idx = if change_context.is_some() {
182 line_index
183 } else if original_lines.last().is_some_and(String::is_empty) {
184 original_lines.len() - 1
185 } else {
186 original_lines.len()
187 };
188 replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
189 continue;
190 }
191
192 let mut pattern = chunk.old_lines.clone();
193 let mut new_slice = chunk.new_lines.clone();
194 let mut matched = seek_sequence_tiered_strings(
195 &original_lines,
196 &pattern,
197 line_index,
198 chunk.is_end_of_file,
199 );
200
201 if matched.is_none() && pattern.last().is_some_and(String::is_empty) {
202 pattern.pop();
203 if new_slice.last().is_some_and(String::is_empty) {
204 new_slice.pop();
205 }
206 matched = seek_sequence_tiered_strings(
207 &original_lines,
208 &pattern,
209 line_index,
210 chunk.is_end_of_file,
211 );
212 }
213
214 if let Some(sequence_match) = matched {
215 replacements.push((sequence_match.found, sequence_match.line_count, new_slice));
216 line_index = sequence_match.found + sequence_match.line_count;
217 } else {
218 let new_slice_trimmed: Vec<String> = new_slice
219 .iter()
220 .filter(|line| !line.trim().is_empty())
221 .cloned()
222 .collect();
223 let already_applied = !new_slice_trimmed.is_empty()
224 && seek_sequence_strings(
225 &original_lines,
226 &new_slice_trimmed,
227 0,
228 chunk.is_end_of_file,
229 )
230 .is_some();
231
232 let line_refs = line_refs(&original_lines);
233 let pattern_refs: Vec<&str> = pattern.iter().map(String::as_str).collect();
234 let nearest_miss =
235 render_nearest_miss(&line_refs, &pattern_refs, original_content.len());
236 let tried_tiers =
237 "exact, trimEnd, trim, indent (tab/space), unicode, reflow (whitespace-normalized)";
238 let already_applied_hint = if already_applied {
239 "\n\nHint: the replacement content for this hunk already appears in the file. \
240 The patch may have been partially applied in a prior turn — re-read the file \
241 to confirm which hunks still need to apply."
242 } else {
243 ""
244 };
245
246 return Err(format!(
247 "Failed to find expected lines in {file_path}:\n{}\n\n\
248 Tried match tiers: {tried_tiers}.\n\n{nearest_miss}{already_applied_hint}",
249 chunk.old_lines.join("\n")
250 ));
251 }
252 }
253
254 replacements.sort_by(|left, right| left.0.cmp(&right.0));
255
256 let mut result = original_lines;
257 for (start_idx, old_len, new_segment) in replacements.into_iter().rev() {
258 result.splice(start_idx..start_idx + old_len, new_segment);
259 }
260
261 if had_trailing_newline || result.last().is_none_or(|line| !line.is_empty()) {
267 result.push(String::new());
268 }
269
270 Ok(result.join(line_ending))
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn chunk(old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
278 UpdateFileChunk {
279 old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
280 new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
281 change_context: None,
282 is_end_of_file: false,
283 }
284 }
285
286 fn context_chunk(context: &str, old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
287 UpdateFileChunk {
288 old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
289 new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
290 change_context: Some(context.to_owned()),
291 is_end_of_file: false,
292 }
293 }
294
295 fn eof_chunk(old_lines: &[&str], new_lines: &[&str]) -> UpdateFileChunk {
296 UpdateFileChunk {
297 old_lines: old_lines.iter().map(|line| (*line).to_owned()).collect(),
298 new_lines: new_lines.iter().map(|line| (*line).to_owned()).collect(),
299 change_context: None,
300 is_end_of_file: true,
301 }
302 }
303
304 fn assert_apply_error(original: &str, file_path: &str, chunks: &[UpdateFileChunk]) -> String {
305 apply_update_chunks(original, file_path, chunks).unwrap_err()
306 }
307
308 #[test]
309 fn missing_change_context_matches_patch_parser_test_60_72() {
310 let chunks = [context_chunk("missing line", &["beta"], &["updated beta"])];
311 assert_eq!(
312 assert_apply_error("alpha\nbeta\n", "src/example.ts", &chunks),
313 "Failed to find context 'missing line' in src/example.ts"
314 );
315 }
316
317 #[test]
318 fn missing_old_lines_error_format_matches_patch_parser_test_74_85() {
319 let chunks = [chunk(&["missing line"], &["replacement line"])];
320 assert_eq!(
321 assert_apply_error("alpha\nbeta\n", "src/example.ts", &chunks),
322 "Failed to find expected lines in src/example.ts:\nmissing line\n\n\
323 Tried match tiers: exact, trimEnd, trim, indent (tab/space), unicode, reflow (whitespace-normalized).\n\n\
324 Nearest miss: no similar region found."
325 );
326 }
327
328 #[test]
329 fn already_applied_hint_matches_patch_parser_test_87_103() {
330 let chunks = [chunk(
331 &["const mainQuota = await getFreshMainQuota(auth.access, storage)"],
332 &["const mainQuota = await getMainQuotaForRouting(auth.access, storage)"],
333 )];
334 let file_with_rewrite_already_applied =
335 "alpha\nconst mainQuota = await getMainQuotaForRouting(auth.access, storage)\nbeta\n";
336
337 assert!(
338 assert_apply_error(file_with_rewrite_already_applied, "src/example.ts", &chunks)
339 .contains("already appears in the file")
340 );
341 }
342
343 #[test]
344 fn absent_old_and_new_lines_have_no_already_applied_hint_matches_patch_parser_test_105_122() {
345 let chunks = [chunk(&["missing old line"], &["missing new line"])];
346 let message = assert_apply_error("unrelated content\n", "src/example.ts", &chunks);
347 assert!(message.contains("Failed to find expected lines"));
348 assert!(!message.contains("already appears in the file"));
349 }
350
351 #[test]
352 fn spaces_patch_matches_tab_file_matches_patch_parser_test_124_147() {
353 let file = "function foo() {\n\treturn 42;\n}\n";
354 let chunks = [chunk(&[" return 42;"], &[" return 43;"])];
355
356 assert_eq!(
357 apply_update_chunks(file, "src/foo.ts", &chunks).unwrap(),
358 "function foo() {\n return 43;\n}\n"
359 );
360 }
361
362 #[test]
363 fn tab_patch_matches_spaces_file_matches_patch_parser_test_149_162() {
364 let file = "function foo() {\n return 42;\n}\n";
365 let chunks = [chunk(&["\treturn 42;"], &["\treturn 43;"])];
366
367 assert_eq!(
368 apply_update_chunks(file, "src/foo.ts", &chunks).unwrap(),
369 "function foo() {\n\treturn 43;\n}\n"
370 );
371 }
372
373 #[test]
374 fn closest_match_diagnostic_matches_patch_parser_test_164_195() {
375 let file =
376 "function foo() {\n const x = 1;\n const y = 2;\n const z = 3;\n return x + y + z;\n}\n";
377 let chunks = [chunk(
378 &[" const x = 1;", " const y = 2;", " const Q = 99;"],
379 &[" const x = 1;", " const y = 2;", " const Q = 100;"],
380 )];
381
382 let message = assert_apply_error(file, "src/foo.ts", &chunks);
383 assert!(message.contains("Nearest miss at lines 2-4 (matched 2/3 context lines):"));
384 assert!(message.contains(" 2 | const x = 1;"));
385 assert!(message.contains(" 3 | const y = 2;"));
386 assert!(message.contains(" 4 | const z = 3;"));
387 assert!(message.contains(
388 "First divergence: wanted line 3 ` const Q = 99;` vs file line 4 ` const z = 3;`"
389 ));
390 }
391
392 #[test]
393 fn tried_tiers_diagnostic_matches_patch_parser_test_197_221() {
394 let chunks = [chunk(&["completely unrelated line"], &["replacement"])];
395 let message = assert_apply_error("alpha\nbeta\ngamma\n", "src/foo.ts", &chunks);
396
397 assert!(message.contains("Tried match tiers:"));
398 assert!(message.contains("exact"));
399 assert!(message.contains("trim"));
400 assert!(message.contains("indent"));
401 assert!(message.contains("unicode"));
402 }
403
404 #[test]
405 fn oversized_file_failure_explains_that_nearest_miss_was_skipped() {
406 let synthetic_file = "x".repeat(NEAREST_MISS_MAX_FILE_BYTES + 1);
407 let chunks = [chunk(&["wanted content"], &["replacement"])];
408
409 let message = assert_apply_error(&synthetic_file, "src/large.txt", &chunks);
410 assert!(message.contains("Nearest miss skipped: file is"));
411 assert!(message.contains("above the 2 MiB diagnostic limit"));
412 }
413
414 #[test]
415 fn reflow_one_line_to_three_line_split_matches_patch_parser_test_225_240() {
416 let original =
417 "function demo() {\n const value = alpha +\n beta +\n gamma;\n return value;\n}\n";
418 let chunks = [chunk(
419 &[" const value = alpha + beta + gamma;"],
420 &[" const value = alpha + beta + delta;"],
421 )];
422
423 assert_eq!(
424 apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
425 "function demo() {\n const value = alpha + beta + delta;\n return value;\n}\n"
426 );
427 }
428
429 #[test]
430 fn reflow_three_line_to_one_line_join_matches_patch_parser_test_242_254() {
431 let original = "function demo() {\n const value = alpha + beta + gamma;\n}\n";
432 let chunks = [chunk(
433 &[" const value = alpha +", " beta +", " gamma;"],
434 &[" const value = alpha +", " beta +", " delta;"],
435 )];
436
437 assert_eq!(
438 apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
439 "function demo() {\n const value = alpha +\n beta +\n delta;\n}\n"
440 );
441 }
442
443 #[test]
444 fn ambiguous_reflow_rejects_matches_patch_parser_test_256_269() {
445 let original =
446 "const value = alpha +\n beta +\n gamma;\n\nconst value = alpha +\n beta +\n gamma;\n";
447 let chunks = [chunk(
448 &["const value = alpha + beta + gamma;"],
449 &["const value = alpha + beta + delta;"],
450 )];
451
452 assert!(assert_apply_error(original, "src/demo.ts", &chunks)
453 .contains("Failed to find expected lines in src/demo.ts"));
454 }
455
456 #[test]
457 fn reflow_near_miss_rejects_matches_patch_parser_test_271_282() {
458 let chunks = [chunk(
459 &["const value = alpha + beta + delta;"],
460 &["const value = alpha + beta + epsilon;"],
461 )];
462
463 assert!(assert_apply_error(
464 "const value = alpha +\n beta +\n gamma;\n",
465 "src/demo.ts",
466 &chunks,
467 )
468 .contains("Failed to find expected lines in src/demo.ts"));
469 }
470
471 #[test]
472 fn contiguous_match_wins_before_reflow_matches_patch_parser_test_284_299() {
473 let original =
474 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + gamma;\n";
475 let chunks = [chunk(
476 &["const value = alpha + beta + gamma;"],
477 &["const value = alpha + beta + delta;"],
478 )];
479
480 assert_eq!(
481 apply_update_chunks(original, "src/demo.ts", &chunks).unwrap(),
482 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + delta;\n"
483 );
484 }
485
486 #[test]
487 fn strict_tiers_stay_ahead_of_reflow_matches_patch_parser_test_301_342() {
488 let cases = [
489 (
490 "src/rstrip.ts",
491 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + gamma; \n",
492 vec!["const value = alpha + beta + gamma;"],
493 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + delta;\n",
494 ),
495 (
496 "src/trim.ts",
497 "const value = alpha +\n beta +\n gamma;\n const value = alpha + beta + gamma;\n",
498 vec!["const value = alpha + beta + gamma;"],
499 "const value = alpha +\n beta +\n gamma;\nconst value = alpha + beta + delta;\n",
500 ),
501 (
502 "src/unicode.ts",
503 "const label =\n \"alpha\";\nconst label = “alpha”;\n",
504 vec!["const label = \"alpha\";"],
505 "const label =\n \"alpha\";\nconst value = alpha + beta + delta;\n",
506 ),
507 ];
508
509 for (file_path, original, old_lines, expected) in cases {
510 let chunks = [chunk(&old_lines, &["const value = alpha + beta + delta;"])];
511 assert_eq!(
512 apply_update_chunks(original, file_path, &chunks).unwrap(),
513 expected
514 );
515 }
516 }
517
518 #[test]
519 fn pure_insertion_with_context_matches_patch_parser_test_345_367() {
520 let original = "function foo() {\n return 1;\n}\n\nfunction bar() {\n return 2;\n}\n";
521 let chunks = [context_chunk("function foo() {", &[], &[" const x = 42;"])];
522
523 let result = apply_update_chunks(original, "src/example.ts", &chunks).unwrap();
524 assert_eq!(
525 result,
526 "function foo() {\n const x = 42;\n return 1;\n}\n\nfunction bar() {\n return 2;\n}\n"
527 );
528 assert!(!result.contains(" return 2;\n const x = 42;"));
529 }
530
531 #[test]
532 fn pure_insertion_without_context_matches_patch_parser_test_369_380() {
533 let original = "alpha\nbeta\n";
534 let chunks = [chunk(&[], &["gamma"])];
535
536 assert_eq!(
537 apply_update_chunks(original, "src/example.ts", &chunks).unwrap(),
538 "alpha\nbeta\ngamma\n"
539 );
540 }
541
542 #[test]
543 fn pure_insertion_does_not_short_circuit_matches_patch_parser_test_382_397() {
544 let original = "import a;\nimport b;\n\nconst x = 1;\n";
545 let chunks = [context_chunk("import a;", &[], &["import inserted;"])];
546
547 assert_eq!(
548 apply_update_chunks(original, "src/example.ts", &chunks).unwrap(),
549 "import a;\nimport inserted;\nimport b;\n\nconst x = 1;\n"
550 );
551 }
552
553 #[test]
554 fn eof_hunk_applies_final_occurrence_matches_patch_parser_test_400_414() {
555 let original = "header\nmarker\nold\nmiddle\nmarker\nold\n";
556 let chunks = [eof_chunk(&["marker", "old"], &["marker", "new"])];
557
558 assert_eq!(
559 apply_update_chunks(original, "src/eof.ts", &chunks).unwrap(),
560 "header\nmarker\nold\nmiddle\nmarker\nnew\n"
561 );
562 }
563
564 #[test]
565 fn eof_hunk_rejects_forward_scan_matches_patch_parser_test_416_429() {
566 let original = "header\nmarker\nold\nmiddle\nmarker\nchanged\n";
567 let chunks = [eof_chunk(&["marker", "old"], &["marker", "new"])];
568
569 assert!(assert_apply_error(original, "src/eof.ts", &chunks)
570 .contains("Failed to find expected lines in src/eof.ts"));
571 }
572
573 #[test]
574 fn trailing_empty_line_hunk_keeps_real_blank_line() {
575 let chunks = [chunk(&["alpha", ""], &["beta", ""])];
576
577 assert_eq!(
584 apply_update_chunks("alpha\n", "src/trailing.ts", &chunks).unwrap(),
585 "beta\n\n"
586 );
587 }
588
589 #[test]
590 fn crlf_replacements_and_insertions_use_crlf_bytes() {
591 let replacement = apply_update_chunks(
592 "alpha\r\nold\r\nomega\r\n",
593 "src/crlf.txt",
594 &[chunk(&["old"], &["new"])],
595 )
596 .unwrap();
597 assert_eq!(replacement.as_bytes(), b"alpha\r\nnew\r\nomega\r\n");
598
599 let insertion = apply_update_chunks(
600 "alpha\r\nomega\r\n",
601 "src/crlf.txt",
602 &[chunk(&[], &["inserted"])],
603 )
604 .unwrap();
605 assert_eq!(insertion.as_bytes(), b"alpha\r\nomega\r\ninserted\r\n");
606 }
607
608 #[test]
609 fn mixed_and_unterminated_files_follow_dominant_newline_policy() {
610 let mixed = apply_update_chunks(
611 "alpha\r\nold\nomega\r\n",
612 "src/mixed.txt",
613 &[chunk(&["old"], &["new"])],
614 )
615 .unwrap();
616 assert_eq!(mixed.as_bytes(), b"alpha\r\nnew\r\nomega\r\n");
617
618 let unterminated =
619 apply_update_chunks("alpha\r\nold", "src/crlf.txt", &[chunk(&["old"], &["new"])])
620 .unwrap();
621 assert_eq!(unterminated.as_bytes(), b"alpha\r\nnew\r\n");
622 }
623
624 #[test]
625 fn lf_updates_keep_existing_byte_behavior() {
626 let cases = [
627 (
628 "alpha\nold\nomega\n",
629 chunk(&["old"], &["new"]),
630 b"alpha\nnew\nomega\n".as_slice(),
631 ),
632 (
633 "alpha\nomega\n",
634 chunk(&[], &["inserted"]),
635 b"alpha\nomega\ninserted\n".as_slice(),
636 ),
637 (
638 "alpha\nold",
639 chunk(&["old"], &["new"]),
640 b"alpha\nnew\n".as_slice(),
641 ),
642 ];
643
644 for (original, update, expected) in cases {
645 let result = apply_update_chunks(original, "src/lf.txt", &[update]).unwrap();
646 assert_eq!(result.as_bytes(), expected);
647 }
648 }
649
650 #[test]
651 fn update_preserves_two_terminal_newlines() {
652 let result = apply_update_chunks(
656 "alpha\n\n",
657 "src/double.txt",
658 &[chunk(&["alpha"], &["beta"])],
659 )
660 .unwrap();
661 assert_eq!(result.as_bytes(), b"beta\n\n");
662 }
663
664 #[test]
665 fn update_keeps_single_terminal_newline() {
666 let result =
667 apply_update_chunks("alpha\n", "src/single.txt", &[chunk(&["alpha"], &["beta"])])
668 .unwrap();
669 assert_eq!(result.as_bytes(), b"beta\n");
670 }
671
672 #[test]
673 fn update_normalizes_unterminated_file_to_newline_terminated() {
674 let result =
679 apply_update_chunks("alpha", "src/bare.txt", &[chunk(&["alpha"], &["beta"])]).unwrap();
680 assert_eq!(result.as_bytes(), b"beta\n");
681 }
682
683 #[test]
684 fn hunk_that_explicitly_deletes_blank_line_still_deletes_it() {
685 let interior = apply_update_chunks(
686 "alpha\n\nbeta\n",
687 "src/delete-blank.txt",
688 &[chunk(&["alpha", ""], &["beta"])],
689 )
690 .unwrap();
691 assert_eq!(interior.as_bytes(), b"beta\nbeta\n");
692
693 let trailing = apply_update_chunks(
697 "alpha\n\n",
698 "src/delete-trailing.txt",
699 &[chunk(&["alpha", ""], &["beta"])],
700 )
701 .unwrap();
702 assert_eq!(trailing.as_bytes(), b"beta\n");
703 }
704
705 #[test]
706 fn crlf_with_two_terminal_newlines_preserves_majority_convention() {
707 let result = apply_update_chunks(
710 "alpha\r\n\r\n",
711 "src/crlf-double.txt",
712 &[chunk(&["alpha"], &["beta"])],
713 )
714 .unwrap();
715 assert_eq!(result.as_bytes(), b"beta\r\n\r\n");
716 }
717}