Skip to main content

aft/patch/
apply.rs

1//! Apply parsed update chunks to file content.
2//!
3//! This ports `applyUpdateChunks` and its diagnostics from
4//! `packages/opencode-plugin/src/patch-parser.ts`.
5
6use 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
14/// Return only the matched line index for diagnostics that do not need the full tiered match details.
15pub 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    // Mixed files use their majority convention; ties and files without newlines use LF.
131    if crlf_count > newline_count - crlf_count {
132        "\r\n"
133    } else {
134        "\n"
135    }
136}
137
138/// Apply parsed update chunks to original file content, returning the patched text or an error string.
139pub 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    // Remember at split time whether the file ended with a newline. The join below
146    // must restore the terminator sentinel from this flag: once the lines are in a
147    // buffer, a real trailing empty line (a file ending in two newlines) is
148    // indistinguishable from the split terminator, so re-inferring it there loses
149    // one terminal newline on every update to such files.
150    let had_trailing_newline = original_content.ends_with('\n');
151    // Remove CRLF's carriage return before matching, then restore the chosen convention on output.
152    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    // Append the terminator sentinel from the tracked flag instead of inferring it
262    // from the last line's emptiness: a real trailing empty line left behind by a
263    // file ending in two newlines looks exactly like the sentinel, and declining to
264    // push in that case would eat one terminal newline. Files that lacked a trailing
265    // newline keep the historical policy of being normalized to newline-terminated.
266    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        // The full old pattern ["alpha", ""] never matches the one-line file at the
578        // strict tiers; the reflow tier matches just "alpha", so new_lines' trailing
579        // "" survives into the buffer. The join previously absorbed that empty line
580        // as the terminator sentinel (yielding "beta\n"); since the terminator is
581        // now tracked from the original content instead of inferred from the last
582        // line, the empty line is real content and the output gains a blank line.
583        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        // Regression repro: "alpha\n\n" splits to ["alpha", "", ""]; popping the split
653        // terminator leaves a REAL trailing empty line, which the join previously
654        // mistook for the sentinel and emitted "beta\n" (one newline lost).
655        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        // Policy preserved from before the terminal-newline fix: apply_patch already
675        // normalized files without a trailing newline to newline-terminated output
676        // (see mixed_and_unterminated_files_follow_dominant_newline_policy), so the
677        // missing newline is deliberately added rather than preserved.
678        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        // Deleting the trailing blank line of a two-newline file leaves exactly one
694        // terminal newline: the hunk consumed the real empty line, the sentinel is
695        // still restored.
696        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        // The CRLF normalization (dominant_line_ending) must compose with the
708        // tracked terminator: both terminal newlines survive, both as CRLF.
709        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}