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 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}