Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:08:50.814Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

udiff.rs

1438 lines · 47.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.