edit_action.rs

  1use std::path::{Path, PathBuf};
  2use util::ResultExt;
  3
  4/// Represents an edit action to be performed on a file.
  5#[derive(Debug, Clone, PartialEq, Eq)]
  6pub enum EditAction {
  7    /// Replace specific content in a file with new content
  8    Replace {
  9        file_path: PathBuf,
 10        old: String,
 11        new: String,
 12    },
 13    /// Write content to a file (create or overwrite)
 14    Write { file_path: PathBuf, content: String },
 15}
 16
 17impl EditAction {
 18    pub fn file_path(&self) -> &Path {
 19        match self {
 20            EditAction::Replace { file_path, .. } => file_path,
 21            EditAction::Write { file_path, .. } => file_path,
 22        }
 23    }
 24}
 25
 26/// Parses edit actions from an LLM response.
 27/// See system.md for more details on the format.
 28#[derive(Debug)]
 29pub struct EditActionParser {
 30    state: State,
 31    pre_fence_line: Vec<u8>,
 32    marker_ix: usize,
 33    line: usize,
 34    column: usize,
 35    old_bytes: Vec<u8>,
 36    new_bytes: Vec<u8>,
 37    errors: Vec<ParseError>,
 38}
 39
 40#[derive(Debug, PartialEq, Eq)]
 41enum State {
 42    /// Anywhere outside an action
 43    Default,
 44    /// After opening ```, in optional language tag
 45    OpenFence,
 46    /// In SEARCH marker
 47    SearchMarker,
 48    /// In search block or divider
 49    SearchBlock,
 50    /// In replace block or REPLACE marker
 51    ReplaceBlock,
 52    /// In closing ```
 53    CloseFence,
 54}
 55
 56impl EditActionParser {
 57    /// Creates a new `EditActionParser`
 58    pub fn new() -> Self {
 59        Self {
 60            state: State::Default,
 61            pre_fence_line: Vec::new(),
 62            marker_ix: 0,
 63            line: 1,
 64            column: 0,
 65            old_bytes: Vec::new(),
 66            new_bytes: Vec::new(),
 67            errors: Vec::new(),
 68        }
 69    }
 70
 71    /// Processes a chunk of input text and returns any completed edit actions.
 72    ///
 73    /// This method can be called repeatedly with fragments of input. The parser
 74    /// maintains its state between calls, allowing you to process streaming input
 75    /// as it becomes available. Actions are only inserted once they are fully parsed.
 76    ///
 77    /// If a block fails to parse, it will simply be skipped and an error will be recorded.
 78    /// All errors can be accessed through the `EditActionsParser::errors` method.
 79    pub fn parse_chunk(&mut self, input: &str) -> Vec<EditAction> {
 80        use State::*;
 81
 82        const FENCE: &[u8] = b"```";
 83        const SEARCH_MARKER: &[u8] = b"<<<<<<< SEARCH";
 84        const DIVIDER: &[u8] = b"=======";
 85        const NL_DIVIDER: &[u8] = b"\n=======";
 86        const REPLACE_MARKER: &[u8] = b">>>>>>> REPLACE";
 87        const NL_REPLACE_MARKER: &[u8] = b"\n>>>>>>> REPLACE";
 88
 89        let mut actions = Vec::new();
 90
 91        for byte in input.bytes() {
 92            // Update line and column tracking
 93            if byte == b'\n' {
 94                self.line += 1;
 95                self.column = 0;
 96            } else {
 97                self.column += 1;
 98            }
 99
100            match &self.state {
101                Default => match match_marker(byte, FENCE, false, &mut self.marker_ix) {
102                    MarkerMatch::Complete => {
103                        self.to_state(OpenFence);
104                    }
105                    MarkerMatch::Partial => {}
106                    MarkerMatch::None => {
107                        if self.marker_ix > 0 {
108                            self.marker_ix = 0;
109                        } else if self.pre_fence_line.ends_with(b"\n") {
110                            self.pre_fence_line.clear();
111                        }
112
113                        self.pre_fence_line.push(byte);
114                    }
115                },
116                OpenFence => {
117                    // skip language tag
118                    if byte == b'\n' {
119                        self.to_state(SearchMarker);
120                    }
121                }
122                SearchMarker => {
123                    if self.expect_marker(byte, SEARCH_MARKER, true) {
124                        self.to_state(SearchBlock);
125                    }
126                }
127                SearchBlock => {
128                    if collect_until_marker(
129                        byte,
130                        DIVIDER,
131                        NL_DIVIDER,
132                        true,
133                        &mut self.marker_ix,
134                        &mut self.old_bytes,
135                    ) {
136                        self.to_state(ReplaceBlock);
137                    }
138                }
139                ReplaceBlock => {
140                    if collect_until_marker(
141                        byte,
142                        REPLACE_MARKER,
143                        NL_REPLACE_MARKER,
144                        true,
145                        &mut self.marker_ix,
146                        &mut self.new_bytes,
147                    ) {
148                        self.to_state(CloseFence);
149                    }
150                }
151                CloseFence => {
152                    if self.expect_marker(byte, FENCE, false) {
153                        if let Some(action) = self.action() {
154                            actions.push(action);
155                        }
156                        self.errors();
157                        self.reset();
158                    }
159                }
160            };
161        }
162
163        actions
164    }
165
166    /// Returns a reference to the errors encountered during parsing.
167    pub fn errors(&self) -> &[ParseError] {
168        &self.errors
169    }
170
171    fn action(&mut self) -> Option<EditAction> {
172        if self.old_bytes.is_empty() && self.new_bytes.is_empty() {
173            self.push_error(ParseErrorKind::NoOp);
174            return None;
175        }
176
177        let mut pre_fence_line = std::mem::take(&mut self.pre_fence_line);
178
179        if pre_fence_line.ends_with(b"\n") {
180            pre_fence_line.pop();
181            pop_carriage_return(&mut pre_fence_line);
182        }
183
184        let file_path = PathBuf::from(String::from_utf8(pre_fence_line).log_err()?);
185        let content = String::from_utf8(std::mem::take(&mut self.new_bytes)).log_err()?;
186
187        if self.old_bytes.is_empty() {
188            Some(EditAction::Write { file_path, content })
189        } else {
190            let old = String::from_utf8(std::mem::take(&mut self.old_bytes)).log_err()?;
191
192            Some(EditAction::Replace {
193                file_path,
194                old,
195                new: content,
196            })
197        }
198    }
199
200    fn expect_marker(&mut self, byte: u8, marker: &'static [u8], trailing_newline: bool) -> bool {
201        match match_marker(byte, marker, trailing_newline, &mut self.marker_ix) {
202            MarkerMatch::Complete => true,
203            MarkerMatch::Partial => false,
204            MarkerMatch::None => {
205                self.push_error(ParseErrorKind::ExpectedMarker {
206                    expected: marker,
207                    found: byte,
208                });
209                self.reset();
210                false
211            }
212        }
213    }
214
215    fn to_state(&mut self, state: State) {
216        self.state = state;
217        self.marker_ix = 0;
218    }
219
220    fn reset(&mut self) {
221        self.pre_fence_line.clear();
222        self.old_bytes.clear();
223        self.new_bytes.clear();
224        self.to_state(State::Default);
225    }
226
227    fn push_error(&mut self, kind: ParseErrorKind) {
228        self.errors.push(ParseError {
229            line: self.line,
230            column: self.column,
231            kind,
232        });
233    }
234}
235
236#[derive(Debug)]
237enum MarkerMatch {
238    None,
239    Partial,
240    Complete,
241}
242
243fn match_marker(
244    byte: u8,
245    marker: &[u8],
246    trailing_newline: bool,
247    marker_ix: &mut usize,
248) -> MarkerMatch {
249    if trailing_newline && *marker_ix >= marker.len() {
250        if byte == b'\n' {
251            MarkerMatch::Complete
252        } else if byte == b'\r' {
253            MarkerMatch::Partial
254        } else {
255            MarkerMatch::None
256        }
257    } else if byte == marker[*marker_ix] {
258        *marker_ix += 1;
259
260        if *marker_ix < marker.len() || trailing_newline {
261            MarkerMatch::Partial
262        } else {
263            MarkerMatch::Complete
264        }
265    } else {
266        MarkerMatch::None
267    }
268}
269
270fn collect_until_marker(
271    byte: u8,
272    marker: &[u8],
273    nl_marker: &[u8],
274    trailing_newline: bool,
275    marker_ix: &mut usize,
276    buf: &mut Vec<u8>,
277) -> bool {
278    let marker = if buf.is_empty() {
279        // do not require another newline if block is empty
280        marker
281    } else {
282        nl_marker
283    };
284
285    match match_marker(byte, marker, trailing_newline, marker_ix) {
286        MarkerMatch::Complete => {
287            pop_carriage_return(buf);
288            true
289        }
290        MarkerMatch::Partial => false,
291        MarkerMatch::None => {
292            if *marker_ix > 0 {
293                buf.extend_from_slice(&marker[..*marker_ix]);
294                *marker_ix = 0;
295
296                // The beginning of marker might match current byte
297                match match_marker(byte, marker, trailing_newline, marker_ix) {
298                    MarkerMatch::Complete => return true,
299                    MarkerMatch::Partial => return false,
300                    MarkerMatch::None => { /* no match, keep collecting */ }
301                }
302            }
303
304            buf.push(byte);
305
306            false
307        }
308    }
309}
310
311fn pop_carriage_return(buf: &mut Vec<u8>) {
312    if buf.ends_with(b"\r") {
313        buf.pop();
314    }
315}
316
317#[derive(Debug, PartialEq, Eq)]
318pub struct ParseError {
319    line: usize,
320    column: usize,
321    kind: ParseErrorKind,
322}
323
324#[derive(Debug, PartialEq, Eq)]
325pub enum ParseErrorKind {
326    ExpectedMarker { expected: &'static [u8], found: u8 },
327    NoOp,
328}
329
330impl std::fmt::Display for ParseErrorKind {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        match self {
333            ParseErrorKind::ExpectedMarker { expected, found } => {
334                write!(
335                    f,
336                    "Expected marker {:?}, found {:?}",
337                    String::from_utf8_lossy(expected),
338                    *found as char
339                )
340            }
341            ParseErrorKind::NoOp => {
342                write!(f, "No search or replace")
343            }
344        }
345    }
346}
347
348impl std::fmt::Display for ParseError {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        write!(f, "input:{}:{}: {}", self.line, self.column, self.kind)
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use rand::prelude::*;
358
359    #[test]
360    fn test_simple_edit_action() {
361        let input = r#"src/main.rs
362```
363<<<<<<< SEARCH
364fn original() {}
365=======
366fn replacement() {}
367>>>>>>> REPLACE
368```
369"#;
370
371        let mut parser = EditActionParser::new();
372        let actions = parser.parse_chunk(input);
373
374        assert_eq!(actions.len(), 1);
375        assert_eq!(
376            actions[0],
377            EditAction::Replace {
378                file_path: PathBuf::from("src/main.rs"),
379                old: "fn original() {}".to_string(),
380                new: "fn replacement() {}".to_string(),
381            }
382        );
383        assert_eq!(parser.errors().len(), 0);
384    }
385
386    #[test]
387    fn test_with_language_tag() {
388        let input = r#"src/main.rs
389```rust
390<<<<<<< SEARCH
391fn original() {}
392=======
393fn replacement() {}
394>>>>>>> REPLACE
395```
396"#;
397
398        let mut parser = EditActionParser::new();
399        let actions = parser.parse_chunk(input);
400
401        assert_eq!(actions.len(), 1);
402        assert_eq!(
403            actions[0],
404            EditAction::Replace {
405                file_path: PathBuf::from("src/main.rs"),
406                old: "fn original() {}".to_string(),
407                new: "fn replacement() {}".to_string(),
408            }
409        );
410        assert_eq!(parser.errors().len(), 0);
411    }
412
413    #[test]
414    fn test_with_surrounding_text() {
415        let input = r#"Here's a modification I'd like to make to the file:
416
417src/main.rs
418```rust
419<<<<<<< SEARCH
420fn original() {}
421=======
422fn replacement() {}
423>>>>>>> REPLACE
424```
425
426This change makes the function better.
427"#;
428
429        let mut parser = EditActionParser::new();
430        let actions = parser.parse_chunk(input);
431
432        assert_eq!(actions.len(), 1);
433        assert_eq!(
434            actions[0],
435            EditAction::Replace {
436                file_path: PathBuf::from("src/main.rs"),
437                old: "fn original() {}".to_string(),
438                new: "fn replacement() {}".to_string(),
439            }
440        );
441        assert_eq!(parser.errors().len(), 0);
442    }
443
444    #[test]
445    fn test_multiple_edit_actions() {
446        let input = r#"First change:
447src/main.rs
448```
449<<<<<<< SEARCH
450fn original() {}
451=======
452fn replacement() {}
453>>>>>>> REPLACE
454```
455
456Second change:
457src/utils.rs
458```rust
459<<<<<<< SEARCH
460fn old_util() -> bool { false }
461=======
462fn new_util() -> bool { true }
463>>>>>>> REPLACE
464```
465"#;
466
467        let mut parser = EditActionParser::new();
468        let actions = parser.parse_chunk(input);
469
470        assert_eq!(actions.len(), 2);
471        assert_eq!(
472            actions[0],
473            EditAction::Replace {
474                file_path: PathBuf::from("src/main.rs"),
475                old: "fn original() {}".to_string(),
476                new: "fn replacement() {}".to_string(),
477            }
478        );
479        assert_eq!(
480            actions[1],
481            EditAction::Replace {
482                file_path: PathBuf::from("src/utils.rs"),
483                old: "fn old_util() -> bool { false }".to_string(),
484                new: "fn new_util() -> bool { true }".to_string(),
485            }
486        );
487        assert_eq!(parser.errors().len(), 0);
488    }
489
490    #[test]
491    fn test_multiline() {
492        let input = r#"src/main.rs
493```rust
494<<<<<<< SEARCH
495fn original() {
496    println!("This is the original function");
497    let x = 42;
498    if x > 0 {
499        println!("Positive number");
500    }
501}
502=======
503fn replacement() {
504    println!("This is the replacement function");
505    let x = 100;
506    if x > 50 {
507        println!("Large number");
508    } else {
509        println!("Small number");
510    }
511}
512>>>>>>> REPLACE
513```
514"#;
515
516        let mut parser = EditActionParser::new();
517        let actions = parser.parse_chunk(input);
518
519        assert_eq!(actions.len(), 1);
520        assert_eq!(
521            actions[0],
522            EditAction::Replace {
523                file_path: PathBuf::from("src/main.rs"),
524                old: "fn original() {\n    println!(\"This is the original function\");\n    let x = 42;\n    if x > 0 {\n        println!(\"Positive number\");\n    }\n}".to_string(),
525                new: "fn replacement() {\n    println!(\"This is the replacement function\");\n    let x = 100;\n    if x > 50 {\n        println!(\"Large number\");\n    } else {\n        println!(\"Small number\");\n    }\n}".to_string(),
526            }
527        );
528        assert_eq!(parser.errors().len(), 0);
529    }
530
531    #[test]
532    fn test_write_action() {
533        let input = r#"Create a new main.rs file:
534
535src/main.rs
536```rust
537<<<<<<< SEARCH
538=======
539fn new_function() {
540    println!("This function is being added");
541}
542>>>>>>> REPLACE
543```
544"#;
545
546        let mut parser = EditActionParser::new();
547        let actions = parser.parse_chunk(input);
548
549        assert_eq!(actions.len(), 1);
550        assert_eq!(
551            actions[0],
552            EditAction::Write {
553                file_path: PathBuf::from("src/main.rs"),
554                content: "fn new_function() {\n    println!(\"This function is being added\");\n}"
555                    .to_string(),
556            }
557        );
558        assert_eq!(parser.errors().len(), 0);
559    }
560
561    #[test]
562    fn test_empty_replace() {
563        let input = r#"src/main.rs
564```rust
565<<<<<<< SEARCH
566fn this_will_be_deleted() {
567    println!("Deleting this function");
568}
569=======
570>>>>>>> REPLACE
571```
572"#;
573
574        let mut parser = EditActionParser::new();
575        let actions = parser.parse_chunk(&input);
576        assert_eq!(actions.len(), 1);
577        assert_eq!(
578            actions[0],
579            EditAction::Replace {
580                file_path: PathBuf::from("src/main.rs"),
581                old: "fn this_will_be_deleted() {\n    println!(\"Deleting this function\");\n}"
582                    .to_string(),
583                new: "".to_string(),
584            }
585        );
586        assert_eq!(parser.errors().len(), 0);
587
588        let actions = parser.parse_chunk(&input.replace("\n", "\r\n"));
589        assert_eq!(actions.len(), 1);
590        assert_eq!(
591            actions[0],
592            EditAction::Replace {
593                file_path: PathBuf::from("src/main.rs"),
594                old:
595                    "fn this_will_be_deleted() {\r\n    println!(\"Deleting this function\");\r\n}"
596                        .to_string(),
597                new: "".to_string(),
598            }
599        );
600        assert_eq!(parser.errors().len(), 0);
601    }
602
603    #[test]
604    fn test_empty_both() {
605        let input = r#"src/main.rs
606```rust
607<<<<<<< SEARCH
608=======
609>>>>>>> REPLACE
610```
611"#;
612
613        let mut parser = EditActionParser::new();
614        let actions = parser.parse_chunk(input);
615
616        // Should not create an action when both sections are empty
617        assert_eq!(actions.len(), 0);
618
619        // Check that the NoOp error was added
620        assert_eq!(parser.errors().len(), 1);
621        match parser.errors()[0].kind {
622            ParseErrorKind::NoOp => {}
623            _ => panic!("Expected NoOp error"),
624        }
625    }
626
627    #[test]
628    fn test_resumability() {
629        let input_part1 = r#"src/main.rs
630```rust
631<<<<<<< SEARCH
632fn ori"#;
633
634        let input_part2 = r#"ginal() {}
635=======
636fn replacement() {}"#;
637
638        let input_part3 = r#"
639>>>>>>> REPLACE
640```
641"#;
642
643        let mut parser = EditActionParser::new();
644        let actions1 = parser.parse_chunk(input_part1);
645        assert_eq!(actions1.len(), 0);
646        assert_eq!(parser.errors().len(), 0);
647
648        let actions2 = parser.parse_chunk(input_part2);
649        // No actions should be complete yet
650        assert_eq!(actions2.len(), 0);
651        assert_eq!(parser.errors().len(), 0);
652
653        let actions3 = parser.parse_chunk(input_part3);
654        // The third chunk should complete the action
655        assert_eq!(actions3.len(), 1);
656        assert_eq!(
657            actions3[0],
658            EditAction::Replace {
659                file_path: PathBuf::from("src/main.rs"),
660                old: "fn original() {}".to_string(),
661                new: "fn replacement() {}".to_string(),
662            }
663        );
664        assert_eq!(parser.errors().len(), 0);
665    }
666
667    #[test]
668    fn test_parser_state_preservation() {
669        let mut parser = EditActionParser::new();
670        let actions1 = parser.parse_chunk("src/main.rs\n```rust\n<<<<<<< SEARCH\n");
671
672        // Check parser is in the correct state
673        assert_eq!(parser.state, State::SearchBlock);
674        assert_eq!(parser.pre_fence_line, b"src/main.rs\n");
675        assert_eq!(parser.errors().len(), 0);
676
677        // Continue parsing
678        let actions2 = parser.parse_chunk("original code\n=======\n");
679        assert_eq!(parser.state, State::ReplaceBlock);
680        assert_eq!(parser.old_bytes, b"original code");
681        assert_eq!(parser.errors().len(), 0);
682
683        let actions3 = parser.parse_chunk("replacement code\n>>>>>>> REPLACE\n```\n");
684
685        // After complete parsing, state should reset
686        assert_eq!(parser.state, State::Default);
687        assert_eq!(parser.pre_fence_line, b"\n");
688        assert!(parser.old_bytes.is_empty());
689        assert!(parser.new_bytes.is_empty());
690
691        assert_eq!(actions1.len(), 0);
692        assert_eq!(actions2.len(), 0);
693        assert_eq!(actions3.len(), 1);
694        assert_eq!(parser.errors().len(), 0);
695    }
696
697    #[test]
698    fn test_invalid_search_marker() {
699        let input = r#"src/main.rs
700```rust
701<<<<<<< WRONG_MARKER
702fn original() {}
703=======
704fn replacement() {}
705>>>>>>> REPLACE
706```
707"#;
708
709        let mut parser = EditActionParser::new();
710        let actions = parser.parse_chunk(input);
711        assert_eq!(actions.len(), 0);
712
713        assert_eq!(parser.errors().len(), 1);
714        let error = &parser.errors()[0];
715
716        assert_eq!(
717            error.to_string(),
718            "input:3:9: Expected marker \"<<<<<<< SEARCH\", found 'W'"
719        );
720    }
721
722    #[test]
723    fn test_missing_closing_fence() {
724        let input = r#"src/main.rs
725```rust
726<<<<<<< SEARCH
727fn original() {}
728=======
729fn replacement() {}
730>>>>>>> REPLACE
731<!-- Missing closing fence -->
732
733src/utils.rs
734```rust
735<<<<<<< SEARCH
736fn utils_func() {}
737=======
738fn new_utils_func() {}
739>>>>>>> REPLACE
740```
741"#;
742
743        let mut parser = EditActionParser::new();
744        let actions = parser.parse_chunk(input);
745
746        // Only the second block should be parsed
747        assert_eq!(actions.len(), 1);
748        assert_eq!(
749            actions[0],
750            EditAction::Replace {
751                file_path: PathBuf::from("src/utils.rs"),
752                old: "fn utils_func() {}".to_string(),
753                new: "fn new_utils_func() {}".to_string(),
754            }
755        );
756        assert_eq!(parser.errors().len(), 1);
757        assert_eq!(
758            parser.errors()[0].to_string(),
759            "input:8:1: Expected marker \"```\", found '<'".to_string()
760        );
761
762        // The parser should continue after an error
763        assert_eq!(parser.state, State::Default);
764    }
765
766    const SYSTEM_PROMPT: &str = include_str!("./edit_prompt.md");
767
768    #[test]
769    fn test_parse_examples_in_system_prompt() {
770        let mut parser = EditActionParser::new();
771        let actions = parser.parse_chunk(SYSTEM_PROMPT);
772        assert_examples_in_system_prompt(&actions, parser.errors());
773    }
774
775    #[gpui::test(iterations = 10)]
776    fn test_random_chunking_of_system_prompt(mut rng: StdRng) {
777        let mut parser = EditActionParser::new();
778        let mut remaining = SYSTEM_PROMPT;
779        let mut actions = Vec::with_capacity(5);
780
781        while !remaining.is_empty() {
782            let chunk_size = rng.gen_range(1..=std::cmp::min(remaining.len(), 100));
783
784            let (chunk, rest) = remaining.split_at(chunk_size);
785
786            actions.extend(parser.parse_chunk(chunk));
787            remaining = rest;
788        }
789
790        assert_examples_in_system_prompt(&actions, parser.errors());
791    }
792
793    fn assert_examples_in_system_prompt(actions: &[EditAction], errors: &[ParseError]) {
794        assert_eq!(actions.len(), 5);
795
796        assert_eq!(
797            actions[0],
798            EditAction::Replace {
799                file_path: PathBuf::from("mathweb/flask/app.py"),
800                old: "from flask import Flask".to_string(),
801                new: "import math\nfrom flask import Flask".to_string(),
802            }
803            .fix_lf(),
804        );
805
806        assert_eq!(
807            actions[1],
808            EditAction::Replace {
809                file_path: PathBuf::from("mathweb/flask/app.py"),
810                old: "def factorial(n):\n    \"compute factorial\"\n\n    if n == 0:\n        return 1\n    else:\n        return n * factorial(n-1)\n".to_string(),
811                new: "".to_string(),
812            }
813            .fix_lf()
814        );
815
816        assert_eq!(
817            actions[2],
818            EditAction::Replace {
819                file_path: PathBuf::from("mathweb/flask/app.py"),
820                old: "    return str(factorial(n))".to_string(),
821                new: "    return str(math.factorial(n))".to_string(),
822            }
823            .fix_lf(),
824        );
825
826        assert_eq!(
827            actions[3],
828            EditAction::Write {
829                file_path: PathBuf::from("hello.py"),
830                content: "def hello():\n    \"print a greeting\"\n\n    print(\"hello\")"
831                    .to_string(),
832            }
833            .fix_lf(),
834        );
835
836        assert_eq!(
837            actions[4],
838            EditAction::Replace {
839                file_path: PathBuf::from("main.py"),
840                old: "def hello():\n    \"print a greeting\"\n\n    print(\"hello\")".to_string(),
841                new: "from hello import hello".to_string(),
842            }
843            .fix_lf(),
844        );
845
846        // The system prompt includes some text that would produce errors
847        assert_eq!(
848            errors[0].to_string(),
849            "input:102:1: Expected marker \"<<<<<<< SEARCH\", found '3'"
850        );
851        #[cfg(not(windows))]
852        assert_eq!(
853            errors[1].to_string(),
854            "input:109:0: Expected marker \"<<<<<<< SEARCH\", found '\\n'"
855        );
856        #[cfg(windows)]
857        assert_eq!(
858            errors[1].to_string(),
859            "input:108:1: Expected marker \"<<<<<<< SEARCH\", found '\\r'"
860        );
861    }
862
863    impl EditAction {
864        fn fix_lf(self: EditAction) -> EditAction {
865            #[cfg(windows)]
866            match self {
867                EditAction::Replace {
868                    file_path,
869                    old,
870                    new,
871                } => EditAction::Replace {
872                    file_path: file_path.clone(),
873                    old: old.replace("\n", "\r\n"),
874                    new: new.replace("\n", "\r\n"),
875                },
876                EditAction::Write { file_path, content } => EditAction::Write {
877                    file_path: file_path.clone(),
878                    content: content.replace("\n", "\r\n"),
879                },
880            }
881            #[cfg(not(windows))]
882            self
883        }
884    }
885
886    #[test]
887    fn test_print_error() {
888        let input = r#"src/main.rs
889```rust
890<<<<<<< WRONG_MARKER
891fn original() {}
892=======
893fn replacement() {}
894>>>>>>> REPLACE
895```
896"#;
897
898        let mut parser = EditActionParser::new();
899        parser.parse_chunk(input);
900
901        assert_eq!(parser.errors().len(), 1);
902        let error = &parser.errors()[0];
903        let expected_error = r#"input:3:9: Expected marker "<<<<<<< SEARCH", found 'W'"#;
904
905        assert_eq!(format!("{}", error), expected_error);
906    }
907}