| 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::default()); |
| 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()), |
| ..UpdateFileChunk::default() |
| }); |
| 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::default()); |
| } |
| if let Some(chunk) = chunks.last_mut() { |
| chunk.push_context_line(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::default()); |
| } |
| if let Some(chunk) = chunks.last_mut() { |
| chunk.push_context_line(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::default()); |
| } |
| 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::default()); |
| } |
| 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()], |
| context_line_indices: vec![], |
| 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()], |
| context_line_indices: vec![(1, 1)], |
| is_end_of_file: false, |
| }, |
| UpdateFileChunk { |
| change_context: None, |
| old_lines: vec!["old b".to_string()], |
| new_lines: vec!["new b".to_string()], |
| context_line_indices: vec![], |
| 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, |
| |
| |
| 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(), |
| ], |
| context_line_indices: vec![(0, 0), (1, 1), (2, 2)], |
| 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()], |
| context_line_indices: vec![], |
| 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()], |
| context_line_indices: vec![], |
| 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()], |
| context_line_indices: vec![], |
| 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()], |
| context_line_indices: vec![], |
| 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()], |
| context_line_indices: vec![], |
| 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, |
| }) |
| ); |
| } |
| } |
|
|