1use std::path::{Path, PathBuf};
2
3use crate::error::SkillfileError;
4use crate::models::Entry;
5
6pub const PATCHES_DIR: &str = ".skillfile/patches";
7
8#[must_use]
14pub fn patches_root(repo_root: &Path) -> PathBuf {
15 repo_root.join(PATCHES_DIR)
16}
17
18pub fn patch_path(entry: &Entry, repo_root: &Path) -> PathBuf {
21 patches_root(repo_root)
22 .join(entry.entity_type.dir_name())
23 .join(format!("{}.patch", entry.name))
24}
25
26#[must_use]
30pub fn has_patch(entry: &Entry, repo_root: &Path) -> bool {
31 patch_path(entry, repo_root).exists()
32}
33
34pub fn write_patch(
36 entry: &Entry,
37 patch_text: &str,
38 repo_root: &Path,
39) -> Result<(), SkillfileError> {
40 let p = patch_path(entry, repo_root);
41 if let Some(parent) = p.parent() {
42 std::fs::create_dir_all(parent)?;
43 }
44 std::fs::write(&p, patch_text)?;
45 Ok(())
46}
47
48pub fn read_patch(entry: &Entry, repo_root: &Path) -> Result<String, SkillfileError> {
50 let p = patch_path(entry, repo_root);
51 Ok(std::fs::read_to_string(&p)?)
52}
53
54pub fn remove_patch(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
56 let p = patch_path(entry, repo_root);
57 if !p.exists() {
58 return Ok(());
59 }
60 std::fs::remove_file(&p)?;
61 if let Some(parent) = p.parent() {
62 if parent.exists() {
63 let is_empty = std::fs::read_dir(parent)?.next().is_none();
64 if is_empty {
65 let _ = std::fs::remove_dir(parent);
66 }
67 }
68 }
69 Ok(())
70}
71
72pub fn dir_patch_path(entry: &Entry, filename: &str, repo_root: &Path) -> PathBuf {
79 patches_root(repo_root)
80 .join(entry.entity_type.dir_name())
81 .join(&entry.name)
82 .join(format!("{filename}.patch"))
83}
84
85#[must_use]
87pub fn has_dir_patch(entry: &Entry, repo_root: &Path) -> bool {
88 let d = patches_root(repo_root)
89 .join(entry.entity_type.dir_name())
90 .join(&entry.name);
91 if !d.is_dir() {
92 return false;
93 }
94 walkdir(&d)
95 .into_iter()
96 .any(|p| p.extension().is_some_and(|e| e == "patch"))
97}
98
99pub fn write_dir_patch(
100 entry: &Entry,
101 filename: &str,
102 patch_text: &str,
103 repo_root: &Path,
104) -> Result<(), SkillfileError> {
105 let p = dir_patch_path(entry, filename, repo_root);
106 if let Some(parent) = p.parent() {
107 std::fs::create_dir_all(parent)?;
108 }
109 std::fs::write(&p, patch_text)?;
110 Ok(())
111}
112
113pub fn remove_dir_patch(
114 entry: &Entry,
115 filename: &str,
116 repo_root: &Path,
117) -> Result<(), SkillfileError> {
118 let p = dir_patch_path(entry, filename, repo_root);
119 if !p.exists() {
120 return Ok(());
121 }
122 std::fs::remove_file(&p)?;
123 if let Some(parent) = p.parent() {
124 if parent.exists() {
125 let is_empty = std::fs::read_dir(parent)?.next().is_none();
126 if is_empty {
127 let _ = std::fs::remove_dir(parent);
128 }
129 }
130 }
131 Ok(())
132}
133
134pub fn remove_all_dir_patches(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
135 let d = patches_root(repo_root)
136 .join(entry.entity_type.dir_name())
137 .join(&entry.name);
138 if d.is_dir() {
139 std::fs::remove_dir_all(&d)?;
140 }
141 Ok(())
142}
143
144pub fn generate_patch(original: &str, modified: &str, label: &str) -> String {
164 if original == modified {
165 return String::new();
166 }
167
168 let diff = similar::TextDiff::from_lines(original, modified);
169 let raw = format!(
170 "{}",
171 diff.unified_diff()
172 .context_radius(3)
173 .header(&format!("a/{label}"), &format!("b/{label}"))
174 );
175
176 if raw.is_empty() {
177 return String::new();
178 }
179
180 let mut result = String::new();
183 for line in raw.split_inclusive('\n') {
184 if line.starts_with("\\ ") {
185 if !result.ends_with('\n') {
187 result.push('\n');
188 }
189 } else if line.ends_with('\n') {
191 result.push_str(line);
192 } else {
193 result.push_str(line);
194 result.push('\n');
195 }
196 }
197
198 result
199}
200
201struct Hunk {
206 orig_start: usize, body: Vec<String>,
208}
209
210fn parse_hunks(patch_text: &str) -> Result<Vec<Hunk>, SkillfileError> {
211 let lines: Vec<&str> = patch_text.split_inclusive('\n').collect();
212 let mut pi = 0;
213
214 while pi < lines.len() && (lines[pi].starts_with("--- ") || lines[pi].starts_with("+++ ")) {
216 pi += 1;
217 }
218
219 let mut hunks: Vec<Hunk> = Vec::new();
220
221 while pi < lines.len() {
222 let pl = lines[pi];
223 if !pl.starts_with("@@ ") {
224 pi += 1;
225 continue;
226 }
227
228 let orig_start = pl
231 .split_whitespace()
232 .nth(1) .and_then(|s| s.trim_start_matches('-').split(',').next())
234 .and_then(|n| n.parse::<usize>().ok())
235 .ok_or_else(|| SkillfileError::Manifest(format!("malformed hunk header: {pl:?}")))?;
236
237 pi += 1;
238 let mut body: Vec<String> = Vec::new();
239
240 while pi < lines.len() {
241 let hl = lines[pi];
242 if hl.starts_with("@@ ") || hl.starts_with("--- ") || hl.starts_with("+++ ") {
243 break;
244 }
245 if hl.starts_with("\\ ") {
246 pi += 1;
248 continue;
249 }
250 body.push(hl.to_string());
251 pi += 1;
252 }
253
254 hunks.push(Hunk { orig_start, body });
255 }
256
257 Ok(hunks)
258}
259
260fn try_hunk_at(lines: &[String], start: usize, ctx_lines: &[&str]) -> bool {
261 if start + ctx_lines.len() > lines.len() {
262 return false;
263 }
264 for (i, expected) in ctx_lines.iter().enumerate() {
265 if lines[start + i].trim_end_matches('\n') != *expected {
266 return false;
267 }
268 }
269 true
270}
271
272fn find_hunk_position(
273 lines: &[String],
274 hunk_start: usize,
275 ctx_lines: &[&str],
276 min_pos: usize,
277) -> Result<usize, SkillfileError> {
278 if try_hunk_at(lines, hunk_start, ctx_lines) {
279 return Ok(hunk_start);
280 }
281
282 for delta in 1..100usize {
283 let candidates = [Some(hunk_start + delta), hunk_start.checked_sub(delta)];
284 for candidate in candidates.into_iter().flatten() {
285 if candidate < min_pos || candidate > lines.len() {
286 continue;
287 }
288 if try_hunk_at(lines, candidate, ctx_lines) {
289 return Ok(candidate);
290 }
291 }
292 }
293
294 if !ctx_lines.is_empty() {
295 return Err(SkillfileError::Manifest(format!(
296 "context mismatch: cannot find context starting with {:?} near line {}",
297 ctx_lines[0],
298 hunk_start + 1
299 )));
300 }
301 Err(SkillfileError::Manifest(
302 "patch extends beyond end of file".into(),
303 ))
304}
305
306pub fn apply_patch_pure(original: &str, patch_text: &str) -> Result<String, SkillfileError> {
321 if patch_text.is_empty() {
322 return Ok(original.to_string());
323 }
324
325 let lines: Vec<String> = original
327 .split_inclusive('\n')
328 .map(|s| s.to_string())
329 .collect();
330
331 let mut output: Vec<String> = Vec::new();
332 let mut li = 0usize; for hunk in parse_hunks(patch_text)? {
335 let ctx_lines: Vec<&str> = hunk
337 .body
338 .iter()
339 .filter(|hl| !hl.is_empty() && (hl.starts_with(' ') || hl.starts_with('-')))
340 .map(|hl| hl[1..].trim_end_matches('\n'))
341 .collect();
342
343 let hunk_start =
344 find_hunk_position(&lines, hunk.orig_start.saturating_sub(1), &ctx_lines, li)?;
345
346 output.extend_from_slice(&lines[li..hunk_start]);
348 li = hunk_start;
349
350 for hl in &hunk.body {
352 if hl.is_empty() {
353 continue;
354 }
355 match hl.chars().next() {
356 Some(' ') => {
357 if li < lines.len() {
358 output.push(lines[li].clone());
359 li += 1;
360 }
361 }
362 Some('-') => {
363 li += 1;
364 }
365 Some('+') => {
366 output.push(hl[1..].to_string());
367 }
368 _ => {}
369 }
370 }
371 }
372
373 output.extend_from_slice(&lines[li..]);
375 Ok(output.concat())
376}
377
378#[must_use]
384pub fn walkdir(dir: &Path) -> Vec<PathBuf> {
385 let mut result = Vec::new();
386 walkdir_inner(dir, &mut result);
387 result.sort();
388 result
389}
390
391fn walkdir_inner(dir: &Path, result: &mut Vec<PathBuf>) {
392 if let Ok(entries) = std::fs::read_dir(dir) {
393 for entry in entries.flatten() {
394 let path = entry.path();
395 if path.is_dir() {
396 walkdir_inner(&path, result);
397 } else {
398 result.push(path);
399 }
400 }
401 }
402}
403
404#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::models::{EntityType, SourceFields};
412
413 fn github_entry(name: &str, entity_type: EntityType) -> Entry {
414 Entry {
415 entity_type,
416 name: name.to_string(),
417 source: SourceFields::Github {
418 owner_repo: "owner/repo".into(),
419 path_in_repo: "agents/test.md".into(),
420 ref_: "main".into(),
421 },
422 }
423 }
424
425 #[test]
428 fn generate_patch_identical_returns_empty() {
429 assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
430 }
431
432 #[test]
433 fn generate_patch_has_headers() {
434 let p = generate_patch("old\n", "new\n", "test.md");
435 assert!(p.contains("--- a/test.md"), "missing fromfile header");
436 assert!(p.contains("+++ b/test.md"), "missing tofile header");
437 }
438
439 #[test]
440 fn generate_patch_add_line() {
441 let p = generate_patch("line1\n", "line1\nline2\n", "test.md");
442 assert!(p.contains("+line2"));
443 }
444
445 #[test]
446 fn generate_patch_remove_line() {
447 let p = generate_patch("line1\nline2\n", "line1\n", "test.md");
448 assert!(p.contains("-line2"));
449 }
450
451 #[test]
452 fn generate_patch_all_lines_end_with_newline() {
453 let p = generate_patch("a\nb\n", "a\nc\n", "test.md");
454 for seg in p.split_inclusive('\n') {
455 assert!(seg.ends_with('\n'), "line does not end with \\n: {seg:?}");
456 }
457 }
458
459 #[test]
462 fn apply_patch_empty_patch_returns_original() {
463 let result = apply_patch_pure("hello\n", "").unwrap();
464 assert_eq!(result, "hello\n");
465 }
466
467 #[test]
468 fn apply_patch_round_trip_add_line() {
469 let orig = "line1\nline2\n";
470 let modified = "line1\nline2\nline3\n";
471 let patch = generate_patch(orig, modified, "test.md");
472 let result = apply_patch_pure(orig, &patch).unwrap();
473 assert_eq!(result, modified);
474 }
475
476 #[test]
477 fn apply_patch_round_trip_remove_line() {
478 let orig = "line1\nline2\nline3\n";
479 let modified = "line1\nline3\n";
480 let patch = generate_patch(orig, modified, "test.md");
481 let result = apply_patch_pure(orig, &patch).unwrap();
482 assert_eq!(result, modified);
483 }
484
485 #[test]
486 fn apply_patch_round_trip_modify_line() {
487 let orig = "# Title\n\nSome text here.\n";
488 let modified = "# Title\n\nSome modified text here.\n";
489 let patch = generate_patch(orig, modified, "test.md");
490 let result = apply_patch_pure(orig, &patch).unwrap();
491 assert_eq!(result, modified);
492 }
493
494 #[test]
495 fn apply_patch_multi_hunk() {
496 let orig = (0..20).map(|i| format!("line{i}\n")).collect::<String>();
497 let mut modified = orig.clone();
498 modified = modified.replace("line2\n", "MODIFIED2\n");
499 modified = modified.replace("line15\n", "MODIFIED15\n");
500 let patch = generate_patch(&orig, &modified, "test.md");
501 assert!(patch.contains("@@"), "should have hunk headers");
502 let result = apply_patch_pure(&orig, &patch).unwrap();
503 assert_eq!(result, modified);
504 }
505
506 #[test]
507 fn apply_patch_context_mismatch_errors() {
508 let orig = "line1\nline2\n";
509 let patch = "--- a/test.md\n+++ b/test.md\n@@ -1,2 +1,2 @@\n-totally_wrong\n+new\n";
510 let result = apply_patch_pure(orig, patch);
511 assert!(result.is_err());
512 assert!(result.unwrap_err().to_string().contains("context mismatch"));
513 }
514
515 #[test]
518 fn patch_path_single_file_agent() {
519 let entry = github_entry("my-agent", EntityType::Agent);
520 let root = Path::new("/repo");
521 let p = patch_path(&entry, root);
522 assert_eq!(
523 p,
524 Path::new("/repo/.skillfile/patches/agents/my-agent.patch")
525 );
526 }
527
528 #[test]
529 fn patch_path_single_file_skill() {
530 let entry = github_entry("my-skill", EntityType::Skill);
531 let root = Path::new("/repo");
532 let p = patch_path(&entry, root);
533 assert_eq!(
534 p,
535 Path::new("/repo/.skillfile/patches/skills/my-skill.patch")
536 );
537 }
538
539 #[test]
540 fn dir_patch_path_returns_correct() {
541 let entry = github_entry("lang-pro", EntityType::Skill);
542 let root = Path::new("/repo");
543 let p = dir_patch_path(&entry, "python.md", root);
544 assert_eq!(
545 p,
546 Path::new("/repo/.skillfile/patches/skills/lang-pro/python.md.patch")
547 );
548 }
549
550 #[test]
551 fn write_read_remove_patch_round_trip() {
552 let dir = tempfile::tempdir().unwrap();
553 let entry = github_entry("test-agent", EntityType::Agent);
554 let patch_text = "--- a/test-agent.md\n+++ b/test-agent.md\n@@ -1 +1 @@\n-old\n+new\n";
555 write_patch(&entry, patch_text, dir.path()).unwrap();
556 assert!(has_patch(&entry, dir.path()));
557 let read = read_patch(&entry, dir.path()).unwrap();
558 assert_eq!(read, patch_text);
559 remove_patch(&entry, dir.path()).unwrap();
560 assert!(!has_patch(&entry, dir.path()));
561 }
562
563 #[test]
564 fn has_dir_patch_detects_patches() {
565 let dir = tempfile::tempdir().unwrap();
566 let entry = github_entry("lang-pro", EntityType::Skill);
567 assert!(!has_dir_patch(&entry, dir.path()));
568 write_dir_patch(&entry, "python.md", "patch content", dir.path()).unwrap();
569 assert!(has_dir_patch(&entry, dir.path()));
570 }
571
572 #[test]
573 fn remove_all_dir_patches_clears_dir() {
574 let dir = tempfile::tempdir().unwrap();
575 let entry = github_entry("lang-pro", EntityType::Skill);
576 write_dir_patch(&entry, "python.md", "p1", dir.path()).unwrap();
577 write_dir_patch(&entry, "typescript.md", "p2", dir.path()).unwrap();
578 assert!(has_dir_patch(&entry, dir.path()));
579 remove_all_dir_patches(&entry, dir.path()).unwrap();
580 assert!(!has_dir_patch(&entry, dir.path()));
581 }
582
583 #[test]
586 fn remove_patch_nonexistent_is_noop() {
587 let dir = tempfile::tempdir().unwrap();
588 let entry = github_entry("ghost-agent", EntityType::Agent);
589 assert!(!has_patch(&entry, dir.path()));
591 remove_patch(&entry, dir.path()).unwrap();
592 assert!(!has_patch(&entry, dir.path()));
593 }
594
595 #[test]
598 fn remove_patch_cleans_up_empty_parent_dir() {
599 let dir = tempfile::tempdir().unwrap();
600 let entry = github_entry("solo-skill", EntityType::Skill);
601 write_patch(&entry, "some patch text\n", dir.path()).unwrap();
602
603 let parent = patches_root(dir.path()).join("skills");
605 assert!(parent.is_dir(), "parent dir should exist after write_patch");
606
607 remove_patch(&entry, dir.path()).unwrap();
608
609 assert!(
611 !has_patch(&entry, dir.path()),
612 "patch file should be removed"
613 );
614 assert!(
615 !parent.exists(),
616 "empty parent dir should be removed after last patch is deleted"
617 );
618 }
619
620 #[test]
623 fn remove_patch_keeps_parent_dir_when_nonempty() {
624 let dir = tempfile::tempdir().unwrap();
625 let entry_a = github_entry("skill-a", EntityType::Skill);
626 let entry_b = github_entry("skill-b", EntityType::Skill);
627 write_patch(&entry_a, "patch a\n", dir.path()).unwrap();
628 write_patch(&entry_b, "patch b\n", dir.path()).unwrap();
629
630 let parent = patches_root(dir.path()).join("skills");
631 remove_patch(&entry_a, dir.path()).unwrap();
632
633 assert!(
635 parent.is_dir(),
636 "parent dir must survive when another patch still exists"
637 );
638 assert!(has_patch(&entry_b, dir.path()));
639 }
640
641 #[test]
644 fn remove_dir_patch_nonexistent_is_noop() {
645 let dir = tempfile::tempdir().unwrap();
646 let entry = github_entry("ghost-skill", EntityType::Skill);
647 remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
649 }
650
651 #[test]
654 fn remove_dir_patch_cleans_up_empty_entry_dir() {
655 let dir = tempfile::tempdir().unwrap();
656 let entry = github_entry("lang-pro", EntityType::Skill);
657 write_dir_patch(&entry, "python.md", "patch text\n", dir.path()).unwrap();
658
659 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
661 assert!(
662 entry_dir.is_dir(),
663 "entry dir should exist after write_dir_patch"
664 );
665
666 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
667
668 assert!(
670 !entry_dir.exists(),
671 "entry dir should be removed when it becomes empty"
672 );
673 }
674
675 #[test]
678 fn remove_dir_patch_keeps_entry_dir_when_nonempty() {
679 let dir = tempfile::tempdir().unwrap();
680 let entry = github_entry("lang-pro", EntityType::Skill);
681 write_dir_patch(&entry, "python.md", "p1\n", dir.path()).unwrap();
682 write_dir_patch(&entry, "typescript.md", "p2\n", dir.path()).unwrap();
683
684 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
685 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
686
687 assert!(
689 entry_dir.is_dir(),
690 "entry dir must survive when another patch still exists"
691 );
692 }
693
694 #[test]
697 fn generate_patch_no_trailing_newline_original() {
698 let p = generate_patch("old text", "new text\n", "test.md");
700 assert!(!p.is_empty(), "patch should not be empty");
701 for seg in p.split_inclusive('\n') {
702 assert!(
703 seg.ends_with('\n'),
704 "every output line must end with \\n, got: {seg:?}"
705 );
706 }
707 }
708
709 #[test]
710 fn generate_patch_no_trailing_newline_modified() {
711 let p = generate_patch("old text\n", "new text", "test.md");
713 assert!(!p.is_empty(), "patch should not be empty");
714 for seg in p.split_inclusive('\n') {
715 assert!(
716 seg.ends_with('\n'),
717 "every output line must end with \\n, got: {seg:?}"
718 );
719 }
720 }
721
722 #[test]
723 fn generate_patch_both_inputs_no_trailing_newline() {
724 let p = generate_patch("old line", "new line", "test.md");
726 assert!(!p.is_empty(), "patch should not be empty");
727 for seg in p.split_inclusive('\n') {
728 assert!(
729 seg.ends_with('\n'),
730 "every output line must end with \\n, got: {seg:?}"
731 );
732 }
733 }
734
735 #[test]
736 fn generate_patch_no_trailing_newline_roundtrip() {
737 let orig = "line one\nline two";
740 let modified = "line one\nline changed";
741 let patch = generate_patch(orig, modified, "test.md");
742 assert!(!patch.is_empty());
743 let result = apply_patch_pure(orig, &patch).unwrap();
745 assert_eq!(
748 result.trim_end_matches('\n'),
749 modified.trim_end_matches('\n')
750 );
751 }
752
753 #[test]
756 fn apply_patch_pure_with_no_newline_marker() {
757 let orig = "line1\nline2\n";
760 let patch = concat!(
761 "--- a/test.md\n",
762 "+++ b/test.md\n",
763 "@@ -1,2 +1,2 @@\n",
764 " line1\n",
765 "-line2\n",
766 "+changed\n",
767 "\\ No newline at end of file\n",
768 );
769 let result = apply_patch_pure(orig, patch).unwrap();
770 assert_eq!(result, "line1\nchanged\n");
771 }
772
773 #[test]
776 fn walkdir_empty_directory_returns_empty() {
777 let dir = tempfile::tempdir().unwrap();
778 let files = walkdir(dir.path());
779 assert!(
780 files.is_empty(),
781 "walkdir of empty dir should return empty vec"
782 );
783 }
784
785 #[test]
786 fn walkdir_nonexistent_directory_returns_empty() {
787 let path = Path::new("/tmp/skillfile_test_does_not_exist_xyz_9999");
788 let files = walkdir(path);
789 assert!(
790 files.is_empty(),
791 "walkdir of non-existent dir should return empty vec"
792 );
793 }
794
795 #[test]
796 fn walkdir_nested_subdirectories() {
797 let dir = tempfile::tempdir().unwrap();
798 let sub = dir.path().join("sub");
799 std::fs::create_dir_all(&sub).unwrap();
800 std::fs::write(dir.path().join("top.txt"), "top").unwrap();
801 std::fs::write(sub.join("nested.txt"), "nested").unwrap();
802
803 let files = walkdir(dir.path());
804 assert_eq!(files.len(), 2, "should find both files");
805
806 let names: Vec<String> = files
807 .iter()
808 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
809 .collect();
810 assert!(names.contains(&"top.txt".to_string()));
811 assert!(names.contains(&"nested.txt".to_string()));
812 }
813
814 #[test]
815 fn walkdir_results_are_sorted() {
816 let dir = tempfile::tempdir().unwrap();
817 std::fs::write(dir.path().join("z.txt"), "z").unwrap();
818 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
819 std::fs::write(dir.path().join("m.txt"), "m").unwrap();
820
821 let files = walkdir(dir.path());
822 let sorted = {
823 let mut v = files.clone();
824 v.sort();
825 v
826 };
827 assert_eq!(files, sorted, "walkdir results must be sorted");
828 }
829
830 #[test]
833 fn apply_patch_pure_fuzzy_hunk_matching() {
834 let orig: String = (1..=20).map(|i| format!("line{i}\n")).collect();
836
837 let patch = concat!(
841 "--- a/test.md\n",
842 "+++ b/test.md\n",
843 "@@ -5,3 +5,3 @@\n", " line7\n",
845 "-line8\n",
846 "+CHANGED8\n",
847 " line9\n",
848 );
849
850 let result = apply_patch_pure(&orig, patch).unwrap();
851 assert!(
852 result.contains("CHANGED8\n"),
853 "fuzzy match should have applied the change"
854 );
855 assert!(
856 !result.contains("line8\n"),
857 "original line8 should have been replaced"
858 );
859 }
860
861 #[test]
864 fn apply_patch_pure_extends_beyond_eof_errors() {
865 let orig = "line1\nline2\n";
874 let patch = concat!(
875 "--- a/test.md\n",
876 "+++ b/test.md\n",
877 "@@ -999,1 +999,1 @@\n",
878 "-nonexistent_line\n",
879 "+replacement\n",
880 );
881 let result = apply_patch_pure(orig, patch);
882 assert!(
883 result.is_err(),
884 "applying a patch beyond EOF should return an error"
885 );
886 }
887}