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