edit_action.rs

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