use std::path::PathBuf;use crate::parser::ADD_FILE_MARKER;use crate::parser::BEGIN_PATCH_MARKER;use crate::parser::CHANGE_CONTEXT_MARKER;use crate::parser::DELETE_FILE_MARKER;use crate::parser::EMPTY_CHANGE_CONTEXT_MARKER;use crate::parser::END_PATCH_MARKER;use crate::parser::EOF_MARKER;use crate::parser::Hunk;use crate::parser::MOVE_TO_MARKER;use crate::parser::ParseError;use crate::parser::UPDATE_FILE_MARKER;use crate::parser::UpdateFileChunk;use Hunk::*;use ParseError::*;const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID:";#[derive(Debug, Default, Clone)]pub struct StreamingPatchParser { line_buffer: String, state: StreamingParserState, line_number: usize,}#[derive(Debug, Default, Clone)]struct StreamingParserState { mode: StreamingParserMode, hunks: Vec<Hunk>, environment_id: Option<String>,}#[derive(Debug, Default, Clone, Copy)]enum StreamingParserMode { #[default] NotStarted, StartedPatch, AddFile, DeleteFile, UpdateFile { hunk_line_number: usize, }, EndedPatch,}impl StreamingPatchParser { pub fn environment_id(&self) -> Option<&str> { self.state.environment_id.as_deref() } fn ensure_update_hunk_is_not_empty(&self, line: &str) -> Result<(), ParseError> { if let Some(UpdateFile { path, chunks, .. }) = self.state.hunks.last() { if chunks.is_empty() && let StreamingParserMode::UpdateFile { hunk_line_number } = self.state.mode { return Err(InvalidHunkError { message: format!("Update file hunk for path '{}' is empty", path.display()), line_number: hunk_line_number, }); } if chunks .last() .is_some_and(|chunk| chunk.old_lines.is_empty() && chunk.new_lines.is_empty()) { if line == END_PATCH_MARKER { return Err(InvalidHunkError { message: "Update hunk does not contain any lines".to_string(), line_number: self.line_number, }); } return Err(InvalidHunkError { message: format!( "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" ), line_number: self.line_number, }); } } Ok(()) } fn handle_hunk_headers_and_end_patch(&mut self, trimmed: &str) -> Result<bool, ParseError> { if matches!(self.state.mode, StreamingParserMode::StartedPatch) && let Some(environment_id) = trimmed.strip_prefix(ENVIRONMENT_ID_MARKER) { if self.state.environment_id.is_some() { return Err(InvalidPatchError( "apply_patch environment_id cannot be specified more than once".to_string(), )); } let environment_id = environment_id.trim(); if environment_id.is_empty() { return Err(InvalidPatchError( "apply_patch environment_id cannot be empty".to_string(), )); } self.state.environment_id = Some(environment_id.to_string()); return Ok(true); } if trimmed == END_PATCH_MARKER { self.ensure_update_hunk_is_not_empty(trimmed)?; self.state.mode = StreamingParserMode::EndedPatch; return Ok(true); } if let Some(path) = trimmed.strip_prefix(ADD_FILE_MARKER) { self.ensure_update_hunk_is_not_empty(trimmed)?; self.state.hunks.push(AddFile { path: PathBuf::from(path), contents: String::new(), }); self.state.mode = StreamingParserMode::AddFile; return Ok(true); } if let Some(path) = trimmed.strip_prefix(DELETE_FILE_MARKER) { self.ensure_update_hunk_is_not_empty(trimmed)?; self.state.hunks.push(DeleteFile { path: PathBuf::from(path), }); self.state.mode = StreamingParserMode::DeleteFile; return Ok(true); } if let Some(path) = trimmed.strip_prefix(UPDATE_FILE_MARKER) { self.ensure_update_hunk_is_not_empty(trimmed)?; self.state.hunks.push(UpdateFile { path: PathBuf::from(path), move_path: None, chunks: Vec::new(), }); self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number: self.line_number, }; return Ok(true); } Ok(false) } pub fn push_delta(&mut self, delta: &str) -> Result<Vec<Hunk>, ParseError> { for ch in delta.chars() { if ch == '\n' { let mut line = std::mem::take(&mut self.line_buffer); line.truncate(line.strip_suffix('\r').map_or(line.len(), str::len)); self.line_number += 1; self.process_line(&line)?; } else { self.line_buffer.push(ch); } } Ok(self.state.hunks.clone()) } pub fn finish(&mut self) -> Result<Vec<Hunk>, ParseError> { if !self.line_buffer.is_empty() { let line = std::mem::take(&mut self.line_buffer); self.line_number += 1; if line.trim() == END_PATCH_MARKER { self.ensure_update_hunk_is_not_empty(line.trim())?; self.state.mode = StreamingParserMode::EndedPatch; } else { self.process_line(&line)?; } } if !matches!(self.state.mode, StreamingParserMode::EndedPatch) { return Err(InvalidPatchError( "The last line of the patch must be '*** End Patch'".to_string(), )); } Ok(self.state.hunks.clone()) } fn process_line(&mut self, line: &str) -> Result<(), ParseError> { let trimmed = line.trim(); match self.state.mode { StreamingParserMode::NotStarted => { if trimmed == BEGIN_PATCH_MARKER { self.state.mode = StreamingParserMode::StartedPatch; return Ok(()); } Err(InvalidPatchError( "The first line of the patch must be '*** Begin Patch'".to_string(), )) } StreamingParserMode::StartedPatch => { if self.handle_hunk_headers_and_end_patch(trimmed)? { return Ok(()); } Err(InvalidHunkError { message: format!( "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'" ), line_number: self.line_number, }) } StreamingParserMode::AddFile => { if self.handle_hunk_headers_and_end_patch(trimmed)? { return Ok(()); } if let Some(line_to_add) = line.strip_prefix('+') && let Some(AddFile { contents, .. }) = self.state.hunks.last_mut() { contents.push_str(line_to_add); contents.push('\n'); return Ok(()); } Err(InvalidHunkError { message: format!( "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'" ), line_number: self.line_number, }) } StreamingParserMode::DeleteFile => { if self.handle_hunk_headers_and_end_patch(trimmed)? { return Ok(()); } Err(InvalidHunkError { message: format!( "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'" ), line_number: self.line_number, }) } StreamingParserMode::UpdateFile { hunk_line_number } => { let update_line = line.trim_end(); if self.handle_hunk_headers_and_end_patch(update_line)? { return Ok(()); } if let Some(UpdateFile { move_path, chunks, .. }) = self.state.hunks.last_mut() { if chunks.last().is_some_and(|chunk| chunk.is_end_of_file) { if update_line.is_empty() { return Ok(()); } if update_line != EMPTY_CHANGE_CONTEXT_MARKER && !update_line.starts_with(CHANGE_CONTEXT_MARKER) { return Err(InvalidHunkError { message: format!( "Expected update hunk to start with a @@ context marker, got: '{line}'" ), line_number: self.line_number, }); } } if chunks.is_empty() && move_path.is_none() && let Some(move_to_path) = update_line.strip_prefix(MOVE_TO_MARKER) { *move_path = Some(PathBuf::from(move_to_path)); self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; return Ok(()); } if (update_line == EMPTY_CHANGE_CONTEXT_MARKER || update_line.starts_with(CHANGE_CONTEXT_MARKER)) && chunks.last().is_some_and(|chunk| { chunk.old_lines.is_empty() && chunk.new_lines.is_empty() }) { return Err(InvalidHunkError { message: format!( "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" ), line_number: self.line_number, }); } if update_line == EMPTY_CHANGE_CONTEXT_MARKER { chunks.push(UpdateFileChunk { change_context: None, old_lines: Vec::new(), new_lines: Vec::new(), is_end_of_file: false, }); self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; return Ok(()); } if let Some(change_context) = update_line.strip_prefix(CHANGE_CONTEXT_MARKER) { chunks.push(UpdateFileChunk { change_context: Some(change_context.to_string()), old_lines: Vec::new(), new_lines: Vec::new(), is_end_of_file: false, }); self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; return Ok(()); } if update_line == EOF_MARKER { if chunks.last().is_some_and(|chunk| { chunk.old_lines.is_empty() && chunk.new_lines.is_empty() }) { return Err(InvalidHunkError { message: "Update hunk does not contain any lines".to_string(), line_number: self.line_number, }); } if let Some(chunk) = chunks.last_mut() { chunk.is_end_of_file = true; } self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; return Ok(()); } if line.is_empty() { if chunks.is_empty() { chunks.push(UpdateFileChunk { change_context: None, old_lines: Vec::new(), new_lines: Vec::new(), is_end_of_file: false, }); } if let Some(chunk) = chunks.last_mut() { chunk.old_lines.push(String::new()); chunk.new_lines.push(String::new()); } self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; return Ok(()); } if let Some(line_to_add) = line.strip_prefix(' ') { if chunks.is_empty() { chunks.push(UpdateFileChunk { change_context: None, old_lines: Vec::new(), new_lines: Vec::new(), is_end_of_file: false, }); } if let Some(chunk) = chunks.last_mut() { chunk.old_lines.push(line_to_add.to_string()); chunk.new_lines.push(line_to_add.to_string()); } self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; return Ok(()); } if let Some(line_to_add) = line.strip_prefix('+') { if chunks.is_empty() { chunks.push(UpdateFileChunk { change_context: None, old_lines: Vec::new(), new_lines: Vec::new(), is_end_of_file: false, }); } if let Some(chunk) = chunks.last_mut() { chunk.new_lines.push(line_to_add.to_string()); } self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; return Ok(()); } if let Some(line_to_remove) = line.strip_prefix('-') { if chunks.is_empty() { chunks.push(UpdateFileChunk { change_context: None, old_lines: Vec::new(), new_lines: Vec::new(), is_end_of_file: false, }); } if let Some(chunk) = chunks.last_mut() { chunk.old_lines.push(line_to_remove.to_string()); } self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; return Ok(()); } if chunks.last().is_some_and(|chunk| { !chunk.old_lines.is_empty() || !chunk.new_lines.is_empty() }) { return Err(InvalidHunkError { message: format!( "Expected update hunk to start with a @@ context marker, got: '{line}'" ), line_number: self.line_number, }); } } Err(InvalidHunkError { message: format!( "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" ), line_number: self.line_number, }) } StreamingParserMode::EndedPatch => { if trimmed.is_empty() { Ok(()) } else { Err(InvalidPatchError( "The last line of the patch must be '*** End Patch'".to_string(), )) } } } }}#[cfg(test)]mod tests { use pretty_assertions::assert_eq; use std::path::PathBuf; use super::*; #[test] fn test_streaming_patch_parser_streams_complete_lines_before_end_patch() { let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor"), Ok(vec![AddFile { path: PathBuf::from("src/hello.txt"), contents: "hello\n".to_string(), }]) ); assert_eq!( parser.push_delta("ld\n"), Ok(vec![AddFile { path: PathBuf::from("src/hello.txt"), contents: "hello\nworld\n".to_string(), }]) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new\n", ), Ok(vec![UpdateFile { path: PathBuf::from("src/old.rs"), move_path: Some(PathBuf::from("src/new.rs")), chunks: vec![UpdateFileChunk { change_context: None, old_lines: vec!["old".to_string()], new_lines: vec!["new".to_string()], is_end_of_file: false, }], }]) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Delete File: gone.txt"), Ok(Vec::new()) ); assert_eq!( parser.push_delta("\n"), Ok(vec![DeleteFile { path: PathBuf::from("gone.txt"), }]) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt\n", ), Ok(vec![ AddFile { path: PathBuf::from("src/one.txt"), contents: "one\n".to_string(), }, DeleteFile { path: PathBuf::from("src/two.txt"), }, ]) ); } #[test] fn test_streaming_patch_parser_environment_id_mode() { let patch = "\*** Begin Patch*** Environment ID: remote*** Add File: src/hello.txt+hello*** End Patch"; let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta(patch), Ok(vec![AddFile { path: PathBuf::from("src/hello.txt"), contents: "hello\n".to_string(), }]) ); assert_eq!(parser.environment_id(), Some("remote")); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Environment ID: first\n*** Environment ID: second\n", ), Err(InvalidPatchError( "apply_patch environment_id cannot be specified more than once".to_string(), )) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Environment ID: \n"), Err(InvalidPatchError( "apply_patch environment_id cannot be empty".to_string(), )) ); } #[test] fn test_streaming_patch_parser_large_patch_split_by_character() { let patch = "\*** Begin Patch*** Add File: docs/release-notes.md+# Release notes++## CLI+- Surface apply_patch progress while arguments stream.+- Keep final patch application gated on the completed tool call.+- Include file summaries in the progress event payload.*** Update File: src/config.rs@@ impl Config- pub apply_patch_progress: bool,+ pub stream_apply_patch_progress: bool, pub include_diagnostics: bool,@@ fn default_progress_interval()- Duration::from_millis(500)+ Duration::from_millis(250)*** Delete File: src/legacy_patch_progress.rs*** Update File: crates/cli/src/main.rs*** Move to: crates/cli/src/bin/codex.rs@@ fn run()- let args = Args::parse();- dispatch(args)+ let cli = Cli::parse();+ dispatch(cli)*** Add File: tests/fixtures/apply_patch_progress.json+{+ \"type\": \"apply_patch_progress\",+ \"hunks\": [+ { \"operation\": \"add\", \"path\": \"docs/release-notes.md\" },+ { \"operation\": \"update\", \"path\": \"src/config.rs\" }+ ]+}*** Update File: README.md@@ Development workflow Build the Rust workspace before opening a pull request.+When touching streamed tool calls, include parser coverage for partial input.+Prefer tests that exercise the exact event payload shape.*** Delete File: docs/old-apply-patch-progress.md*** End Patch"; let mut parser = StreamingPatchParser::default(); let mut max_hunk_count = 0; let mut saw_hunk_counts = Vec::new(); let mut hunks = Vec::new(); for ch in patch.chars() { let updated_hunks = parser.push_delta(&ch.to_string()).unwrap(); if !updated_hunks.is_empty() { let hunk_count = updated_hunks.len(); assert!( hunk_count >= max_hunk_count, "hunk count should never decrease while streaming: {hunk_count} < {max_hunk_count}", ); if hunk_count > max_hunk_count { saw_hunk_counts.push(hunk_count); max_hunk_count = hunk_count; } hunks = updated_hunks; } } assert_eq!(saw_hunk_counts, vec![1, 2, 3, 4, 5, 6, 7]); assert_eq!(hunks.len(), 7); assert_eq!( hunks .iter() .map(|hunk| match hunk { AddFile { .. } => "add", DeleteFile { .. } => "delete", UpdateFile { move_path: Some(_), .. } => "move-update", UpdateFile { move_path: None, .. } => "update", }) .collect::<Vec<_>>(), vec![ "add", "update", "delete", "move-update", "add", "update", "delete" ] ); } #[test] fn test_streaming_patch_parser_keeps_indented_update_markers_as_context_lines() { let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "\*** Begin Patch*** Update File: a.txt@@-old a+new a *** Update File: b.txt@@-old b+new b*** End Patch", ), Ok(vec![UpdateFile { path: PathBuf::from("a.txt"), move_path: None, chunks: vec![ UpdateFileChunk { change_context: None, old_lines: vec!["old a".to_string(), "*** Update File: b.txt".to_string()], new_lines: vec!["new a".to_string(), "*** Update File: b.txt".to_string()], is_end_of_file: false, }, UpdateFileChunk { change_context: None, old_lines: vec!["old b".to_string()], new_lines: vec!["new b".to_string()], is_end_of_file: false, }, ], }]) ); } #[test] fn test_streaming_patch_parser_preserves_bare_empty_update_lines() { let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "\*** Begin Patch*** Update File: file.txt@@ context before context after*** End Patch", ), Ok(vec![UpdateFile { path: PathBuf::from("file.txt"), move_path: None, chunks: vec![UpdateFileChunk { change_context: None, // The normal parser treats a bare empty line in an update hunk as an // empty context line. Preserve that leniency in the streaming parser. old_lines: vec![ "context before".to_string(), String::new(), "context after".to_string(), ], new_lines: vec![ "context before".to_string(), String::new(), "context after".to_string(), ], is_end_of_file: false, }], }]) ); } #[test] fn test_streaming_patch_parser_ignores_empty_lines_after_end_of_file() { let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch\n", ), Ok(vec![UpdateFile { path: PathBuf::from("file.txt"), move_path: None, chunks: vec![UpdateFileChunk { change_context: None, old_lines: Vec::new(), new_lines: vec!["quux".to_string()], is_end_of_file: true, }], }]) ); } #[test] fn test_streaming_patch_parser_matches_line_ending_behavior() { let mut parser = StreamingPatchParser::default(); assert_eq!( 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"), Ok(vec![UpdateFile { path: PathBuf::from("file.txt"), move_path: None, chunks: vec![UpdateFileChunk { change_context: None, old_lines: vec!["old".to_string()], new_lines: vec!["new".to_string()], is_end_of_file: false, }], }]) ); let mut parser = StreamingPatchParser::default(); assert_eq!( 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"), Ok(vec![UpdateFile { path: PathBuf::from("file.txt"), move_path: None, chunks: vec![UpdateFileChunk { change_context: None, old_lines: vec!["old\r".to_string()], new_lines: vec!["new".to_string()], is_end_of_file: false, }], }]) ); } #[test] fn test_streaming_patch_parser_finish_processes_final_line_without_newline() { let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch"), Ok(vec![AddFile { path: PathBuf::from("file.txt"), contents: "hello\n".to_string(), }]) ); assert_eq!( parser.finish(), Ok(vec![AddFile { path: PathBuf::from("file.txt"), contents: "hello\n".to_string(), }]) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n *** End Patch", ), Ok(vec![UpdateFile { path: PathBuf::from("file.txt"), move_path: None, chunks: vec![UpdateFileChunk { change_context: None, old_lines: vec!["old".to_string()], new_lines: vec!["new".to_string()], is_end_of_file: false, }], }]) ); assert_eq!( parser.finish(), Ok(vec![UpdateFile { path: PathBuf::from("file.txt"), move_path: None, chunks: vec![UpdateFileChunk { change_context: None, old_lines: vec!["old".to_string()], new_lines: vec!["new".to_string()], is_end_of_file: false, }], }]) ); } #[test] fn test_streaming_patch_parser_finish_requires_end_patch() { let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n"), Ok(vec![AddFile { path: PathBuf::from("file.txt"), contents: "hello\n".to_string(), }]) ); assert_eq!( parser.finish(), Err(InvalidPatchError( "The last line of the patch must be '*** End Patch'".to_string(), )) ); } #[test] fn test_streaming_patch_parser_rejects_content_after_end_patch() { let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\nextra\n", ), Err(InvalidPatchError( "The last line of the patch must be '*** End Patch'".to_string(), )) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\n \t\n", ), Ok(vec![AddFile { path: PathBuf::from("file.txt"), contents: "hello\n".to_string(), }]) ); } #[test] fn test_streaming_patch_parser_returns_errors() { let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("bad\n"), Err(InvalidPatchError( "The first line of the patch must be '*** Begin Patch'".to_string(), )) ); let mut parser = StreamingPatchParser::default(); assert_eq!(parser.push_delta("*** Begin Patch\n"), Ok(Vec::new())); assert_eq!( parser.push_delta("bad\n"), Err(InvalidHunkError { message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'" .to_string(), line_number: 2, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Add File: file.txt\nbad\n"), Err(InvalidHunkError { message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'" .to_string(), line_number: 3, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Delete File: file.txt\nbad\n"), Err(InvalidHunkError { message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'" .to_string(), line_number: 3, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n*** End Patch\n"), Err(InvalidHunkError { message: "Update file hunk for path 'file.txt' is empty".to_string(), line_number: 2, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** Delete File: other.txt\n", ), Err(InvalidHunkError { message: "Update file hunk for path 'old.txt' is empty".to_string(), line_number: 2, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End Patch\n"), Err(InvalidHunkError { message: "Update hunk does not contain any lines".to_string(), line_number: 4, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End of File\n"), Err(InvalidHunkError { message: "Update hunk does not contain any lines".to_string(), line_number: 4, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n@@\n"), Err(InvalidHunkError { message: "Unexpected line found in update hunk: '@@'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" .to_string(), line_number: 4, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\nbad\n"), Err(InvalidHunkError { message: "Expected update hunk to start with a @@ context marker, got: 'bad'" .to_string(), line_number: 5, }) ); let mut parser = StreamingPatchParser::default(); assert_eq!( parser.push_delta( "*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n", ), Err(InvalidHunkError { message: "Unexpected line found in update hunk: '*** Update File: other.txt'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" .to_string(), line_number: 4, }) ); }}