1use regex::Regex;
6use std::sync::OnceLock;
7
8pub const MAX_PATCH_SIZE: usize = 1024 * 1024;
10pub const MAX_HUNKS: usize = 500;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Hunk {
15 Add {
16 path: String,
17 contents: String,
18 },
19 Delete {
20 path: String,
21 },
22 Update {
23 path: String,
24 move_path: Option<String>,
25 chunks: Vec<UpdateFileChunk>,
26 },
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct UpdateFileChunk {
31 pub old_lines: Vec<String>,
32 pub new_lines: Vec<String>,
33 pub change_context: Option<String>,
34 pub is_end_of_file: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PatchHeader {
39 pub file_path: String,
40 pub move_path: Option<String>,
41 pub next_idx: usize,
42}
43
44pub fn strip_heredoc(input: &str) -> String {
51 static OPEN_RE: OnceLock<Regex> = OnceLock::new();
52 let open_re = OPEN_RE.get_or_init(|| {
53 Regex::new(r#"^(?:cat\s+)?<<['"]?([A-Za-z0-9_]+)['"]?\s*\n"#)
54 .expect("heredoc opening regex should compile")
55 });
56
57 let Some(captures) = open_re.captures(input) else {
58 return input.to_owned();
59 };
60 let Some(opening) = captures.get(0) else {
61 return input.to_owned();
62 };
63 let delimiter = captures
64 .get(1)
65 .expect("heredoc regex has a delimiter capture")
66 .as_str();
67 let rest = &input[opening.end()..];
68
69 for (offset, _) in rest.match_indices('\n') {
70 let after_newline = &rest[offset + 1..];
71 let Some(after_delimiter) = after_newline.strip_prefix(delimiter) else {
72 continue;
73 };
74 if after_delimiter.chars().all(char::is_whitespace) {
75 return rest[..offset].to_owned();
76 }
77 }
78
79 input.to_owned()
80}
81
82pub fn parse_patch_header(lines: &[&str], start_idx: usize) -> Option<PatchHeader> {
84 let line = *lines.get(start_idx)?;
85
86 if let Some(path) = line.strip_prefix("*** Add File:") {
87 let file_path = path.trim();
88 return (!file_path.is_empty()).then(|| PatchHeader {
89 file_path: file_path.to_owned(),
90 move_path: None,
91 next_idx: start_idx + 1,
92 });
93 }
94
95 if let Some(path) = line.strip_prefix("*** Delete File:") {
96 let file_path = path.trim();
97 return (!file_path.is_empty()).then(|| PatchHeader {
98 file_path: file_path.to_owned(),
99 move_path: None,
100 next_idx: start_idx + 1,
101 });
102 }
103
104 if let Some(path) = line.strip_prefix("*** Update File:") {
105 let file_path = path.trim();
106 if file_path.is_empty() {
107 return None;
108 }
109
110 let mut move_path = None;
111 let mut next_idx = start_idx + 1;
112 if let Some(next_line) = lines.get(next_idx) {
113 if let Some(path) = next_line.strip_prefix("*** Move to:") {
114 move_path = Some(path.trim().to_owned());
115 next_idx += 1;
116 }
117 }
118
119 return Some(PatchHeader {
120 file_path: file_path.to_owned(),
121 move_path,
122 next_idx,
123 });
124 }
125
126 None
127}
128
129pub fn parse_add_file_content(lines: &[&str], start_idx: usize) -> (String, usize) {
131 let mut content = String::new();
132 let mut i = start_idx;
133
134 while i < lines.len() && !lines[i].starts_with("***") {
135 if let Some(line) = lines[i].strip_prefix('+') {
136 content.push_str(line);
137 content.push('\n');
138 }
139 i += 1;
140 }
141
142 if content.ends_with('\n') {
143 content.pop();
144 }
145
146 (content, i)
147}
148
149pub fn parse_update_file_chunks(lines: &[&str], start_idx: usize) -> (Vec<UpdateFileChunk>, usize) {
151 let mut chunks = Vec::new();
152 let mut i = start_idx;
153
154 while i < lines.len() && !lines[i].starts_with("***") {
155 if lines[i].starts_with("@@") {
156 let context_line = lines[i]["@@".len()..].trim();
157 i += 1;
158
159 let mut old_lines = Vec::new();
160 let mut new_lines = Vec::new();
161 let mut is_end_of_file = false;
162
163 while i < lines.len() && !lines[i].starts_with("@@") {
164 let change_line = lines[i];
165
166 if change_line == "*** End of File" {
167 is_end_of_file = true;
168 i += 1;
169 break;
170 }
171 if change_line.starts_with("***") {
172 break;
173 }
174
175 if let Some(content) = change_line.strip_prefix(' ') {
176 old_lines.push(content.to_owned());
177 new_lines.push(content.to_owned());
178 } else if let Some(content) = change_line.strip_prefix('-') {
179 old_lines.push(content.to_owned());
180 } else if let Some(content) = change_line.strip_prefix('+') {
181 new_lines.push(content.to_owned());
182 }
183
184 i += 1;
185 }
186
187 chunks.push(UpdateFileChunk {
188 old_lines,
189 new_lines,
190 change_context: (!context_line.is_empty()).then(|| context_line.to_owned()),
191 is_end_of_file,
192 });
193 } else {
194 i += 1;
195 }
196 }
197
198 (chunks, i)
199}
200
201pub fn parse_patch(patch_text: &str) -> Result<Vec<Hunk>, String> {
206 if patch_text.len() > MAX_PATCH_SIZE {
207 return Err(format!(
208 "Patch too large: {} bytes exceeds limit of {} bytes",
209 patch_text.len(),
210 MAX_PATCH_SIZE
211 ));
212 }
213
214 let trimmed = patch_text.trim();
215 let cleaned = strip_heredoc(trimmed);
216 let lines: Vec<&str> = cleaned
219 .split('\n')
220 .map(|line| line.strip_suffix('\r').unwrap_or(line))
221 .collect();
222 let mut hunks = Vec::new();
223
224 let begin_idx = lines
225 .iter()
226 .position(|line| line.trim() == "*** Begin Patch");
227 let end_idx = lines.iter().position(|line| line.trim() == "*** End Patch");
228
229 let (Some(begin_idx), Some(end_idx)) = (begin_idx, end_idx) else {
230 return Err(
231 "Invalid patch format: missing *** Begin Patch / *** End Patch markers".to_owned(),
232 );
233 };
234 if begin_idx >= end_idx {
235 return Err(
236 "Invalid patch format: missing *** Begin Patch / *** End Patch markers".to_owned(),
237 );
238 }
239
240 let mut i = begin_idx + 1;
241 while i < end_idx {
242 let Some(header) = parse_patch_header(&lines, i) else {
243 i += 1;
244 continue;
245 };
246
247 if hunks.len() >= MAX_HUNKS {
248 return Err(format!(
249 "Patch exceeds maximum of {} file operations",
250 MAX_HUNKS
251 ));
252 }
253
254 if lines[i].starts_with("*** Add File:") {
255 let (contents, next_idx) = parse_add_file_content(&lines, header.next_idx);
256 hunks.push(Hunk::Add {
257 path: header.file_path,
258 contents,
259 });
260 i = next_idx;
261 } else if lines[i].starts_with("*** Delete File:") {
262 hunks.push(Hunk::Delete {
263 path: header.file_path,
264 });
265 i = header.next_idx;
266 } else if lines[i].starts_with("*** Update File:") {
267 let (chunks, next_idx) = parse_update_file_chunks(&lines, header.next_idx);
268 hunks.push(Hunk::Update {
269 path: header.file_path,
270 move_path: header.move_path,
271 chunks,
272 });
273 i = next_idx;
274 } else {
275 i += 1;
276 }
277 }
278
279 Ok(hunks)
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn assert_parse_error(patch: &str, expected: &str) {
287 assert_eq!(parse_patch(patch).unwrap_err(), expected);
288 }
289
290 #[test]
291 fn parse_patch_missing_markers_matches_patch_parser_test_4_9() {
292 assert_parse_error(
293 "*** Add File: hello.txt\n+hello",
294 "Invalid patch format: missing *** Begin Patch / *** End Patch markers",
295 );
296 }
297
298 #[test]
299 fn parse_patch_empty_body_matches_patch_parser_test_11_13() {
300 assert_eq!(
301 parse_patch("*** Begin Patch\n*** End Patch").unwrap(),
302 vec![]
303 );
304 }
305
306 #[test]
307 fn parse_patch_ignores_empty_add_header_matches_patch_parser_test_15_17() {
308 assert_eq!(
309 parse_patch("*** Begin Patch\n*** Add File:\n+hello\n*** End Patch").unwrap(),
310 vec![]
311 );
312 }
313
314 #[test]
315 fn parse_patch_size_limit_matches_patch_parser_test_19_25() {
316 let oversized_patch = "x".repeat(MAX_PATCH_SIZE + 1);
317 assert_parse_error(
318 &oversized_patch,
319 "Patch too large: 1048577 bytes exceeds limit of 1048576 bytes",
320 );
321 }
322
323 #[test]
324 fn parse_patch_hunk_limit_matches_patch_parser_test_27_38() {
325 let mut patch = vec!["*** Begin Patch".to_owned()];
326 for index in 0..=MAX_HUNKS {
327 patch.push(format!("*** Add File: file-{index}.txt"));
328 patch.push(format!("+line {index}"));
329 }
330 patch.push("*** End Patch".to_owned());
331
332 assert_parse_error(
333 &patch.join("\n"),
334 "Patch exceeds maximum of 500 file operations",
335 );
336 }
337
338 #[test]
339 fn parse_patch_invalid_heredoc_matches_patch_parser_test_40_56() {
340 let wrapped_patch = [
341 "<<EOF",
342 "*** Begin Patch",
343 "*** Add File: hello.txt",
344 "+hello world",
345 "*** End Patch",
346 "NOT_EOF",
347 ]
348 .join("\n");
349
350 let expected = vec![Hunk::Add {
351 path: "hello.txt".to_owned(),
352 contents: "hello world".to_owned(),
353 }];
354 assert_eq!(parse_patch(&wrapped_patch).unwrap(), expected);
355 assert_eq!(
356 parse_patch(&format!("prefix\n{wrapped_patch}")).unwrap(),
357 expected
358 );
359 }
360
361 #[test]
362 fn strip_heredoc_accepts_whole_input_wrapper_from_patch_parser_source_33_36() {
363 let wrapped_patch = [
364 "cat <<'PATCH'",
365 "*** Begin Patch",
366 "*** Add File: hello.txt",
367 "+hello world",
368 "*** End Patch",
369 "PATCH",
370 ]
371 .join("\n");
372
373 assert_eq!(
374 parse_patch(&wrapped_patch).unwrap(),
375 vec![Hunk::Add {
376 path: "hello.txt".to_owned(),
377 contents: "hello world".to_owned(),
378 }]
379 );
380 }
381
382 #[test]
383 fn parse_patch_round_trips_add_delete_update_move_from_parser_source_38_141() {
384 let patch = [
385 "*** Begin Patch",
386 "*** Add File: src/new.txt",
387 "+hello",
388 "+world",
389 "*** Delete File: src/old.txt",
390 "*** Update File: src/edit.txt",
391 "@@ function demo()",
392 " const keep = true;",
393 "-const value = 1;",
394 "+const value = 2;",
395 "*** Update File: src/from.txt",
396 "*** Move to: src/to.txt",
397 "@@",
398 "-old",
399 "+new",
400 "*** End of File",
401 "*** End Patch",
402 ]
403 .join("\n");
404
405 assert_eq!(
406 parse_patch(&patch).unwrap(),
407 vec![
408 Hunk::Add {
409 path: "src/new.txt".to_owned(),
410 contents: "hello\nworld".to_owned(),
411 },
412 Hunk::Delete {
413 path: "src/old.txt".to_owned(),
414 },
415 Hunk::Update {
416 path: "src/edit.txt".to_owned(),
417 move_path: None,
418 chunks: vec![UpdateFileChunk {
419 old_lines: vec![
420 "const keep = true;".to_owned(),
421 "const value = 1;".to_owned()
422 ],
423 new_lines: vec![
424 "const keep = true;".to_owned(),
425 "const value = 2;".to_owned()
426 ],
427 change_context: Some("function demo()".to_owned()),
428 is_end_of_file: false,
429 }],
430 },
431 Hunk::Update {
432 path: "src/from.txt".to_owned(),
433 move_path: Some("src/to.txt".to_owned()),
434 chunks: vec![UpdateFileChunk {
435 old_lines: vec!["old".to_owned()],
436 new_lines: vec!["new".to_owned()],
437 change_context: None,
438 is_end_of_file: true,
439 }],
440 },
441 ]
442 );
443 }
444
445 #[test]
446 fn parse_patch_supports_multiple_chunks_in_one_update_from_parser_source_91_141() {
447 let patch = [
448 "*** Begin Patch",
449 "*** Update File: src/multi.txt",
450 "@@ first",
451 "-one",
452 "+two",
453 "@@ second",
454 " three",
455 "-four",
456 "+five",
457 "*** End Patch",
458 ]
459 .join("\n");
460
461 assert_eq!(
462 parse_patch(&patch).unwrap(),
463 vec![Hunk::Update {
464 path: "src/multi.txt".to_owned(),
465 move_path: None,
466 chunks: vec![
467 UpdateFileChunk {
468 old_lines: vec!["one".to_owned()],
469 new_lines: vec!["two".to_owned()],
470 change_context: Some("first".to_owned()),
471 is_end_of_file: false,
472 },
473 UpdateFileChunk {
474 old_lines: vec!["three".to_owned(), "four".to_owned()],
475 new_lines: vec!["three".to_owned(), "five".to_owned()],
476 change_context: Some("second".to_owned()),
477 is_end_of_file: false,
478 },
479 ],
480 }]
481 );
482 }
483
484 #[test]
485 fn crlf_envelope_does_not_leak_carriage_returns_into_patch_content() {
486 let patch = [
487 "*** Begin Patch",
488 "*** Add File: created.txt",
489 "+created",
490 "*** Update File: existing.txt",
491 "@@",
492 "-old\rinside",
493 "+new\rinside",
494 "*** End Patch",
495 ]
496 .join("\r\n");
497
498 assert_eq!(
499 parse_patch(&patch).unwrap(),
500 vec![
501 Hunk::Add {
502 path: "created.txt".to_owned(),
503 contents: "created".to_owned(),
504 },
505 Hunk::Update {
506 path: "existing.txt".to_owned(),
507 move_path: None,
508 chunks: vec![UpdateFileChunk {
509 old_lines: vec!["old\rinside".to_owned()],
510 new_lines: vec!["new\rinside".to_owned()],
511 change_context: None,
512 is_end_of_file: false,
513 }],
514 },
515 ]
516 );
517 }
518}