edit_action.rs

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