1use std::{
2 borrow::Cow,
3 fmt::{Display, Write},
4 mem,
5 ops::Range,
6};
7
8use anyhow::{Context as _, Result, anyhow};
9
10pub fn strip_diff_path_prefix<'a>(diff: &'a str, prefix: &str) -> Cow<'a, str> {
11 if prefix.is_empty() {
12 return Cow::Borrowed(diff);
13 }
14
15 let prefix_with_slash = format!("{}/", prefix);
16 let mut needs_rewrite = false;
17
18 for line in diff.lines() {
19 match DiffLine::parse(line) {
20 DiffLine::OldPath { path } | DiffLine::NewPath { path } => {
21 if path.starts_with(&prefix_with_slash) {
22 needs_rewrite = true;
23 break;
24 }
25 }
26 _ => {}
27 }
28 }
29
30 if !needs_rewrite {
31 return Cow::Borrowed(diff);
32 }
33
34 let mut result = String::with_capacity(diff.len());
35 for line in diff.lines() {
36 match DiffLine::parse(line) {
37 DiffLine::OldPath { path } => {
38 let stripped = path
39 .strip_prefix(&prefix_with_slash)
40 .unwrap_or(path.as_ref());
41 result.push_str(&format!("--- a/{}\n", stripped));
42 }
43 DiffLine::NewPath { path } => {
44 let stripped = path
45 .strip_prefix(&prefix_with_slash)
46 .unwrap_or(path.as_ref());
47 result.push_str(&format!("+++ b/{}\n", stripped));
48 }
49 _ => {
50 result.push_str(line);
51 result.push('\n');
52 }
53 }
54 }
55
56 Cow::Owned(result)
57}
58
59/// Strip unnecessary git metadata lines from a diff, keeping only the lines
60/// needed for patch application: path headers (--- and +++), hunk headers (@@),
61/// and content lines (+, -, space).
62pub fn strip_diff_metadata(diff: &str) -> String {
63 let mut result = String::new();
64
65 for line in diff.lines() {
66 let dominated = DiffLine::parse(line);
67 match dominated {
68 // Keep path headers, hunk headers, and content lines
69 DiffLine::OldPath { .. }
70 | DiffLine::NewPath { .. }
71 | DiffLine::HunkHeader(_)
72 | DiffLine::Context(_)
73 | DiffLine::Deletion(_)
74 | DiffLine::Addition(_)
75 | DiffLine::NoNewlineAtEOF => {
76 result.push_str(line);
77 result.push('\n');
78 }
79 // Skip garbage lines (diff --git, index, etc.)
80 DiffLine::Garbage(_) => {}
81 }
82 }
83
84 result
85}
86
87/// Marker used to encode cursor position in patch comment lines.
88pub const CURSOR_POSITION_MARKER: &str = "[CURSOR_POSITION]";
89
90/// Extract cursor offset from a patch and return `(clean_patch, cursor_offset)`.
91///
92/// Cursor position is encoded as a comment line (starting with `#`) containing
93/// `[CURSOR_POSITION]`. A `^` in the line indicates the cursor column; a `<`
94/// indicates column 0. The offset is computed relative to addition (`+`) and
95/// context (` `) lines accumulated so far in the hunk, which represent the
96/// cursor position within the new text contributed by the hunk.
97pub fn extract_cursor_from_patch(patch: &str) -> (String, Option<usize>) {
98 let mut clean_patch = String::new();
99 let mut cursor_offset: Option<usize> = None;
100 let mut line_start_offset = 0usize;
101 let mut prev_line_start_offset = 0usize;
102
103 for line in patch.lines() {
104 let diff_line = DiffLine::parse(line);
105
106 match &diff_line {
107 DiffLine::Garbage(content)
108 if content.starts_with('#') && content.contains(CURSOR_POSITION_MARKER) =>
109 {
110 let caret_column = if let Some(caret_pos) = content.find('^') {
111 caret_pos
112 } else if content.find('<').is_some() {
113 0
114 } else {
115 continue;
116 };
117 let cursor_column = caret_column.saturating_sub('#'.len_utf8());
118 cursor_offset = Some(prev_line_start_offset + cursor_column);
119 }
120 _ => {
121 if !clean_patch.is_empty() {
122 clean_patch.push('\n');
123 }
124 clean_patch.push_str(line);
125
126 match diff_line {
127 DiffLine::Addition(content) | DiffLine::Context(content) => {
128 prev_line_start_offset = line_start_offset;
129 line_start_offset += content.len() + 1;
130 }
131 _ => {}
132 }
133 }
134 }
135 }
136
137 if patch.ends_with('\n') && !clean_patch.is_empty() {
138 clean_patch.push('\n');
139 }
140
141 (clean_patch, cursor_offset)
142}
143
144/// Find all byte offsets where `hunk.context` occurs as a substring of `text`.
145///
146/// If no exact matches are found and the context ends with `'\n'` but `text`
147/// does not, retries without the trailing newline, accepting only a match at
148/// the very end of `text`. When this fallback fires, the hunk's context is
149/// trimmed and its edit ranges are clamped so that downstream code doesn't
150/// index past the end of the matched region. This handles diffs that are
151/// missing a `\ No newline at end of file` marker: the parser always appends
152/// `'\n'` via `writeln!`, so the context can have a trailing newline that
153/// doesn't exist in the source text.
154pub fn find_context_candidates(text: &str, hunk: &mut Hunk) -> Vec<usize> {
155 let candidates: Vec<usize> = text
156 .match_indices(&hunk.context)
157 .map(|(offset, _)| offset)
158 .collect();
159
160 if !candidates.is_empty() {
161 return candidates;
162 }
163
164 if hunk.context.ends_with('\n') && !hunk.context.is_empty() {
165 let old_len = hunk.context.len();
166 hunk.context.pop();
167 let new_len = hunk.context.len();
168
169 if !hunk.context.is_empty() {
170 let candidates: Vec<usize> = text
171 .match_indices(&hunk.context)
172 .filter(|(offset, _)| offset + new_len == text.len())
173 .map(|(offset, _)| offset)
174 .collect();
175
176 if !candidates.is_empty() {
177 for edit in &mut hunk.edits {
178 let touched_phantom = edit.range.end > new_len;
179 edit.range.start = edit.range.start.min(new_len);
180 edit.range.end = edit.range.end.min(new_len);
181 if touched_phantom {
182 // The replacement text was also written with a
183 // trailing '\n' that corresponds to the phantom
184 // newline we just removed from the context.
185 if edit.text.ends_with('\n') {
186 edit.text.pop();
187 }
188 }
189 }
190 return candidates;
191 }
192
193 // Restore if fallback didn't help either.
194 hunk.context.push('\n');
195 debug_assert_eq!(hunk.context.len(), old_len);
196 } else {
197 hunk.context.push('\n');
198 }
199 }
200
201 Vec::new()
202}
203
204/// Given multiple candidate offsets where context matches, use line numbers to disambiguate.
205/// Returns the offset that matches the expected line, or None if no match or no line number available.
206pub fn disambiguate_by_line_number(
207 candidates: &[usize],
208 expected_line: Option<u32>,
209 offset_to_line: &dyn Fn(usize) -> u32,
210) -> Option<usize> {
211 match candidates.len() {
212 0 => None,
213 1 => Some(candidates[0]),
214 _ => {
215 let expected = expected_line?;
216 candidates
217 .iter()
218 .copied()
219 .find(|&offset| offset_to_line(offset) == expected)
220 }
221 }
222}
223
224pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result<String> {
225 apply_diff_to_string_with_hunk_offset(diff_str, text).map(|(text, _)| text)
226}
227
228/// Applies a diff to a string and returns the result along with the offset where
229/// the first hunk's context matched in the original text. This offset can be used
230/// to adjust cursor positions that are relative to the hunk's content.
231pub fn apply_diff_to_string_with_hunk_offset(
232 diff_str: &str,
233 text: &str,
234) -> Result<(String, Option<usize>)> {
235 let mut diff = DiffParser::new(diff_str);
236
237 let mut text = text.to_string();
238 let mut first_hunk_offset = None;
239
240 while let Some(event) = diff.next().context("Failed to parse diff")? {
241 match event {
242 DiffEvent::Hunk {
243 mut hunk,
244 path: _,
245 status: _,
246 } => {
247 let candidates = find_context_candidates(&text, &mut hunk);
248
249 let hunk_offset =
250 disambiguate_by_line_number(&candidates, hunk.start_line, &|offset| {
251 text[..offset].matches('\n').count() as u32
252 })
253 .ok_or_else(|| anyhow!("couldn't resolve hunk"))?;
254
255 if first_hunk_offset.is_none() {
256 first_hunk_offset = Some(hunk_offset);
257 }
258
259 for edit in hunk.edits.iter().rev() {
260 let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end);
261 text.replace_range(range, &edit.text);
262 }
263 }
264 DiffEvent::FileEnd { .. } => {}
265 }
266 }
267
268 Ok((text, first_hunk_offset))
269}
270
271struct PatchFile<'a> {
272 old_path: Cow<'a, str>,
273 new_path: Cow<'a, str>,
274}
275
276pub struct DiffParser<'a> {
277 current_file: Option<PatchFile<'a>>,
278 current_line: Option<(&'a str, DiffLine<'a>)>,
279 hunk: Hunk,
280 diff: std::str::Lines<'a>,
281 pending_start_line: Option<u32>,
282 processed_no_newline: bool,
283 last_diff_op: LastDiffOp,
284}
285
286#[derive(Clone, Copy, Default)]
287enum LastDiffOp {
288 #[default]
289 None,
290 Context,
291 Deletion,
292 Addition,
293}
294
295#[derive(Debug, PartialEq)]
296pub enum DiffEvent<'a> {
297 Hunk {
298 path: Cow<'a, str>,
299 hunk: Hunk,
300 status: FileStatus,
301 },
302 FileEnd {
303 renamed_to: Option<Cow<'a, str>>,
304 },
305}
306
307#[derive(Debug, Clone, Copy, PartialEq)]
308pub enum FileStatus {
309 Created,
310 Modified,
311 Deleted,
312}
313
314#[derive(Debug, Default, PartialEq)]
315pub struct Hunk {
316 pub context: String,
317 pub edits: Vec<Edit>,
318 pub start_line: Option<u32>,
319}
320
321impl Hunk {
322 pub fn is_empty(&self) -> bool {
323 self.context.is_empty() && self.edits.is_empty()
324 }
325}
326
327#[derive(Debug, PartialEq)]
328pub struct Edit {
329 pub range: Range<usize>,
330 pub text: String,
331}
332
333impl<'a> DiffParser<'a> {
334 pub fn new(diff: &'a str) -> Self {
335 let mut diff = diff.lines();
336 let current_line = diff.next().map(|line| (line, DiffLine::parse(line)));
337 DiffParser {
338 current_file: None,
339 hunk: Hunk::default(),
340 current_line,
341 diff,
342 pending_start_line: None,
343 processed_no_newline: false,
344 last_diff_op: LastDiffOp::None,
345 }
346 }
347
348 pub fn next(&mut self) -> Result<Option<DiffEvent<'a>>> {
349 loop {
350 let (hunk_done, file_done) = match self.current_line.as_ref().map(|e| &e.1) {
351 Some(DiffLine::OldPath { .. }) | Some(DiffLine::Garbage(_)) | None => (true, true),
352 Some(DiffLine::HunkHeader(_)) => (true, false),
353 _ => (false, false),
354 };
355
356 if hunk_done {
357 if let Some(file) = &self.current_file
358 && !self.hunk.is_empty()
359 {
360 let status = if file.old_path == "/dev/null" {
361 FileStatus::Created
362 } else if file.new_path == "/dev/null" {
363 FileStatus::Deleted
364 } else {
365 FileStatus::Modified
366 };
367 let path = if status == FileStatus::Created {
368 file.new_path.clone()
369 } else {
370 file.old_path.clone()
371 };
372 let mut hunk = mem::take(&mut self.hunk);
373 hunk.start_line = self.pending_start_line.take();
374 self.processed_no_newline = false;
375 self.last_diff_op = LastDiffOp::None;
376 return Ok(Some(DiffEvent::Hunk { path, hunk, status }));
377 }
378 }
379
380 if file_done {
381 if let Some(PatchFile { old_path, new_path }) = self.current_file.take() {
382 return Ok(Some(DiffEvent::FileEnd {
383 renamed_to: if old_path != new_path && old_path != "/dev/null" {
384 Some(new_path)
385 } else {
386 None
387 },
388 }));
389 }
390 }
391
392 let Some((line, parsed_line)) = self.current_line.take() else {
393 break;
394 };
395
396 (|| {
397 match parsed_line {
398 DiffLine::OldPath { path } => {
399 self.current_file = Some(PatchFile {
400 old_path: path,
401 new_path: "".into(),
402 });
403 }
404 DiffLine::NewPath { path } => {
405 if let Some(current_file) = &mut self.current_file {
406 current_file.new_path = path
407 }
408 }
409 DiffLine::HunkHeader(location) => {
410 if let Some(loc) = location {
411 self.pending_start_line = Some(loc.start_line_old);
412 }
413 }
414 DiffLine::Context(ctx) => {
415 if self.current_file.is_some() {
416 writeln!(&mut self.hunk.context, "{ctx}")?;
417 self.last_diff_op = LastDiffOp::Context;
418 }
419 }
420 DiffLine::Deletion(del) => {
421 if self.current_file.is_some() {
422 let range = self.hunk.context.len()
423 ..self.hunk.context.len() + del.len() + '\n'.len_utf8();
424 if let Some(last_edit) = self.hunk.edits.last_mut()
425 && last_edit.range.end == range.start
426 {
427 last_edit.range.end = range.end;
428 } else {
429 self.hunk.edits.push(Edit {
430 range,
431 text: String::new(),
432 });
433 }
434 writeln!(&mut self.hunk.context, "{del}")?;
435 self.last_diff_op = LastDiffOp::Deletion;
436 }
437 }
438 DiffLine::Addition(add) => {
439 if self.current_file.is_some() {
440 let range = self.hunk.context.len()..self.hunk.context.len();
441 if let Some(last_edit) = self.hunk.edits.last_mut()
442 && last_edit.range.end == range.start
443 {
444 writeln!(&mut last_edit.text, "{add}").unwrap();
445 } else {
446 self.hunk.edits.push(Edit {
447 range,
448 text: format!("{add}\n"),
449 });
450 }
451 self.last_diff_op = LastDiffOp::Addition;
452 }
453 }
454 DiffLine::NoNewlineAtEOF => {
455 if !self.processed_no_newline {
456 self.processed_no_newline = true;
457 match self.last_diff_op {
458 LastDiffOp::Addition => {
459 // Remove trailing newline from the last addition
460 if let Some(last_edit) = self.hunk.edits.last_mut() {
461 last_edit.text.pop();
462 }
463 }
464 LastDiffOp::Deletion => {
465 // Remove trailing newline from context (which includes the deletion)
466 self.hunk.context.pop();
467 if let Some(last_edit) = self.hunk.edits.last_mut() {
468 last_edit.range.end -= 1;
469 }
470 }
471 LastDiffOp::Context | LastDiffOp::None => {
472 // Remove trailing newline from context
473 self.hunk.context.pop();
474 }
475 }
476 }
477 }
478 DiffLine::Garbage(_) => {}
479 }
480
481 anyhow::Ok(())
482 })()
483 .with_context(|| format!("on line:\n\n```\n{}```", line))?;
484
485 self.current_line = self.diff.next().map(|line| (line, DiffLine::parse(line)));
486 }
487
488 anyhow::Ok(None)
489 }
490}
491
492#[derive(Debug, PartialEq)]
493pub enum DiffLine<'a> {
494 OldPath { path: Cow<'a, str> },
495 NewPath { path: Cow<'a, str> },
496 HunkHeader(Option<HunkLocation>),
497 Context(&'a str),
498 Deletion(&'a str),
499 Addition(&'a str),
500 NoNewlineAtEOF,
501 Garbage(&'a str),
502}
503
504#[derive(Debug, PartialEq)]
505pub struct HunkLocation {
506 pub start_line_old: u32,
507 pub count_old: u32,
508 pub start_line_new: u32,
509 pub count_new: u32,
510}
511
512impl<'a> DiffLine<'a> {
513 pub fn parse(line: &'a str) -> Self {
514 Self::try_parse(line).unwrap_or(Self::Garbage(line))
515 }
516
517 fn try_parse(line: &'a str) -> Option<Self> {
518 if line.starts_with("\\ No newline") {
519 return Some(Self::NoNewlineAtEOF);
520 }
521 if let Some(header) = line.strip_prefix("---").and_then(eat_required_whitespace) {
522 let path = parse_header_path("a/", header);
523 Some(Self::OldPath { path })
524 } else if let Some(header) = line.strip_prefix("+++").and_then(eat_required_whitespace) {
525 Some(Self::NewPath {
526 path: parse_header_path("b/", header),
527 })
528 } else if let Some(header) = line.strip_prefix("@@").and_then(eat_required_whitespace) {
529 if header.starts_with("...") {
530 return Some(Self::HunkHeader(None));
531 }
532
533 let mut tokens = header.split_whitespace();
534 let old_range = tokens.next()?.strip_prefix('-')?;
535 let new_range = tokens.next()?.strip_prefix('+')?;
536
537 let (start_line_old, count_old) = old_range.split_once(',').unwrap_or((old_range, "1"));
538 let (start_line_new, count_new) = new_range.split_once(',').unwrap_or((new_range, "1"));
539
540 Some(Self::HunkHeader(Some(HunkLocation {
541 start_line_old: start_line_old.parse::<u32>().ok()?.saturating_sub(1),
542 count_old: count_old.parse().ok()?,
543 start_line_new: start_line_new.parse::<u32>().ok()?.saturating_sub(1),
544 count_new: count_new.parse().ok()?,
545 })))
546 } else if let Some(deleted_header) = line.strip_prefix("-") {
547 Some(Self::Deletion(deleted_header))
548 } else if line.is_empty() {
549 Some(Self::Context(""))
550 } else if let Some(context) = line.strip_prefix(" ") {
551 Some(Self::Context(context))
552 } else {
553 Some(Self::Addition(line.strip_prefix("+")?))
554 }
555 }
556}
557
558impl<'a> Display for DiffLine<'a> {
559 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560 match self {
561 DiffLine::OldPath { path } => write!(f, "--- {path}"),
562 DiffLine::NewPath { path } => write!(f, "+++ {path}"),
563 DiffLine::HunkHeader(Some(hunk_location)) => {
564 write!(
565 f,
566 "@@ -{},{} +{},{} @@",
567 hunk_location.start_line_old + 1,
568 hunk_location.count_old,
569 hunk_location.start_line_new + 1,
570 hunk_location.count_new
571 )
572 }
573 DiffLine::HunkHeader(None) => write!(f, "@@ ... @@"),
574 DiffLine::Context(content) => write!(f, " {content}"),
575 DiffLine::Deletion(content) => write!(f, "-{content}"),
576 DiffLine::Addition(content) => write!(f, "+{content}"),
577 DiffLine::NoNewlineAtEOF => write!(f, "\\ No newline at end of file"),
578 DiffLine::Garbage(line) => write!(f, "{line}"),
579 }
580 }
581}
582
583fn parse_header_path<'a>(strip_prefix: &'static str, header: &'a str) -> Cow<'a, str> {
584 if !header.contains(['"', '\\']) {
585 let path = header.split_ascii_whitespace().next().unwrap_or(header);
586 return Cow::Borrowed(path.strip_prefix(strip_prefix).unwrap_or(path));
587 }
588
589 let mut path = String::with_capacity(header.len());
590 let mut in_quote = false;
591 let mut chars = header.chars().peekable();
592 let mut strip_prefix = Some(strip_prefix);
593
594 while let Some(char) = chars.next() {
595 if char == '"' {
596 in_quote = !in_quote;
597 } else if char == '\\' {
598 let Some(&next_char) = chars.peek() else {
599 break;
600 };
601 chars.next();
602 path.push(next_char);
603 } else if char.is_ascii_whitespace() && !in_quote {
604 break;
605 } else {
606 path.push(char);
607 }
608
609 if let Some(prefix) = strip_prefix
610 && path == prefix
611 {
612 strip_prefix.take();
613 path.clear();
614 }
615 }
616
617 Cow::Owned(path)
618}
619
620fn eat_required_whitespace(header: &str) -> Option<&str> {
621 let trimmed = header.trim_ascii_start();
622
623 if trimmed.len() == header.len() {
624 None
625 } else {
626 Some(trimmed)
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use indoc::indoc;
634
635 #[test]
636 fn parse_lines_simple() {
637 let input = indoc! {"
638 diff --git a/text.txt b/text.txt
639 index 86c770d..a1fd855 100644
640 --- a/file.txt
641 +++ b/file.txt
642 @@ -1,2 +1,3 @@
643 context
644 -deleted
645 +inserted
646 garbage
647
648 --- b/file.txt
649 +++ a/file.txt
650 "};
651
652 let lines = input.lines().map(DiffLine::parse).collect::<Vec<_>>();
653
654 assert_eq!(
655 lines,
656 &[
657 DiffLine::Garbage("diff --git a/text.txt b/text.txt"),
658 DiffLine::Garbage("index 86c770d..a1fd855 100644"),
659 DiffLine::OldPath {
660 path: "file.txt".into()
661 },
662 DiffLine::NewPath {
663 path: "file.txt".into()
664 },
665 DiffLine::HunkHeader(Some(HunkLocation {
666 start_line_old: 0,
667 count_old: 2,
668 start_line_new: 0,
669 count_new: 3
670 })),
671 DiffLine::Context("context"),
672 DiffLine::Deletion("deleted"),
673 DiffLine::Addition("inserted"),
674 DiffLine::Garbage("garbage"),
675 DiffLine::Context(""),
676 DiffLine::OldPath {
677 path: "b/file.txt".into()
678 },
679 DiffLine::NewPath {
680 path: "a/file.txt".into()
681 },
682 ]
683 );
684 }
685
686 #[test]
687 fn file_header_extra_space() {
688 let options = ["--- file", "--- file", "---\tfile"];
689
690 for option in options {
691 assert_eq!(
692 DiffLine::parse(option),
693 DiffLine::OldPath {
694 path: "file".into()
695 },
696 "{option}",
697 );
698 }
699 }
700
701 #[test]
702 fn hunk_header_extra_space() {
703 let options = [
704 "@@ -1,2 +1,3 @@",
705 "@@ -1,2 +1,3 @@",
706 "@@\t-1,2\t+1,3\t@@",
707 "@@ -1,2 +1,3 @@",
708 "@@ -1,2 +1,3 @@",
709 "@@ -1,2 +1,3 @@",
710 "@@ -1,2 +1,3 @@ garbage",
711 ];
712
713 for option in options {
714 assert_eq!(
715 DiffLine::parse(option),
716 DiffLine::HunkHeader(Some(HunkLocation {
717 start_line_old: 0,
718 count_old: 2,
719 start_line_new: 0,
720 count_new: 3
721 })),
722 "{option}",
723 );
724 }
725 }
726
727 #[test]
728 fn hunk_header_without_location() {
729 assert_eq!(DiffLine::parse("@@ ... @@"), DiffLine::HunkHeader(None));
730 }
731
732 #[test]
733 fn test_parse_path() {
734 assert_eq!(parse_header_path("a/", "foo.txt"), "foo.txt");
735 assert_eq!(
736 parse_header_path("a/", "foo/bar/baz.txt"),
737 "foo/bar/baz.txt"
738 );
739 assert_eq!(parse_header_path("a/", "a/foo.txt"), "foo.txt");
740 assert_eq!(
741 parse_header_path("a/", "a/foo/bar/baz.txt"),
742 "foo/bar/baz.txt"
743 );
744
745 // Extra
746 assert_eq!(
747 parse_header_path("a/", "a/foo/bar/baz.txt 2025"),
748 "foo/bar/baz.txt"
749 );
750 assert_eq!(
751 parse_header_path("a/", "a/foo/bar/baz.txt\t2025"),
752 "foo/bar/baz.txt"
753 );
754 assert_eq!(
755 parse_header_path("a/", "a/foo/bar/baz.txt \""),
756 "foo/bar/baz.txt"
757 );
758
759 // Quoted
760 assert_eq!(
761 parse_header_path("a/", "a/foo/bar/\"baz quox.txt\""),
762 "foo/bar/baz quox.txt"
763 );
764 assert_eq!(
765 parse_header_path("a/", "\"a/foo/bar/baz quox.txt\""),
766 "foo/bar/baz quox.txt"
767 );
768 assert_eq!(
769 parse_header_path("a/", "\"foo/bar/baz quox.txt\""),
770 "foo/bar/baz quox.txt"
771 );
772 assert_eq!(parse_header_path("a/", "\"whatever π€·\""), "whatever π€·");
773 assert_eq!(
774 parse_header_path("a/", "\"foo/bar/baz quox.txt\" 2025"),
775 "foo/bar/baz quox.txt"
776 );
777 // unescaped quotes are dropped
778 assert_eq!(parse_header_path("a/", "foo/\"bar\""), "foo/bar");
779
780 // Escaped
781 assert_eq!(
782 parse_header_path("a/", "\"foo/\\\"bar\\\"/baz.txt\""),
783 "foo/\"bar\"/baz.txt"
784 );
785 assert_eq!(
786 parse_header_path("a/", "\"C:\\\\Projects\\\\My App\\\\old file.txt\""),
787 "C:\\Projects\\My App\\old file.txt"
788 );
789 }
790
791 #[test]
792 fn test_parse_diff_with_leading_and_trailing_garbage() {
793 let diff = indoc! {"
794 I need to make some changes.
795
796 I'll change the following things:
797 - one
798 - two
799 - three
800
801 ```
802 --- a/file.txt
803 +++ b/file.txt
804 one
805 +AND
806 two
807 ```
808
809 Summary of what I did:
810 - one
811 - two
812 - three
813
814 That's about it.
815 "};
816
817 let mut events = Vec::new();
818 let mut parser = DiffParser::new(diff);
819 while let Some(event) = parser.next().unwrap() {
820 events.push(event);
821 }
822
823 assert_eq!(
824 events,
825 &[
826 DiffEvent::Hunk {
827 path: "file.txt".into(),
828 hunk: Hunk {
829 context: "one\ntwo\n".into(),
830 edits: vec![Edit {
831 range: 4..4,
832 text: "AND\n".into()
833 }],
834 start_line: None,
835 },
836 status: FileStatus::Modified,
837 },
838 DiffEvent::FileEnd { renamed_to: None }
839 ],
840 )
841 }
842
843 #[test]
844 fn test_no_newline_at_eof() {
845 let diff = indoc! {"
846 --- a/file.py
847 +++ b/file.py
848 @@ -55,7 +55,3 @@ class CustomDataset(Dataset):
849 torch.set_rng_state(state)
850 mask = self.transform(mask)
851
852 - if self.mode == 'Training':
853 - return (img, mask, name)
854 - else:
855 - return (img, mask, name)
856 \\ No newline at end of file
857 "};
858
859 let mut events = Vec::new();
860 let mut parser = DiffParser::new(diff);
861 while let Some(event) = parser.next().unwrap() {
862 events.push(event);
863 }
864
865 assert_eq!(
866 events,
867 &[
868 DiffEvent::Hunk {
869 path: "file.py".into(),
870 hunk: Hunk {
871 context: concat!(
872 " torch.set_rng_state(state)\n",
873 " mask = self.transform(mask)\n",
874 "\n",
875 " if self.mode == 'Training':\n",
876 " return (img, mask, name)\n",
877 " else:\n",
878 " return (img, mask, name)",
879 )
880 .into(),
881 edits: vec![Edit {
882 range: 80..203,
883 text: "".into()
884 }],
885 start_line: Some(54), // @@ -55,7 -> line 54 (0-indexed)
886 },
887 status: FileStatus::Modified,
888 },
889 DiffEvent::FileEnd { renamed_to: None }
890 ],
891 );
892 }
893
894 #[test]
895 fn test_no_newline_at_eof_addition() {
896 let diff = indoc! {"
897 --- a/file.txt
898 +++ b/file.txt
899 @@ -1,2 +1,3 @@
900 context
901 -deleted
902 +added line
903 \\ No newline at end of file
904 "};
905
906 let mut events = Vec::new();
907 let mut parser = DiffParser::new(diff);
908 while let Some(event) = parser.next().unwrap() {
909 events.push(event);
910 }
911
912 assert_eq!(
913 events,
914 &[
915 DiffEvent::Hunk {
916 path: "file.txt".into(),
917 hunk: Hunk {
918 context: "context\ndeleted\n".into(),
919 edits: vec![Edit {
920 range: 8..16,
921 text: "added line".into()
922 }],
923 start_line: Some(0), // @@ -1,2 -> line 0 (0-indexed)
924 },
925 status: FileStatus::Modified,
926 },
927 DiffEvent::FileEnd { renamed_to: None }
928 ],
929 );
930 }
931
932 #[test]
933 fn test_double_no_newline_at_eof() {
934 // Two consecutive "no newline" markers - the second should be ignored
935 let diff = indoc! {"
936 --- a/file.txt
937 +++ b/file.txt
938 @@ -1,3 +1,3 @@
939 line1
940 -old
941 +new
942 line3
943 \\ No newline at end of file
944 \\ No newline at end of file
945 "};
946
947 let mut events = Vec::new();
948 let mut parser = DiffParser::new(diff);
949 while let Some(event) = parser.next().unwrap() {
950 events.push(event);
951 }
952
953 assert_eq!(
954 events,
955 &[
956 DiffEvent::Hunk {
957 path: "file.txt".into(),
958 hunk: Hunk {
959 context: "line1\nold\nline3".into(), // Only one newline removed
960 edits: vec![Edit {
961 range: 6..10, // "old\n" is 4 bytes
962 text: "new\n".into()
963 }],
964 start_line: Some(0),
965 },
966 status: FileStatus::Modified,
967 },
968 DiffEvent::FileEnd { renamed_to: None }
969 ],
970 );
971 }
972
973 #[test]
974 fn test_no_newline_after_context_not_addition() {
975 // "No newline" after context lines should remove newline from context,
976 // not from an earlier addition
977 let diff = indoc! {"
978 --- a/file.txt
979 +++ b/file.txt
980 @@ -1,4 +1,4 @@
981 line1
982 -old
983 +new
984 line3
985 line4
986 \\ No newline at end of file
987 "};
988
989 let mut events = Vec::new();
990 let mut parser = DiffParser::new(diff);
991 while let Some(event) = parser.next().unwrap() {
992 events.push(event);
993 }
994
995 assert_eq!(
996 events,
997 &[
998 DiffEvent::Hunk {
999 path: "file.txt".into(),
1000 hunk: Hunk {
1001 // newline removed from line4 (context), not from "new" (addition)
1002 context: "line1\nold\nline3\nline4".into(),
1003 edits: vec![Edit {
1004 range: 6..10, // "old\n" is 4 bytes
1005 text: "new\n".into() // Still has newline
1006 }],
1007 start_line: Some(0),
1008 },
1009 status: FileStatus::Modified,
1010 },
1011 DiffEvent::FileEnd { renamed_to: None }
1012 ],
1013 );
1014 }
1015
1016 #[test]
1017 fn test_strip_diff_metadata() {
1018 let diff_with_metadata = indoc! {r#"
1019 diff --git a/file.txt b/file.txt
1020 index 1234567..abcdefg 100644
1021 --- a/file.txt
1022 +++ b/file.txt
1023 @@ -1,3 +1,4 @@
1024 context line
1025 -removed line
1026 +added line
1027 more context
1028 "#};
1029
1030 let stripped = strip_diff_metadata(diff_with_metadata);
1031
1032 assert_eq!(
1033 stripped,
1034 indoc! {r#"
1035 --- a/file.txt
1036 +++ b/file.txt
1037 @@ -1,3 +1,4 @@
1038 context line
1039 -removed line
1040 +added line
1041 more context
1042 "#}
1043 );
1044 }
1045
1046 #[test]
1047 fn test_apply_diff_to_string_no_trailing_newline() {
1048 // Text without trailing newline; diff generated without
1049 // `\ No newline at end of file` marker.
1050 let text = "line1\nline2\nline3";
1051 let diff = indoc! {"
1052 --- a/file.txt
1053 +++ b/file.txt
1054 @@ -1,3 +1,3 @@
1055 line1
1056 -line2
1057 +replaced
1058 line3
1059 "};
1060
1061 let result = apply_diff_to_string(diff, text).unwrap();
1062 assert_eq!(result, "line1\nreplaced\nline3");
1063 }
1064
1065 #[test]
1066 fn test_apply_diff_to_string_trailing_newline_present() {
1067 // When text has a trailing newline, exact matching still works and
1068 // the fallback is never needed.
1069 let text = "line1\nline2\nline3\n";
1070 let diff = indoc! {"
1071 --- a/file.txt
1072 +++ b/file.txt
1073 @@ -1,3 +1,3 @@
1074 line1
1075 -line2
1076 +replaced
1077 line3
1078 "};
1079
1080 let result = apply_diff_to_string(diff, text).unwrap();
1081 assert_eq!(result, "line1\nreplaced\nline3\n");
1082 }
1083
1084 #[test]
1085 fn test_apply_diff_to_string_deletion_at_end_no_trailing_newline() {
1086 // Deletion of the last line when text has no trailing newline.
1087 // The edit range must be clamped so it doesn't index past the
1088 // end of the text.
1089 let text = "line1\nline2\nline3";
1090 let diff = indoc! {"
1091 --- a/file.txt
1092 +++ b/file.txt
1093 @@ -1,3 +1,2 @@
1094 line1
1095 line2
1096 -line3
1097 "};
1098
1099 let result = apply_diff_to_string(diff, text).unwrap();
1100 assert_eq!(result, "line1\nline2\n");
1101 }
1102
1103 #[test]
1104 fn test_apply_diff_to_string_replace_last_line_no_trailing_newline() {
1105 // Replace the last line when text has no trailing newline.
1106 let text = "aaa\nbbb\nccc";
1107 let diff = indoc! {"
1108 --- a/file.txt
1109 +++ b/file.txt
1110 @@ -1,3 +1,3 @@
1111 aaa
1112 bbb
1113 -ccc
1114 +ddd
1115 "};
1116
1117 let result = apply_diff_to_string(diff, text).unwrap();
1118 assert_eq!(result, "aaa\nbbb\nddd");
1119 }
1120
1121 #[test]
1122 fn test_apply_diff_to_string_multibyte_no_trailing_newline() {
1123 // Multi-byte UTF-8 characters near the end; ensures char boundary
1124 // safety when the fallback clamps edit ranges.
1125 let text = "hello\nμΈκ³";
1126 let diff = indoc! {"
1127 --- a/file.txt
1128 +++ b/file.txt
1129 @@ -1,2 +1,2 @@
1130 hello
1131 -μΈκ³
1132 +world
1133 "};
1134
1135 let result = apply_diff_to_string(diff, text).unwrap();
1136 assert_eq!(result, "hello\nworld");
1137 }
1138
1139 #[test]
1140 fn test_find_context_candidates_no_false_positive_mid_text() {
1141 // The stripped fallback must only match at the end of text, not in
1142 // the middle where a real newline exists.
1143 let text = "aaa\nbbb\nccc\n";
1144 let mut hunk = Hunk {
1145 context: "bbb\n".into(),
1146 edits: vec![],
1147 start_line: None,
1148 };
1149
1150 let candidates = find_context_candidates(text, &mut hunk);
1151 // Exact match at offset 4 β the fallback is not used.
1152 assert_eq!(candidates, vec![4]);
1153 }
1154
1155 #[test]
1156 fn test_find_context_candidates_fallback_at_end() {
1157 let text = "aaa\nbbb";
1158 let mut hunk = Hunk {
1159 context: "bbb\n".into(),
1160 edits: vec![],
1161 start_line: None,
1162 };
1163
1164 let candidates = find_context_candidates(text, &mut hunk);
1165 assert_eq!(candidates, vec![4]);
1166 // Context should be stripped.
1167 assert_eq!(hunk.context, "bbb");
1168 }
1169
1170 #[test]
1171 fn test_find_context_candidates_no_fallback_mid_text() {
1172 // "bbb" appears mid-text followed by a newline, so the exact
1173 // match succeeds. Verify the stripped fallback doesn't produce a
1174 // second, spurious candidate.
1175 let text = "aaa\nbbb\nccc";
1176 let mut hunk = Hunk {
1177 context: "bbb\nccc\n".into(),
1178 edits: vec![],
1179 start_line: None,
1180 };
1181
1182 let candidates = find_context_candidates(text, &mut hunk);
1183 // No exact match (text ends without newline after "ccc"), but the
1184 // stripped context "bbb\nccc" matches at offset 4, which is the end.
1185 assert_eq!(candidates, vec![4]);
1186 assert_eq!(hunk.context, "bbb\nccc");
1187 }
1188
1189 #[test]
1190 fn test_find_context_candidates_clamps_edit_ranges() {
1191 let text = "aaa\nbbb";
1192 let mut hunk = Hunk {
1193 context: "aaa\nbbb\n".into(),
1194 edits: vec![Edit {
1195 range: 4..8, // "bbb\n" β end points at the trailing \n
1196 text: "ccc\n".into(),
1197 }],
1198 start_line: None,
1199 };
1200
1201 let candidates = find_context_candidates(text, &mut hunk);
1202 assert_eq!(candidates, vec![0]);
1203 // Edit range end should be clamped to 7 (new context length).
1204 assert_eq!(hunk.edits[0].range, 4..7);
1205 }
1206}