Skip to main content

hanzo_apply_patch/
parser.rs

1//! This module is responsible for parsing & validating a patch into a list of "hunks".
2//! (It does not attempt to actually check that the patch can be applied to the filesystem.)
3//!
4//! The official Lark grammar for the apply-patch format is:
5//!
6//! start: begin_patch hunk+ end_patch
7//! begin_patch: "*** Begin Patch" LF
8//! end_patch: "*** End Patch" LF?
9//!
10//! hunk: add_hunk | delete_hunk | update_hunk
11//! add_hunk: "*** Add File: " filename LF add_line+
12//! delete_hunk: "*** Delete File: " filename LF
13//! update_hunk: "*** Update File: " filename LF change_move? change?
14//! filename: /(.+)/
15//! add_line: "+" /(.+)/ LF -> line
16//!
17//! change_move: "*** Move to: " filename LF
18//! change: (change_context | change_line)+ eof_line?
19//! change_context: ("@@" | "@@ " /(.+)/) LF
20//! change_line: ("+" | "-" | " ") /(.+)/ LF
21//! eof_line: "*** End of File" LF
22//!
23//! The parser below is a little more lenient than the explicit spec and allows for
24//! leading/trailing whitespace around patch markers.
25use crate::ApplyPatchArgs;
26use std::path::Path;
27use std::path::PathBuf;
28
29use thiserror::Error;
30
31const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
32const END_PATCH_MARKER: &str = "*** End Patch";
33const ADD_FILE_MARKER: &str = "*** Add File: ";
34const DELETE_FILE_MARKER: &str = "*** Delete File: ";
35const UPDATE_FILE_MARKER: &str = "*** Update File: ";
36const MOVE_TO_MARKER: &str = "*** Move to: ";
37const EOF_MARKER: &str = "*** End of File";
38const CHANGE_CONTEXT_MARKER: &str = "@@ ";
39const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
40
41/// Currently, the only OpenAI model that knowingly requires lenient parsing is
42/// gpt-4.1. While we could try to require everyone to pass in a strictness
43/// param when invoking apply_patch, it is a pain to thread it through all of
44/// the call sites, so we resign ourselves allowing lenient parsing for all
45/// models. See [`ParseMode::Lenient`] for details on the exceptions we make for
46/// gpt-4.1.
47const PARSE_IN_STRICT_MODE: bool = false;
48
49#[derive(Debug, PartialEq, Error, Clone)]
50pub enum ParseError {
51    #[error("invalid patch: {0}")]
52    InvalidPatchError(String),
53    #[error("invalid hunk at line {line_number}, {message}")]
54    InvalidHunkError { message: String, line_number: usize },
55}
56use ParseError::*;
57
58#[derive(Debug, PartialEq, Clone)]
59#[allow(clippy::enum_variant_names)]
60pub enum Hunk {
61    AddFile {
62        path: PathBuf,
63        contents: String,
64    },
65    DeleteFile {
66        path: PathBuf,
67    },
68    UpdateFile {
69        path: PathBuf,
70        move_path: Option<PathBuf>,
71
72        /// Chunks should be in order, i.e. the `change_context` of one chunk
73        /// should occur later in the file than the previous chunk.
74        chunks: Vec<UpdateFileChunk>,
75    },
76}
77
78impl Hunk {
79    pub fn resolve_path(&self, cwd: &Path) -> PathBuf {
80        match self {
81            Hunk::AddFile { path, .. } => cwd.join(path),
82            Hunk::DeleteFile { path } => cwd.join(path),
83            Hunk::UpdateFile { path, .. } => cwd.join(path),
84        }
85    }
86}
87
88use Hunk::*;
89
90#[derive(Debug, PartialEq, Clone)]
91pub struct UpdateFileChunk {
92    /// A single line of context used to narrow down the position of the chunk
93    /// (this is usually a class, method, or function definition.)
94    pub change_context: Option<String>,
95
96    /// A contiguous block of lines that should be replaced with `new_lines`.
97    /// `old_lines` must occur strictly after `change_context`.
98    pub old_lines: Vec<String>,
99    pub new_lines: Vec<String>,
100
101    /// If set to true, `old_lines` must occur at the end of the source file.
102    /// (Tolerance around trailing newlines should be encouraged.)
103    pub is_end_of_file: bool,
104}
105
106pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
107    let mode = if PARSE_IN_STRICT_MODE {
108        ParseMode::Strict
109    } else {
110        ParseMode::Lenient
111    };
112    parse_patch_text(patch, mode)
113}
114
115enum ParseMode {
116    /// Parse the patch text argument as is.
117    Strict,
118
119    /// GPT-4.1 is known to formulate the `command` array for the `local_shell`
120    /// tool call for `apply_patch` call using something like the following:
121    ///
122    /// ```json
123    /// [
124    ///   "apply_patch",
125    ///   "<<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
126    /// ]
127    /// ```
128    ///
129    /// This is a problem because `local_shell` is a bit of a misnomer: the
130    /// `command` is not invoked by passing the arguments to a shell like Bash,
131    /// but are invoked using something akin to `execvpe(3)`.
132    ///
133    /// This is significant in this case because where a shell would interpret
134    /// `<<'EOF'...` as a heredoc and pass the contents via stdin (which is
135    /// fine, as `apply_patch` is specified to read from stdin if no argument is
136    /// passed), `execvpe(3)` interprets the heredoc as a literal string. To get
137    /// the `local_shell` tool to run a command the way shell would, the
138    /// `command` array must be something like:
139    ///
140    /// ```json
141    /// [
142    ///   "bash",
143    ///   "-lc",
144    ///   "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
145    /// ]
146    /// ```
147    ///
148    /// In lenient mode, we check if the argument to `apply_patch` starts with
149    /// `<<'EOF'` and ends with `EOF\n`. If so, we strip off these markers,
150    /// trim() the result, and treat what is left as the patch text.
151    Lenient,
152}
153
154fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
155    let lines: Vec<&str> = patch.trim().lines().collect();
156    let lines: &[&str] = match check_patch_boundaries_strict(&lines) {
157        Ok(()) => &lines,
158        Err(e) => match mode {
159            ParseMode::Strict => {
160                return Err(e);
161            }
162            ParseMode::Lenient => check_patch_boundaries_lenient(&lines, e)?,
163        },
164    };
165
166    let mut hunks: Vec<Hunk> = Vec::new();
167    // The above checks ensure that lines.len() >= 2.
168    let last_line_index = lines.len().saturating_sub(1);
169    let mut remaining_lines = &lines[1..last_line_index];
170    let mut line_number = 2;
171    while !remaining_lines.is_empty() {
172        let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number)?;
173        hunks.push(hunk);
174        line_number += hunk_lines;
175        remaining_lines = &remaining_lines[hunk_lines..]
176    }
177    let patch = lines.join("\n");
178    Ok(ApplyPatchArgs {
179        hunks,
180        patch,
181        workdir: None,
182    })
183}
184
185/// Checks the start and end lines of the patch text for `apply_patch`,
186/// returning an error if they do not match the expected markers.
187fn check_patch_boundaries_strict(lines: &[&str]) -> Result<(), ParseError> {
188    let (first_line, last_line) = match lines {
189        [] => (None, None),
190        [first] => (Some(first), Some(first)),
191        [first, .., last] => (Some(first), Some(last)),
192    };
193    check_start_and_end_lines_strict(first_line, last_line)
194}
195
196/// If we are in lenient mode, we check if the first line starts with `<<EOF`
197/// (possibly quoted) and the last line ends with `EOF`. There must be at least
198/// 4 lines total because the heredoc markers take up 2 lines and the patch text
199/// must have at least 2 lines.
200///
201/// If successful, returns the lines of the patch text that contain the patch
202/// contents, excluding the heredoc markers.
203fn check_patch_boundaries_lenient<'a>(
204    original_lines: &'a [&'a str],
205    original_parse_error: ParseError,
206) -> Result<&'a [&'a str], ParseError> {
207    match original_lines {
208        [first, .., last] => {
209            if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
210                && last.ends_with("EOF")
211                && original_lines.len() >= 4
212            {
213                let inner_lines = &original_lines[1..original_lines.len() - 1];
214                match check_patch_boundaries_strict(inner_lines) {
215                    Ok(()) => Ok(inner_lines),
216                    Err(e) => Err(e),
217                }
218            } else {
219                Err(original_parse_error)
220            }
221        }
222        _ => Err(original_parse_error),
223    }
224}
225
226fn check_start_and_end_lines_strict(
227    first_line: Option<&&str>,
228    last_line: Option<&&str>,
229) -> Result<(), ParseError> {
230    match (first_line, last_line) {
231        (Some(&first), Some(&last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => {
232            Ok(())
233        }
234        (Some(&first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from(
235            "The first line of the patch must be '*** Begin Patch'",
236        ))),
237        _ => Err(InvalidPatchError(String::from(
238            "The last line of the patch must be '*** End Patch'",
239        ))),
240    }
241}
242
243/// Attempts to parse a single hunk from the start of lines.
244/// Returns the parsed hunk and the number of lines parsed (or a ParseError).
245fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), ParseError> {
246    // Be tolerant of case mismatches and extra padding around marker strings.
247    let first_line = lines[0].trim();
248    if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
249        // Add File
250        let mut contents = String::new();
251        let mut parsed_lines = 1;
252        for add_line in &lines[1..] {
253            if let Some(line_to_add) = add_line.strip_prefix('+') {
254                contents.push_str(line_to_add);
255                contents.push('\n');
256                parsed_lines += 1;
257            } else {
258                break;
259            }
260        }
261        return Ok((
262            AddFile {
263                path: PathBuf::from(path),
264                contents,
265            },
266            parsed_lines,
267        ));
268    } else if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) {
269        // Delete File
270        return Ok((
271            DeleteFile {
272                path: PathBuf::from(path),
273            },
274            1,
275        ));
276    } else if let Some(path) = first_line.strip_prefix(UPDATE_FILE_MARKER) {
277        // Update File
278        let mut remaining_lines = &lines[1..];
279        let mut parsed_lines = 1;
280
281        // Optional: move file line
282        let move_path = remaining_lines
283            .first()
284            .and_then(|x| x.strip_prefix(MOVE_TO_MARKER));
285
286        if move_path.is_some() {
287            remaining_lines = &remaining_lines[1..];
288            parsed_lines += 1;
289        }
290
291        let mut chunks = Vec::new();
292        // NOTE: we need to know to stop once we reach the next special marker header.
293        while !remaining_lines.is_empty() {
294            // Skip over any completely blank lines that may separate chunks.
295            if remaining_lines[0].trim().is_empty() {
296                parsed_lines += 1;
297                remaining_lines = &remaining_lines[1..];
298                continue;
299            }
300
301            if remaining_lines[0].starts_with("***") {
302                break;
303            }
304
305            let (chunk, chunk_lines) = parse_update_file_chunk(
306                remaining_lines,
307                line_number + parsed_lines,
308                chunks.is_empty(),
309            )?;
310            chunks.push(chunk);
311            parsed_lines += chunk_lines;
312            remaining_lines = &remaining_lines[chunk_lines..]
313        }
314
315        if chunks.is_empty() {
316            return Err(InvalidHunkError {
317                message: format!("Update file hunk for path '{path}' is empty"),
318                line_number,
319            });
320        }
321
322        return Ok((
323            UpdateFile {
324                path: PathBuf::from(path),
325                move_path: move_path.map(PathBuf::from),
326                chunks,
327            },
328            parsed_lines,
329        ));
330    }
331
332    Err(InvalidHunkError {
333        message: format!(
334            "'{first_line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
335        ),
336        line_number,
337    })
338}
339
340fn parse_update_file_chunk(
341    lines: &[&str],
342    line_number: usize,
343    allow_missing_context: bool,
344) -> Result<(UpdateFileChunk, usize), ParseError> {
345    if lines.is_empty() {
346        return Err(InvalidHunkError {
347            message: "Update hunk does not contain any lines".to_string(),
348            line_number,
349        });
350    }
351    // If we see an explicit context marker @@ or @@ <context>, consume it; otherwise, optionally
352    // allow treating the chunk as starting directly with diff lines.
353    let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
354        (None, 1)
355    } else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
356        (Some(context.to_string()), 1)
357    } else {
358        if !allow_missing_context {
359            return Err(InvalidHunkError {
360                message: format!(
361                    "Expected update hunk to start with a @@ context marker, got: '{}'",
362                    lines[0]
363                ),
364                line_number,
365            });
366        }
367        (None, 0)
368    };
369    if start_index >= lines.len() {
370        return Err(InvalidHunkError {
371            message: "Update hunk does not contain any lines".to_string(),
372            line_number: line_number + 1,
373        });
374    }
375    let mut chunk = UpdateFileChunk {
376        change_context,
377        old_lines: Vec::new(),
378        new_lines: Vec::new(),
379        is_end_of_file: false,
380    };
381    let mut parsed_lines = 0;
382    for line in &lines[start_index..] {
383        match *line {
384            EOF_MARKER => {
385                if parsed_lines == 0 {
386                    return Err(InvalidHunkError {
387                        message: "Update hunk does not contain any lines".to_string(),
388                        line_number: line_number + 1,
389                    });
390                }
391                chunk.is_end_of_file = true;
392                parsed_lines += 1;
393                break;
394            }
395            line_contents => {
396                match line_contents.chars().next() {
397                    None => {
398                        // Interpret this as an empty line.
399                        chunk.old_lines.push(String::new());
400                        chunk.new_lines.push(String::new());
401                    }
402                    Some(' ') => {
403                        chunk.old_lines.push(line_contents[1..].to_string());
404                        chunk.new_lines.push(line_contents[1..].to_string());
405                    }
406                    Some('+') => {
407                        chunk.new_lines.push(line_contents[1..].to_string());
408                    }
409                    Some('-') => {
410                        chunk.old_lines.push(line_contents[1..].to_string());
411                    }
412                    _ => {
413                        if parsed_lines == 0 {
414                            return Err(InvalidHunkError {
415                                message: format!(
416                                    "Unexpected line found in update hunk: '{line_contents}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
417                                ),
418                                line_number: line_number + 1,
419                            });
420                        }
421                        // Assume this is the start of the next hunk.
422                        break;
423                    }
424                }
425                parsed_lines += 1;
426            }
427        }
428    }
429
430    Ok((chunk, parsed_lines + start_index))
431}
432
433#[test]
434fn test_parse_patch() {
435    assert_eq!(
436        parse_patch_text("bad", ParseMode::Strict),
437        Err(InvalidPatchError(
438            "The first line of the patch must be '*** Begin Patch'".to_string()
439        ))
440    );
441    assert_eq!(
442        parse_patch_text("*** Begin Patch\nbad", ParseMode::Strict),
443        Err(InvalidPatchError(
444            "The last line of the patch must be '*** End Patch'".to_string()
445        ))
446    );
447    assert_eq!(
448        parse_patch_text(
449            "*** Begin Patch\n\
450             *** Update File: test.py\n\
451             *** End Patch",
452            ParseMode::Strict
453        ),
454        Err(InvalidHunkError {
455            message: "Update file hunk for path 'test.py' is empty".to_string(),
456            line_number: 2,
457        })
458    );
459    assert_eq!(
460        parse_patch_text(
461            "*** Begin Patch\n\
462             *** End Patch",
463            ParseMode::Strict
464        )
465        .unwrap()
466        .hunks,
467        Vec::new()
468    );
469    assert_eq!(
470        parse_patch_text(
471            "*** Begin Patch\n\
472             *** Add File: path/add.py\n\
473             +abc\n\
474             +def\n\
475             *** Delete File: path/delete.py\n\
476             *** Update File: path/update.py\n\
477             *** Move to: path/update2.py\n\
478             @@ def f():\n\
479             -    pass\n\
480             +    return 123\n\
481             *** End Patch",
482            ParseMode::Strict
483        )
484        .unwrap()
485        .hunks,
486        vec![
487            AddFile {
488                path: PathBuf::from("path/add.py"),
489                contents: "abc\ndef\n".to_string()
490            },
491            DeleteFile {
492                path: PathBuf::from("path/delete.py")
493            },
494            UpdateFile {
495                path: PathBuf::from("path/update.py"),
496                move_path: Some(PathBuf::from("path/update2.py")),
497                chunks: vec![UpdateFileChunk {
498                    change_context: Some("def f():".to_string()),
499                    old_lines: vec!["    pass".to_string()],
500                    new_lines: vec!["    return 123".to_string()],
501                    is_end_of_file: false
502                }]
503            }
504        ]
505    );
506    // Update hunk followed by another hunk (Add File).
507    assert_eq!(
508        parse_patch_text(
509            "*** Begin Patch\n\
510             *** Update File: file.py\n\
511             @@\n\
512             +line\n\
513             *** Add File: other.py\n\
514             +content\n\
515             *** End Patch",
516            ParseMode::Strict
517        )
518        .unwrap()
519        .hunks,
520        vec![
521            UpdateFile {
522                path: PathBuf::from("file.py"),
523                move_path: None,
524                chunks: vec![UpdateFileChunk {
525                    change_context: None,
526                    old_lines: vec![],
527                    new_lines: vec!["line".to_string()],
528                    is_end_of_file: false
529                }],
530            },
531            AddFile {
532                path: PathBuf::from("other.py"),
533                contents: "content\n".to_string()
534            }
535        ]
536    );
537
538    // Update hunk without an explicit @@ header for the first chunk should parse.
539    // Use a raw string to preserve the leading space diff marker on the context line.
540    assert_eq!(
541        parse_patch_text(
542            r#"*** Begin Patch
543*** Update File: file2.py
544 import foo
545+bar
546*** End Patch"#,
547            ParseMode::Strict
548        )
549        .unwrap()
550        .hunks,
551        vec![UpdateFile {
552            path: PathBuf::from("file2.py"),
553            move_path: None,
554            chunks: vec![UpdateFileChunk {
555                change_context: None,
556                old_lines: vec!["import foo".to_string()],
557                new_lines: vec!["import foo".to_string(), "bar".to_string()],
558                is_end_of_file: false,
559            }],
560        }]
561    );
562}
563
564#[test]
565fn test_parse_patch_lenient() {
566    let patch_text = r#"*** Begin Patch
567*** Update File: file2.py
568 import foo
569+bar
570*** End Patch"#;
571    let expected_patch = vec![UpdateFile {
572        path: PathBuf::from("file2.py"),
573        move_path: None,
574        chunks: vec![UpdateFileChunk {
575            change_context: None,
576            old_lines: vec!["import foo".to_string()],
577            new_lines: vec!["import foo".to_string(), "bar".to_string()],
578            is_end_of_file: false,
579        }],
580    }];
581    let expected_error =
582        InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
583
584    let patch_text_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
585    assert_eq!(
586        parse_patch_text(&patch_text_in_heredoc, ParseMode::Strict),
587        Err(expected_error.clone())
588    );
589    assert_eq!(
590        parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
591        Ok(ApplyPatchArgs {
592            hunks: expected_patch.clone(),
593            patch: patch_text.to_string(),
594            workdir: None,
595        })
596    );
597
598    let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
599    assert_eq!(
600        parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Strict),
601        Err(expected_error.clone())
602    );
603    assert_eq!(
604        parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
605        Ok(ApplyPatchArgs {
606            hunks: expected_patch.clone(),
607            patch: patch_text.to_string(),
608            workdir: None,
609        })
610    );
611
612    let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
613    assert_eq!(
614        parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Strict),
615        Err(expected_error.clone())
616    );
617    assert_eq!(
618        parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
619        Ok(ApplyPatchArgs {
620            hunks: expected_patch,
621            patch: patch_text.to_string(),
622            workdir: None,
623        })
624    );
625
626    let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
627    assert_eq!(
628        parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Strict),
629        Err(expected_error.clone())
630    );
631    assert_eq!(
632        parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Lenient),
633        Err(expected_error.clone())
634    );
635
636    let patch_text_with_missing_closing_heredoc =
637        "<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
638    assert_eq!(
639        parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
640        Err(expected_error)
641    );
642    assert_eq!(
643        parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
644        Err(InvalidPatchError(
645            "The last line of the patch must be '*** End Patch'".to_string()
646        ))
647    );
648}
649
650#[test]
651fn test_parse_one_hunk() {
652    assert_eq!(
653        parse_one_hunk(&["bad"], 234),
654        Err(InvalidHunkError {
655            message: "'bad' is not a valid hunk header. \
656            Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'".to_string(),
657            line_number: 234
658        })
659    );
660    // Other edge cases are already covered by tests above/below.
661}
662
663#[test]
664fn test_update_file_chunk() {
665    assert_eq!(
666        parse_update_file_chunk(&["bad"], 123, false),
667        Err(InvalidHunkError {
668            message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
669                .to_string(),
670            line_number: 123
671        })
672    );
673    assert_eq!(
674        parse_update_file_chunk(&["@@"], 123, false),
675        Err(InvalidHunkError {
676            message: "Update hunk does not contain any lines".to_string(),
677            line_number: 124
678        })
679    );
680    assert_eq!(
681        parse_update_file_chunk(&["@@", "bad"], 123, false),
682        Err(InvalidHunkError {
683            message:  "Unexpected line found in update hunk: 'bad'. \
684                       Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)".to_string(),
685            line_number: 124
686        })
687    );
688    assert_eq!(
689        parse_update_file_chunk(&["@@", "*** End of File"], 123, false),
690        Err(InvalidHunkError {
691            message: "Update hunk does not contain any lines".to_string(),
692            line_number: 124
693        })
694    );
695    assert_eq!(
696        parse_update_file_chunk(
697            &[
698                "@@ change_context",
699                "",
700                " context",
701                "-remove",
702                "+add",
703                " context2",
704                "*** End Patch",
705            ],
706            123,
707            false
708        ),
709        Ok((
710            (UpdateFileChunk {
711                change_context: Some("change_context".to_string()),
712                old_lines: vec![
713                    "".to_string(),
714                    "context".to_string(),
715                    "remove".to_string(),
716                    "context2".to_string()
717                ],
718                new_lines: vec![
719                    "".to_string(),
720                    "context".to_string(),
721                    "add".to_string(),
722                    "context2".to_string()
723                ],
724                is_end_of_file: false
725            }),
726            6
727        ))
728    );
729    assert_eq!(
730        parse_update_file_chunk(&["@@", "+line", "*** End of File"], 123, false),
731        Ok((
732            (UpdateFileChunk {
733                change_context: None,
734                old_lines: vec![],
735                new_lines: vec!["line".to_string()],
736                is_end_of_file: true
737            }),
738            3
739        ))
740    );
741}