1use crate::ApplyPatchArgs;
27use crate::streaming_parser::StreamingPatchParser;
28#[cfg(test)]
29use codex_utils_absolute_path::test_support::PathBufExt;
30use codex_utils_path_uri::PathUri;
31use codex_utils_path_uri::PathUriParseError;
32use std::path::Path;
33use std::path::PathBuf;
34
35use thiserror::Error;
36
37pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
38pub(crate) const END_PATCH_MARKER: &str = "*** End Patch";
39pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: ";
40pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: ";
41pub(crate) const UPDATE_FILE_MARKER: &str = "*** Update File: ";
42pub(crate) const MOVE_TO_MARKER: &str = "*** Move to: ";
43pub(crate) const EOF_MARKER: &str = "*** End of File";
44pub(crate) const CHANGE_CONTEXT_MARKER: &str = "@@ ";
45pub(crate) const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
46
47const PARSE_IN_STRICT_MODE: bool = false;
54
55#[derive(Debug, PartialEq, Error, Clone)]
56pub enum ParseError {
57 #[error("invalid patch: {0}")]
58 InvalidPatchError(String),
59 #[error("invalid hunk at line {line_number}, {message}")]
60 InvalidHunkError { message: String, line_number: usize },
61}
62use ParseError::*;
63
64#[derive(Debug, PartialEq, Clone)]
65#[allow(clippy::enum_variant_names)]
66pub enum Hunk {
67 AddFile {
68 path: PathBuf,
69 contents: String,
70 },
71 DeleteFile {
72 path: PathBuf,
73 },
74 UpdateFile {
75 path: PathBuf,
76 move_path: Option<PathBuf>,
77
78 chunks: Vec<UpdateFileChunk>,
81 },
82}
83
84impl Hunk {
85 pub fn resolve_path(&self, cwd: &PathUri) -> Result<PathUri, PathUriParseError> {
86 let path = match self {
87 Hunk::UpdateFile { path, .. } => path,
88 Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(),
89 };
90 cwd.join(&path.to_string_lossy())
91 }
92
93 pub fn path(&self) -> &Path {
95 match self {
96 Hunk::AddFile { path, .. } => path,
97 Hunk::DeleteFile { path } => path,
98 Hunk::UpdateFile {
99 move_path: Some(path),
100 ..
101 } => path,
102 Hunk::UpdateFile {
103 path,
104 move_path: None,
105 ..
106 } => path,
107 }
108 }
109}
110
111#[cfg(test)]
112use Hunk::*;
113
114#[derive(Debug, PartialEq, Clone)]
115pub struct UpdateFileChunk {
116 pub change_context: Option<String>,
119
120 pub old_lines: Vec<String>,
123 pub new_lines: Vec<String>,
124
125 pub is_end_of_file: bool,
128}
129
130pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
131 let mode = if PARSE_IN_STRICT_MODE {
132 ParseMode::Strict
133 } else {
134 ParseMode::Lenient
135 };
136 parse_patch_text(patch, mode)
137}
138
139enum ParseMode {
140 Strict,
142
143 Lenient,
176}
177
178fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
179 let lines: Vec<&str> = patch.trim().lines().collect();
180 let patch_lines = match mode {
181 ParseMode::Strict => check_patch_boundaries_strict(&lines)?,
182 ParseMode::Lenient => check_patch_boundaries_lenient(&lines)?,
183 };
184
185 let patch = patch_lines.join("\n");
186 let mut parser = StreamingPatchParser::default();
187 parser.push_delta(&patch)?;
188 let hunks = parser.finish()?;
189 let environment_id = parser.environment_id().map(str::to_owned);
190 Ok(ApplyPatchArgs {
191 hunks,
192 patch,
193 workdir: None,
194 environment_id,
195 })
196}
197
198fn check_patch_boundaries_strict<'a>(lines: &'a [&'a str]) -> Result<&'a [&'a str], ParseError> {
201 let (first_line, last_line) = match lines {
202 [] => (None, None),
203 [first] => (Some(first), Some(first)),
204 [first, .., last] => (Some(first), Some(last)),
205 };
206 check_start_and_end_lines_strict(first_line, last_line)?;
207 Ok(lines)
208}
209
210fn check_patch_boundaries_lenient<'a>(
218 original_lines: &'a [&'a str],
219) -> Result<&'a [&'a str], ParseError> {
220 let original_parse_error = match check_patch_boundaries_strict(original_lines) {
221 Ok(lines) => return Ok(lines),
222 Err(e) => e,
223 };
224
225 match original_lines {
226 [first, .., last] => {
227 if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
228 && last.ends_with("EOF")
229 && original_lines.len() >= 4
230 {
231 let inner_lines = &original_lines[1..original_lines.len() - 1];
232 check_patch_boundaries_strict(inner_lines)
233 } else {
234 Err(original_parse_error)
235 }
236 }
237 _ => Err(original_parse_error),
238 }
239}
240
241fn check_start_and_end_lines_strict(
242 first_line: Option<&&str>,
243 last_line: Option<&&str>,
244) -> Result<(), ParseError> {
245 let first_line = first_line.map(|line| line.trim());
246 let last_line = last_line.map(|line| line.trim());
247
248 match (first_line, last_line) {
249 (Some(first), Some(last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => {
250 Ok(())
251 }
252 (Some(first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from(
253 "The first line of the patch must be '*** Begin Patch'",
254 ))),
255 _ => Err(InvalidPatchError(String::from(
256 "The last line of the patch must be '*** End Patch'",
257 ))),
258 }
259}
260
261#[test]
262fn test_parse_patch() {
263 assert_eq!(
264 parse_patch_text("bad", ParseMode::Strict),
265 Err(InvalidPatchError(
266 "The first line of the patch must be '*** Begin Patch'".to_string()
267 ))
268 );
269 assert_eq!(
270 parse_patch_text("*** Begin Patch\nbad", ParseMode::Strict),
271 Err(InvalidPatchError(
272 "The last line of the patch must be '*** End Patch'".to_string()
273 ))
274 );
275
276 assert_eq!(
277 parse_patch_text(
278 concat!(
279 "*** Begin Patch",
280 " ",
281 "\n*** Add File: foo\n+hi\n",
282 " ",
283 "*** End Patch"
284 ),
285 ParseMode::Strict
286 )
287 .unwrap()
288 .hunks,
289 vec![AddFile {
290 path: PathBuf::from("foo"),
291 contents: "hi\n".to_string()
292 }]
293 );
294 assert_eq!(
295 parse_patch_text(
296 "*** Begin Patch\n\
297 *** Update File: test.py\n\
298 *** End Patch",
299 ParseMode::Strict
300 ),
301 Err(InvalidHunkError {
302 message: "Update file hunk for path 'test.py' is empty".to_string(),
303 line_number: 2,
304 })
305 );
306 assert_eq!(
307 parse_patch_text(
308 "*** Begin Patch\n\
309 *** End Patch",
310 ParseMode::Strict
311 )
312 .unwrap()
313 .hunks,
314 Vec::new()
315 );
316 assert_eq!(
317 parse_patch_text(
318 "*** Begin Patch\n\
319 *** Add File: path/add.py\n\
320 +abc\n\
321 +def\n\
322 *** Delete File: path/delete.py\n\
323 *** Update File: path/update.py\n\
324 *** Move to: path/update2.py\n\
325 @@ def f():\n\
326 - pass\n\
327 + return 123\n\
328 *** End Patch",
329 ParseMode::Strict
330 )
331 .unwrap()
332 .hunks,
333 vec![
334 AddFile {
335 path: PathBuf::from("path/add.py"),
336 contents: "abc\ndef\n".to_string()
337 },
338 DeleteFile {
339 path: PathBuf::from("path/delete.py")
340 },
341 UpdateFile {
342 path: PathBuf::from("path/update.py"),
343 move_path: Some(PathBuf::from("path/update2.py")),
344 chunks: vec![UpdateFileChunk {
345 change_context: Some("def f():".to_string()),
346 old_lines: vec![" pass".to_string()],
347 new_lines: vec![" return 123".to_string()],
348 is_end_of_file: false
349 }]
350 }
351 ]
352 );
353 assert_eq!(
355 parse_patch_text(
356 "*** Begin Patch\n\
357 *** Update File: file.py\n\
358 @@\n\
359 +line\n\
360 *** Add File: other.py\n\
361 +content\n\
362 *** End Patch",
363 ParseMode::Strict
364 )
365 .unwrap()
366 .hunks,
367 vec![
368 UpdateFile {
369 path: PathBuf::from("file.py"),
370 move_path: None,
371 chunks: vec![UpdateFileChunk {
372 change_context: None,
373 old_lines: vec![],
374 new_lines: vec!["line".to_string()],
375 is_end_of_file: false
376 }],
377 },
378 AddFile {
379 path: PathBuf::from("other.py"),
380 contents: "content\n".to_string()
381 }
382 ]
383 );
384
385 assert_eq!(
388 parse_patch_text(
389 r#"*** Begin Patch
390*** Update File: file2.py
391 import foo
392+bar
393*** End Patch"#,
394 ParseMode::Strict
395 )
396 .unwrap()
397 .hunks,
398 vec![UpdateFile {
399 path: PathBuf::from("file2.py"),
400 move_path: None,
401 chunks: vec![UpdateFileChunk {
402 change_context: None,
403 old_lines: vec!["import foo".to_string()],
404 new_lines: vec!["import foo".to_string(), "bar".to_string()],
405 is_end_of_file: false,
406 }],
407 }]
408 );
409}
410
411#[test]
412fn test_parse_patch_preserves_end_of_file_marker() {
413 let patch =
414 "*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch";
415 assert_eq!(
416 parse_patch(patch),
417 Ok(ApplyPatchArgs {
418 hunks: vec![UpdateFile {
419 path: PathBuf::from("file.txt"),
420 move_path: None,
421 chunks: vec![UpdateFileChunk {
422 change_context: None,
423 old_lines: Vec::new(),
424 new_lines: vec!["quux".to_string()],
425 is_end_of_file: true,
426 }],
427 }],
428 patch: patch.to_string(),
429 workdir: None,
430 environment_id: None,
431 })
432 );
433}
434
435#[test]
436fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() {
437 let dir = tempfile::tempdir().unwrap();
438 let absolute_delete = dir.path().join("absolute-delete.py").abs();
439 let absolute_update = dir.path().join("absolute-update.py").abs();
440 let patch_text = format!(
441 r#"*** Begin Patch
442*** Add File: relative-add.py
443+content
444*** Delete File: {}
445*** Update File: {}
446@@
447-old
448+new
449*** End Patch"#,
450 absolute_delete.display(),
451 absolute_update.display()
452 );
453
454 assert_eq!(
455 parse_patch_text(&patch_text, ParseMode::Strict)
456 .unwrap()
457 .hunks,
458 vec![
459 AddFile {
460 path: PathBuf::from("relative-add.py"),
461 contents: "content\n".to_string()
462 },
463 DeleteFile {
464 path: absolute_delete.to_path_buf()
465 },
466 UpdateFile {
467 path: absolute_update.to_path_buf(),
468 move_path: None,
469 chunks: vec![UpdateFileChunk {
470 change_context: None,
471 old_lines: vec!["old".to_string()],
472 new_lines: vec!["new".to_string()],
473 is_end_of_file: false
474 }]
475 },
476 ]
477 );
478}
479
480#[test]
481fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() {
482 let cwd_dir = tempfile::tempdir().unwrap();
483 let cwd = PathUri::from_host_native_path(cwd_dir.path()).unwrap();
484 let absolute_dir = tempfile::tempdir().unwrap();
485 let absolute_add = absolute_dir.path().join("absolute-add.py").abs();
486 let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs();
487 let absolute_update = absolute_dir.path().join("absolute-update.py").abs();
488
489 for (hunk, expected_path) in [
490 (
491 AddFile {
492 path: PathBuf::from("relative-add.py"),
493 contents: String::new(),
494 },
495 cwd.join("relative-add.py").unwrap(),
496 ),
497 (
498 DeleteFile {
499 path: PathBuf::from("relative-delete.py"),
500 },
501 cwd.join("relative-delete.py").unwrap(),
502 ),
503 (
504 UpdateFile {
505 path: PathBuf::from("relative-update.py"),
506 move_path: None,
507 chunks: Vec::new(),
508 },
509 cwd.join("relative-update.py").unwrap(),
510 ),
511 (
512 AddFile {
513 path: absolute_add.to_path_buf(),
514 contents: String::new(),
515 },
516 PathUri::from_abs_path(&absolute_add),
517 ),
518 (
519 DeleteFile {
520 path: absolute_delete.to_path_buf(),
521 },
522 PathUri::from_abs_path(&absolute_delete),
523 ),
524 (
525 UpdateFile {
526 path: absolute_update.to_path_buf(),
527 move_path: None,
528 chunks: Vec::new(),
529 },
530 PathUri::from_abs_path(&absolute_update),
531 ),
532 ] {
533 assert_eq!(hunk.resolve_path(&cwd), Ok(expected_path));
534 }
535}
536
537#[test]
538fn test_parse_patch_lenient() {
539 let patch_text = r#"*** Begin Patch
540*** Update File: file2.py
541 import foo
542+bar
543*** End Patch"#;
544 let expected_patch = vec![UpdateFile {
545 path: PathBuf::from("file2.py"),
546 move_path: None,
547 chunks: vec![UpdateFileChunk {
548 change_context: None,
549 old_lines: vec!["import foo".to_string()],
550 new_lines: vec!["import foo".to_string(), "bar".to_string()],
551 is_end_of_file: false,
552 }],
553 }];
554 let expected_error =
555 InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
556
557 let patch_text_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
558 assert_eq!(
559 parse_patch_text(&patch_text_in_heredoc, ParseMode::Strict),
560 Err(expected_error.clone())
561 );
562 assert_eq!(
563 parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
564 Ok(ApplyPatchArgs {
565 hunks: expected_patch.clone(),
566 patch: patch_text.to_string(),
567 workdir: None,
568 environment_id: None,
569 })
570 );
571
572 let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
573 assert_eq!(
574 parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Strict),
575 Err(expected_error.clone())
576 );
577 assert_eq!(
578 parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
579 Ok(ApplyPatchArgs {
580 hunks: expected_patch.clone(),
581 patch: patch_text.to_string(),
582 workdir: None,
583 environment_id: None,
584 })
585 );
586
587 let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
588 assert_eq!(
589 parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Strict),
590 Err(expected_error.clone())
591 );
592 assert_eq!(
593 parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
594 Ok(ApplyPatchArgs {
595 hunks: expected_patch,
596 patch: patch_text.to_string(),
597 workdir: None,
598 environment_id: None,
599 })
600 );
601
602 let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
603 assert_eq!(
604 parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Strict),
605 Err(expected_error.clone())
606 );
607 assert_eq!(
608 parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Lenient),
609 Err(expected_error.clone())
610 );
611
612 let patch_text_with_missing_closing_heredoc =
613 "<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
614 assert_eq!(
615 parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
616 Err(expected_error)
617 );
618 assert_eq!(
619 parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
620 Err(InvalidPatchError(
621 "The last line of the patch must be '*** End Patch'".to_string()
622 ))
623 );
624}
625
626#[test]
627fn test_parse_patch_environment_id_preamble() {
628 assert_eq!(
629 parse_patch_text(
630 "*** Begin Patch\n\
631 *** Environment ID: remote\n\
632 *** Add File: hello.txt\n\
633 +hello\n\
634 *** End Patch",
635 ParseMode::Strict
636 ),
637 Ok(ApplyPatchArgs {
638 hunks: vec![AddFile {
639 path: PathBuf::from("hello.txt"),
640 contents: "hello\n".to_string(),
641 }],
642 patch: "*** Begin Patch\n*** Environment ID: remote\n*** Add File: hello.txt\n+hello\n*** End Patch".to_string(),
643 workdir: None,
644 environment_id: Some("remote".to_string()),
645 })
646 );
647
648 assert_eq!(
649 parse_patch_text(
650 "*** Begin Patch\n\
651 *** Environment ID: \n\
652 *** Add File: hello.txt\n\
653 +hello\n\
654 *** End Patch",
655 ParseMode::Strict
656 ),
657 Err(InvalidPatchError(
658 "apply_patch environment_id cannot be empty".to_string()
659 ))
660 );
661}