Skip to main content

codex_apply_patch/
streaming_parser.rs

1use std::path::PathBuf;
2
3use crate::parser::ADD_FILE_MARKER;
4use crate::parser::BEGIN_PATCH_MARKER;
5use crate::parser::CHANGE_CONTEXT_MARKER;
6use crate::parser::DELETE_FILE_MARKER;
7use crate::parser::EMPTY_CHANGE_CONTEXT_MARKER;
8use crate::parser::END_PATCH_MARKER;
9use crate::parser::EOF_MARKER;
10use crate::parser::Hunk;
11use crate::parser::MOVE_TO_MARKER;
12use crate::parser::ParseError;
13use crate::parser::UPDATE_FILE_MARKER;
14use crate::parser::UpdateFileChunk;
15
16use Hunk::*;
17use ParseError::*;
18
19const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID:";
20
21#[derive(Debug, Default, Clone)]
22pub struct StreamingPatchParser {
23    line_buffer: String,
24    state: StreamingParserState,
25    line_number: usize,
26}
27
28#[derive(Debug, Default, Clone)]
29struct StreamingParserState {
30    mode: StreamingParserMode,
31    hunks: Vec<Hunk>,
32    environment_id: Option<String>,
33}
34
35#[derive(Debug, Default, Clone, Copy)]
36enum StreamingParserMode {
37    #[default]
38    NotStarted,
39    StartedPatch,
40    AddFile,
41    DeleteFile,
42    UpdateFile {
43        hunk_line_number: usize,
44    },
45    EndedPatch,
46}
47
48impl StreamingPatchParser {
49    pub fn environment_id(&self) -> Option<&str> {
50        self.state.environment_id.as_deref()
51    }
52
53    fn ensure_update_hunk_is_not_empty(&self, line: &str) -> Result<(), ParseError> {
54        if let Some(UpdateFile { path, chunks, .. }) = self.state.hunks.last() {
55            if chunks.is_empty()
56                && let StreamingParserMode::UpdateFile { hunk_line_number } = self.state.mode
57            {
58                return Err(InvalidHunkError {
59                    message: format!("Update file hunk for path '{}' is empty", path.display()),
60                    line_number: hunk_line_number,
61                });
62            }
63            if chunks
64                .last()
65                .is_some_and(|chunk| chunk.old_lines.is_empty() && chunk.new_lines.is_empty())
66            {
67                if line == END_PATCH_MARKER {
68                    return Err(InvalidHunkError {
69                        message: "Update hunk does not contain any lines".to_string(),
70                        line_number: self.line_number,
71                    });
72                }
73                return Err(InvalidHunkError {
74                    message: format!(
75                        "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
76                    ),
77                    line_number: self.line_number,
78                });
79            }
80        }
81        Ok(())
82    }
83
84    fn handle_hunk_headers_and_end_patch(&mut self, trimmed: &str) -> Result<bool, ParseError> {
85        if matches!(self.state.mode, StreamingParserMode::StartedPatch)
86            && let Some(environment_id) = trimmed.strip_prefix(ENVIRONMENT_ID_MARKER)
87        {
88            if self.state.environment_id.is_some() {
89                return Err(InvalidPatchError(
90                    "apply_patch environment_id cannot be specified more than once".to_string(),
91                ));
92            }
93            let environment_id = environment_id.trim();
94            if environment_id.is_empty() {
95                return Err(InvalidPatchError(
96                    "apply_patch environment_id cannot be empty".to_string(),
97                ));
98            }
99            self.state.environment_id = Some(environment_id.to_string());
100            return Ok(true);
101        }
102        if trimmed == END_PATCH_MARKER {
103            self.ensure_update_hunk_is_not_empty(trimmed)?;
104            self.state.mode = StreamingParserMode::EndedPatch;
105            return Ok(true);
106        }
107        if let Some(path) = trimmed.strip_prefix(ADD_FILE_MARKER) {
108            self.ensure_update_hunk_is_not_empty(trimmed)?;
109            self.state.hunks.push(AddFile {
110                path: PathBuf::from(path),
111                contents: String::new(),
112            });
113            self.state.mode = StreamingParserMode::AddFile;
114            return Ok(true);
115        }
116        if let Some(path) = trimmed.strip_prefix(DELETE_FILE_MARKER) {
117            self.ensure_update_hunk_is_not_empty(trimmed)?;
118            self.state.hunks.push(DeleteFile {
119                path: PathBuf::from(path),
120            });
121            self.state.mode = StreamingParserMode::DeleteFile;
122            return Ok(true);
123        }
124        if let Some(path) = trimmed.strip_prefix(UPDATE_FILE_MARKER) {
125            self.ensure_update_hunk_is_not_empty(trimmed)?;
126            self.state.hunks.push(UpdateFile {
127                path: PathBuf::from(path),
128                move_path: None,
129                chunks: Vec::new(),
130            });
131            self.state.mode = StreamingParserMode::UpdateFile {
132                hunk_line_number: self.line_number,
133            };
134            return Ok(true);
135        }
136        Ok(false)
137    }
138
139    pub fn push_delta(&mut self, delta: &str) -> Result<Vec<Hunk>, ParseError> {
140        for ch in delta.chars() {
141            if ch == '\n' {
142                let mut line = std::mem::take(&mut self.line_buffer);
143                line.truncate(line.strip_suffix('\r').map_or(line.len(), str::len));
144                self.line_number += 1;
145                self.process_line(&line)?;
146            } else {
147                self.line_buffer.push(ch);
148            }
149        }
150
151        Ok(self.state.hunks.clone())
152    }
153
154    pub fn finish(&mut self) -> Result<Vec<Hunk>, ParseError> {
155        if !self.line_buffer.is_empty() {
156            let line = std::mem::take(&mut self.line_buffer);
157            self.line_number += 1;
158            if line.trim() == END_PATCH_MARKER {
159                self.ensure_update_hunk_is_not_empty(line.trim())?;
160                self.state.mode = StreamingParserMode::EndedPatch;
161            } else {
162                self.process_line(&line)?;
163            }
164        }
165
166        if !matches!(self.state.mode, StreamingParserMode::EndedPatch) {
167            return Err(InvalidPatchError(
168                "The last line of the patch must be '*** End Patch'".to_string(),
169            ));
170        }
171
172        Ok(self.state.hunks.clone())
173    }
174
175    fn process_line(&mut self, line: &str) -> Result<(), ParseError> {
176        let trimmed = line.trim();
177        match self.state.mode {
178            StreamingParserMode::NotStarted => {
179                if trimmed == BEGIN_PATCH_MARKER {
180                    self.state.mode = StreamingParserMode::StartedPatch;
181                    return Ok(());
182                }
183                Err(InvalidPatchError(
184                    "The first line of the patch must be '*** Begin Patch'".to_string(),
185                ))
186            }
187            StreamingParserMode::StartedPatch => {
188                if self.handle_hunk_headers_and_end_patch(trimmed)? {
189                    return Ok(());
190                }
191                Err(InvalidHunkError {
192                    message: format!(
193                        "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
194                    ),
195                    line_number: self.line_number,
196                })
197            }
198            StreamingParserMode::AddFile => {
199                if self.handle_hunk_headers_and_end_patch(trimmed)? {
200                    return Ok(());
201                }
202                if let Some(line_to_add) = line.strip_prefix('+')
203                    && let Some(AddFile { contents, .. }) = self.state.hunks.last_mut()
204                {
205                    contents.push_str(line_to_add);
206                    contents.push('\n');
207                    return Ok(());
208                }
209                Err(InvalidHunkError {
210                    message: format!(
211                        "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
212                    ),
213                    line_number: self.line_number,
214                })
215            }
216            StreamingParserMode::DeleteFile => {
217                if self.handle_hunk_headers_and_end_patch(trimmed)? {
218                    return Ok(());
219                }
220                Err(InvalidHunkError {
221                    message: format!(
222                        "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
223                    ),
224                    line_number: self.line_number,
225                })
226            }
227            StreamingParserMode::UpdateFile { hunk_line_number } => {
228                let update_line = line.trim_end();
229                if self.handle_hunk_headers_and_end_patch(update_line)? {
230                    return Ok(());
231                }
232
233                if let Some(UpdateFile {
234                    move_path, chunks, ..
235                }) = self.state.hunks.last_mut()
236                {
237                    if chunks.last().is_some_and(|chunk| chunk.is_end_of_file) {
238                        if update_line.is_empty() {
239                            return Ok(());
240                        }
241                        if update_line != EMPTY_CHANGE_CONTEXT_MARKER
242                            && !update_line.starts_with(CHANGE_CONTEXT_MARKER)
243                        {
244                            return Err(InvalidHunkError {
245                                message: format!(
246                                    "Expected update hunk to start with a @@ context marker, got: '{line}'"
247                                ),
248                                line_number: self.line_number,
249                            });
250                        }
251                    }
252
253                    if chunks.is_empty()
254                        && move_path.is_none()
255                        && let Some(move_to_path) = update_line.strip_prefix(MOVE_TO_MARKER)
256                    {
257                        *move_path = Some(PathBuf::from(move_to_path));
258                        self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
259                        return Ok(());
260                    }
261
262                    if (update_line == EMPTY_CHANGE_CONTEXT_MARKER
263                        || update_line.starts_with(CHANGE_CONTEXT_MARKER))
264                        && chunks.last().is_some_and(|chunk| {
265                            chunk.old_lines.is_empty() && chunk.new_lines.is_empty()
266                        })
267                    {
268                        return Err(InvalidHunkError {
269                            message: format!(
270                                "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
271                            ),
272                            line_number: self.line_number,
273                        });
274                    }
275
276                    if update_line == EMPTY_CHANGE_CONTEXT_MARKER {
277                        chunks.push(UpdateFileChunk {
278                            change_context: None,
279                            old_lines: Vec::new(),
280                            new_lines: Vec::new(),
281                            is_end_of_file: false,
282                        });
283                        self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
284                        return Ok(());
285                    }
286
287                    if let Some(change_context) = update_line.strip_prefix(CHANGE_CONTEXT_MARKER) {
288                        chunks.push(UpdateFileChunk {
289                            change_context: Some(change_context.to_string()),
290                            old_lines: Vec::new(),
291                            new_lines: Vec::new(),
292                            is_end_of_file: false,
293                        });
294                        self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
295                        return Ok(());
296                    }
297
298                    if update_line == EOF_MARKER {
299                        if chunks.last().is_some_and(|chunk| {
300                            chunk.old_lines.is_empty() && chunk.new_lines.is_empty()
301                        }) {
302                            return Err(InvalidHunkError {
303                                message: "Update hunk does not contain any lines".to_string(),
304                                line_number: self.line_number,
305                            });
306                        }
307                        if let Some(chunk) = chunks.last_mut() {
308                            chunk.is_end_of_file = true;
309                        }
310                        self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
311                        return Ok(());
312                    }
313
314                    if line.is_empty() {
315                        if chunks.is_empty() {
316                            chunks.push(UpdateFileChunk {
317                                change_context: None,
318                                old_lines: Vec::new(),
319                                new_lines: Vec::new(),
320                                is_end_of_file: false,
321                            });
322                        }
323                        if let Some(chunk) = chunks.last_mut() {
324                            chunk.old_lines.push(String::new());
325                            chunk.new_lines.push(String::new());
326                        }
327                        self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
328                        return Ok(());
329                    }
330
331                    if let Some(line_to_add) = line.strip_prefix(' ') {
332                        if chunks.is_empty() {
333                            chunks.push(UpdateFileChunk {
334                                change_context: None,
335                                old_lines: Vec::new(),
336                                new_lines: Vec::new(),
337                                is_end_of_file: false,
338                            });
339                        }
340                        if let Some(chunk) = chunks.last_mut() {
341                            chunk.old_lines.push(line_to_add.to_string());
342                            chunk.new_lines.push(line_to_add.to_string());
343                        }
344                        self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
345                        return Ok(());
346                    }
347
348                    if let Some(line_to_add) = line.strip_prefix('+') {
349                        if chunks.is_empty() {
350                            chunks.push(UpdateFileChunk {
351                                change_context: None,
352                                old_lines: Vec::new(),
353                                new_lines: Vec::new(),
354                                is_end_of_file: false,
355                            });
356                        }
357                        if let Some(chunk) = chunks.last_mut() {
358                            chunk.new_lines.push(line_to_add.to_string());
359                        }
360                        self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
361                        return Ok(());
362                    }
363
364                    if let Some(line_to_remove) = line.strip_prefix('-') {
365                        if chunks.is_empty() {
366                            chunks.push(UpdateFileChunk {
367                                change_context: None,
368                                old_lines: Vec::new(),
369                                new_lines: Vec::new(),
370                                is_end_of_file: false,
371                            });
372                        }
373                        if let Some(chunk) = chunks.last_mut() {
374                            chunk.old_lines.push(line_to_remove.to_string());
375                        }
376                        self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
377                        return Ok(());
378                    }
379
380                    if chunks.last().is_some_and(|chunk| {
381                        !chunk.old_lines.is_empty() || !chunk.new_lines.is_empty()
382                    }) {
383                        return Err(InvalidHunkError {
384                            message: format!(
385                                "Expected update hunk to start with a @@ context marker, got: '{line}'"
386                            ),
387                            line_number: self.line_number,
388                        });
389                    }
390                }
391                Err(InvalidHunkError {
392                    message: format!(
393                        "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
394                    ),
395                    line_number: self.line_number,
396                })
397            }
398            StreamingParserMode::EndedPatch => {
399                if trimmed.is_empty() {
400                    Ok(())
401                } else {
402                    Err(InvalidPatchError(
403                        "The last line of the patch must be '*** End Patch'".to_string(),
404                    ))
405                }
406            }
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use pretty_assertions::assert_eq;
414    use std::path::PathBuf;
415
416    use super::*;
417
418    #[test]
419    fn test_streaming_patch_parser_streams_complete_lines_before_end_patch() {
420        let mut parser = StreamingPatchParser::default();
421        assert_eq!(
422            parser.push_delta("*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor"),
423            Ok(vec![AddFile {
424                path: PathBuf::from("src/hello.txt"),
425                contents: "hello\n".to_string(),
426            }])
427        );
428        assert_eq!(
429            parser.push_delta("ld\n"),
430            Ok(vec![AddFile {
431                path: PathBuf::from("src/hello.txt"),
432                contents: "hello\nworld\n".to_string(),
433            }])
434        );
435
436        let mut parser = StreamingPatchParser::default();
437        assert_eq!(
438            parser.push_delta(
439                "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new\n",
440            ),
441            Ok(vec![UpdateFile {
442                path: PathBuf::from("src/old.rs"),
443                move_path: Some(PathBuf::from("src/new.rs")),
444                chunks: vec![UpdateFileChunk {
445                    change_context: None,
446                    old_lines: vec!["old".to_string()],
447                    new_lines: vec!["new".to_string()],
448                    is_end_of_file: false,
449                }],
450            }])
451        );
452
453        let mut parser = StreamingPatchParser::default();
454        assert_eq!(
455            parser.push_delta("*** Begin Patch\n*** Delete File: gone.txt"),
456            Ok(Vec::new())
457        );
458        assert_eq!(
459            parser.push_delta("\n"),
460            Ok(vec![DeleteFile {
461                path: PathBuf::from("gone.txt"),
462            }])
463        );
464
465        let mut parser = StreamingPatchParser::default();
466        assert_eq!(
467            parser.push_delta(
468                "*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt\n",
469            ),
470            Ok(vec![
471                AddFile {
472                    path: PathBuf::from("src/one.txt"),
473                    contents: "one\n".to_string(),
474                },
475                DeleteFile {
476                    path: PathBuf::from("src/two.txt"),
477                },
478            ])
479        );
480    }
481
482    #[test]
483    fn test_streaming_patch_parser_environment_id_mode() {
484        let patch = "\
485*** Begin Patch
486*** Environment ID: remote
487*** Add File: src/hello.txt
488+hello
489*** End Patch
490";
491
492        let mut parser = StreamingPatchParser::default();
493        assert_eq!(
494            parser.push_delta(patch),
495            Ok(vec![AddFile {
496                path: PathBuf::from("src/hello.txt"),
497                contents: "hello\n".to_string(),
498            }])
499        );
500        assert_eq!(parser.environment_id(), Some("remote"));
501
502        let mut parser = StreamingPatchParser::default();
503        assert_eq!(
504            parser.push_delta(
505                "*** Begin Patch\n*** Environment ID: first\n*** Environment ID: second\n",
506            ),
507            Err(InvalidPatchError(
508                "apply_patch environment_id cannot be specified more than once".to_string(),
509            ))
510        );
511
512        let mut parser = StreamingPatchParser::default();
513        assert_eq!(
514            parser.push_delta("*** Begin Patch\n*** Environment ID:   \n"),
515            Err(InvalidPatchError(
516                "apply_patch environment_id cannot be empty".to_string(),
517            ))
518        );
519    }
520
521    #[test]
522    fn test_streaming_patch_parser_large_patch_split_by_character() {
523        let patch = "\
524*** Begin Patch
525*** Add File: docs/release-notes.md
526+# Release notes
527+
528+## CLI
529+- Surface apply_patch progress while arguments stream.
530+- Keep final patch application gated on the completed tool call.
531+- Include file summaries in the progress event payload.
532*** Update File: src/config.rs
533@@ impl Config
534-    pub apply_patch_progress: bool,
535+    pub stream_apply_patch_progress: bool,
536     pub include_diagnostics: bool,
537@@ fn default_progress_interval()
538-    Duration::from_millis(500)
539+    Duration::from_millis(250)
540*** Delete File: src/legacy_patch_progress.rs
541*** Update File: crates/cli/src/main.rs
542*** Move to: crates/cli/src/bin/codex.rs
543@@ fn run()
544-    let args = Args::parse();
545-    dispatch(args)
546+    let cli = Cli::parse();
547+    dispatch(cli)
548*** Add File: tests/fixtures/apply_patch_progress.json
549+{
550+  \"type\": \"apply_patch_progress\",
551+  \"hunks\": [
552+    { \"operation\": \"add\", \"path\": \"docs/release-notes.md\" },
553+    { \"operation\": \"update\", \"path\": \"src/config.rs\" }
554+  ]
555+}
556*** Update File: README.md
557@@ Development workflow
558 Build the Rust workspace before opening a pull request.
559+When touching streamed tool calls, include parser coverage for partial input.
560+Prefer tests that exercise the exact event payload shape.
561*** Delete File: docs/old-apply-patch-progress.md
562*** End Patch";
563
564        let mut parser = StreamingPatchParser::default();
565        let mut max_hunk_count = 0;
566        let mut saw_hunk_counts = Vec::new();
567        let mut hunks = Vec::new();
568        for ch in patch.chars() {
569            let updated_hunks = parser.push_delta(&ch.to_string()).unwrap();
570            if !updated_hunks.is_empty() {
571                let hunk_count = updated_hunks.len();
572                assert!(
573                    hunk_count >= max_hunk_count,
574                    "hunk count should never decrease while streaming: {hunk_count} < {max_hunk_count}",
575                );
576                if hunk_count > max_hunk_count {
577                    saw_hunk_counts.push(hunk_count);
578                    max_hunk_count = hunk_count;
579                }
580                hunks = updated_hunks;
581            }
582        }
583
584        assert_eq!(saw_hunk_counts, vec![1, 2, 3, 4, 5, 6, 7]);
585        assert_eq!(hunks.len(), 7);
586        assert_eq!(
587            hunks
588                .iter()
589                .map(|hunk| match hunk {
590                    AddFile { .. } => "add",
591                    DeleteFile { .. } => "delete",
592                    UpdateFile {
593                        move_path: Some(_), ..
594                    } => "move-update",
595                    UpdateFile {
596                        move_path: None, ..
597                    } => "update",
598                })
599                .collect::<Vec<_>>(),
600            vec![
601                "add",
602                "update",
603                "delete",
604                "move-update",
605                "add",
606                "update",
607                "delete"
608            ]
609        );
610    }
611
612    #[test]
613    fn test_streaming_patch_parser_keeps_indented_update_markers_as_context_lines() {
614        let mut parser = StreamingPatchParser::default();
615        assert_eq!(
616            parser.push_delta(
617                "\
618*** Begin Patch
619*** Update File: a.txt
620@@
621-old a
622+new a
623 *** Update File: b.txt
624@@
625-old b
626+new b
627*** End Patch
628",
629            ),
630            Ok(vec![UpdateFile {
631                path: PathBuf::from("a.txt"),
632                move_path: None,
633                chunks: vec![
634                    UpdateFileChunk {
635                        change_context: None,
636                        old_lines: vec!["old a".to_string(), "*** Update File: b.txt".to_string()],
637                        new_lines: vec!["new a".to_string(), "*** Update File: b.txt".to_string()],
638                        is_end_of_file: false,
639                    },
640                    UpdateFileChunk {
641                        change_context: None,
642                        old_lines: vec!["old b".to_string()],
643                        new_lines: vec!["new b".to_string()],
644                        is_end_of_file: false,
645                    },
646                ],
647            }])
648        );
649    }
650
651    #[test]
652    fn test_streaming_patch_parser_preserves_bare_empty_update_lines() {
653        let mut parser = StreamingPatchParser::default();
654        assert_eq!(
655            parser.push_delta(
656                "\
657*** Begin Patch
658*** Update File: file.txt
659@@
660 context before
661
662 context after
663*** End Patch
664",
665            ),
666            Ok(vec![UpdateFile {
667                path: PathBuf::from("file.txt"),
668                move_path: None,
669                chunks: vec![UpdateFileChunk {
670                    change_context: None,
671                    // The normal parser treats a bare empty line in an update hunk as an
672                    // empty context line. Preserve that leniency in the streaming parser.
673                    old_lines: vec![
674                        "context before".to_string(),
675                        String::new(),
676                        "context after".to_string(),
677                    ],
678                    new_lines: vec![
679                        "context before".to_string(),
680                        String::new(),
681                        "context after".to_string(),
682                    ],
683                    is_end_of_file: false,
684                }],
685            }])
686        );
687    }
688
689    #[test]
690    fn test_streaming_patch_parser_ignores_empty_lines_after_end_of_file() {
691        let mut parser = StreamingPatchParser::default();
692        assert_eq!(
693            parser.push_delta(
694                "*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch\n",
695            ),
696            Ok(vec![UpdateFile {
697                path: PathBuf::from("file.txt"),
698                move_path: None,
699                chunks: vec![UpdateFileChunk {
700                    change_context: None,
701                    old_lines: Vec::new(),
702                    new_lines: vec!["quux".to_string()],
703                    is_end_of_file: true,
704                }],
705            }])
706        );
707    }
708
709    #[test]
710    fn test_streaming_patch_parser_matches_line_ending_behavior() {
711        let mut parser = StreamingPatchParser::default();
712        assert_eq!(
713            parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n"),
714            Ok(vec![UpdateFile {
715                path: PathBuf::from("file.txt"),
716                move_path: None,
717                chunks: vec![UpdateFileChunk {
718                    change_context: None,
719                    old_lines: vec!["old".to_string()],
720                    new_lines: vec!["new".to_string()],
721                    is_end_of_file: false,
722                }],
723            }])
724        );
725
726        let mut parser = StreamingPatchParser::default();
727        assert_eq!(
728            parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n"),
729            Ok(vec![UpdateFile {
730                path: PathBuf::from("file.txt"),
731                move_path: None,
732                chunks: vec![UpdateFileChunk {
733                    change_context: None,
734                    old_lines: vec!["old\r".to_string()],
735                    new_lines: vec!["new".to_string()],
736                    is_end_of_file: false,
737                }],
738            }])
739        );
740    }
741
742    #[test]
743    fn test_streaming_patch_parser_finish_processes_final_line_without_newline() {
744        let mut parser = StreamingPatchParser::default();
745        assert_eq!(
746            parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch"),
747            Ok(vec![AddFile {
748                path: PathBuf::from("file.txt"),
749                contents: "hello\n".to_string(),
750            }])
751        );
752        assert_eq!(
753            parser.finish(),
754            Ok(vec![AddFile {
755                path: PathBuf::from("file.txt"),
756                contents: "hello\n".to_string(),
757            }])
758        );
759
760        let mut parser = StreamingPatchParser::default();
761        assert_eq!(
762            parser.push_delta(
763                "*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n *** End Patch",
764            ),
765            Ok(vec![UpdateFile {
766                path: PathBuf::from("file.txt"),
767                move_path: None,
768                chunks: vec![UpdateFileChunk {
769                    change_context: None,
770                    old_lines: vec!["old".to_string()],
771                    new_lines: vec!["new".to_string()],
772                    is_end_of_file: false,
773                }],
774            }])
775        );
776        assert_eq!(
777            parser.finish(),
778            Ok(vec![UpdateFile {
779                path: PathBuf::from("file.txt"),
780                move_path: None,
781                chunks: vec![UpdateFileChunk {
782                    change_context: None,
783                    old_lines: vec!["old".to_string()],
784                    new_lines: vec!["new".to_string()],
785                    is_end_of_file: false,
786                }],
787            }])
788        );
789    }
790
791    #[test]
792    fn test_streaming_patch_parser_finish_requires_end_patch() {
793        let mut parser = StreamingPatchParser::default();
794        assert_eq!(
795            parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n"),
796            Ok(vec![AddFile {
797                path: PathBuf::from("file.txt"),
798                contents: "hello\n".to_string(),
799            }])
800        );
801        assert_eq!(
802            parser.finish(),
803            Err(InvalidPatchError(
804                "The last line of the patch must be '*** End Patch'".to_string(),
805            ))
806        );
807    }
808
809    #[test]
810    fn test_streaming_patch_parser_rejects_content_after_end_patch() {
811        let mut parser = StreamingPatchParser::default();
812        assert_eq!(
813            parser.push_delta(
814                "*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\nextra\n",
815            ),
816            Err(InvalidPatchError(
817                "The last line of the patch must be '*** End Patch'".to_string(),
818            ))
819        );
820
821        let mut parser = StreamingPatchParser::default();
822        assert_eq!(
823            parser.push_delta(
824                "*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\n \t\n",
825            ),
826            Ok(vec![AddFile {
827                path: PathBuf::from("file.txt"),
828                contents: "hello\n".to_string(),
829            }])
830        );
831    }
832
833    #[test]
834    fn test_streaming_patch_parser_returns_errors() {
835        let mut parser = StreamingPatchParser::default();
836        assert_eq!(
837            parser.push_delta("bad\n"),
838            Err(InvalidPatchError(
839                "The first line of the patch must be '*** Begin Patch'".to_string(),
840            ))
841        );
842
843        let mut parser = StreamingPatchParser::default();
844        assert_eq!(parser.push_delta("*** Begin Patch\n"), Ok(Vec::new()));
845        assert_eq!(
846            parser.push_delta("bad\n"),
847            Err(InvalidHunkError {
848                message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
849                    .to_string(),
850                line_number: 2,
851            })
852        );
853
854        let mut parser = StreamingPatchParser::default();
855        assert_eq!(
856            parser.push_delta("*** Begin Patch\n*** Add File: file.txt\nbad\n"),
857            Err(InvalidHunkError {
858                message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
859                    .to_string(),
860                line_number: 3,
861            })
862        );
863
864        let mut parser = StreamingPatchParser::default();
865        assert_eq!(
866            parser.push_delta("*** Begin Patch\n*** Delete File: file.txt\nbad\n"),
867            Err(InvalidHunkError {
868                message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
869                    .to_string(),
870                line_number: 3,
871            })
872        );
873
874        let mut parser = StreamingPatchParser::default();
875        assert_eq!(
876            parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n*** End Patch\n"),
877            Err(InvalidHunkError {
878                message: "Update file hunk for path 'file.txt' is empty".to_string(),
879                line_number: 2,
880            })
881        );
882
883        let mut parser = StreamingPatchParser::default();
884        assert_eq!(
885            parser.push_delta(
886                "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** Delete File: other.txt\n",
887            ),
888            Err(InvalidHunkError {
889                message: "Update file hunk for path 'old.txt' is empty".to_string(),
890                line_number: 2,
891            })
892        );
893
894        let mut parser = StreamingPatchParser::default();
895        assert_eq!(
896            parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End Patch\n"),
897            Err(InvalidHunkError {
898                message: "Update hunk does not contain any lines".to_string(),
899                line_number: 4,
900            })
901        );
902
903        let mut parser = StreamingPatchParser::default();
904        assert_eq!(
905            parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End of File\n"),
906            Err(InvalidHunkError {
907                message: "Update hunk does not contain any lines".to_string(),
908                line_number: 4,
909            })
910        );
911
912        let mut parser = StreamingPatchParser::default();
913        assert_eq!(
914            parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n@@\n"),
915            Err(InvalidHunkError {
916                message: "Unexpected line found in update hunk: '@@'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
917                    .to_string(),
918                line_number: 4,
919            })
920        );
921
922        let mut parser = StreamingPatchParser::default();
923        assert_eq!(
924            parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\nbad\n"),
925            Err(InvalidHunkError {
926                message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
927                    .to_string(),
928                line_number: 5,
929            })
930        );
931
932        let mut parser = StreamingPatchParser::default();
933        assert_eq!(
934            parser.push_delta(
935                "*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n",
936            ),
937            Err(InvalidHunkError {
938                message: "Unexpected line found in update hunk: '*** Update File: other.txt'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
939                    .to_string(),
940                line_number: 4,
941            })
942        );
943    }
944}