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 remove_empty_parent(&p);
62 Ok(())
63}
64
65pub fn dir_patch_path(entry: &Entry, filename: &str, repo_root: &Path) -> PathBuf {
72 patches_root(repo_root)
73 .join(entry.entity_type.dir_name())
74 .join(&entry.name)
75 .join(format!("{filename}.patch"))
76}
77
78#[must_use]
80pub fn has_dir_patch(entry: &Entry, repo_root: &Path) -> bool {
81 let d = patches_root(repo_root)
82 .join(entry.entity_type.dir_name())
83 .join(&entry.name);
84 if !d.is_dir() {
85 return false;
86 }
87 walkdir(&d)
88 .into_iter()
89 .any(|p| p.extension().is_some_and(|e| e == "patch"))
90}
91
92pub fn write_dir_patch(patch_path: &Path, patch_text: &str) -> Result<(), SkillfileError> {
99 if let Some(parent) = patch_path.parent() {
100 std::fs::create_dir_all(parent)?;
101 }
102 std::fs::write(patch_path, patch_text)?;
103 Ok(())
104}
105
106pub fn remove_dir_patch(
107 entry: &Entry,
108 filename: &str,
109 repo_root: &Path,
110) -> Result<(), SkillfileError> {
111 let p = dir_patch_path(entry, filename, repo_root);
112 if !p.exists() {
113 return Ok(());
114 }
115 std::fs::remove_file(&p)?;
116 remove_empty_parent(&p);
117 Ok(())
118}
119
120pub fn remove_all_dir_patches(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
121 let d = patches_root(repo_root)
122 .join(entry.entity_type.dir_name())
123 .join(&entry.name);
124 if d.is_dir() {
125 std::fs::remove_dir_all(&d)?;
126 }
127 Ok(())
128}
129
130fn remove_empty_parent(path: &Path) {
136 let Some(parent) = path.parent() else {
137 return;
138 };
139 if !parent.exists() {
140 return;
141 }
142 let is_empty = std::fs::read_dir(parent)
143 .map(|mut rd| rd.next().is_none())
144 .unwrap_or(true);
145 if is_empty {
146 let _ = std::fs::remove_dir(parent);
147 }
148}
149
150pub fn generate_patch(original: &str, modified: &str, label: &str) -> String {
170 if original == modified {
171 return String::new();
172 }
173
174 let diff = similar::TextDiff::from_lines(original, modified);
175 let raw = format!(
176 "{}",
177 diff.unified_diff()
178 .context_radius(3)
179 .header(&format!("a/{label}"), &format!("b/{label}"))
180 );
181
182 if raw.is_empty() {
183 return String::new();
184 }
185
186 let mut result = String::new();
189 for line in raw.split_inclusive('\n') {
190 normalize_diff_line(line, &mut result);
191 }
192
193 result
194}
195
196fn normalize_diff_line(line: &str, result: &mut String) {
202 if line.starts_with("\\ ") {
203 if !result.ends_with('\n') {
205 result.push('\n');
206 }
207 return;
208 }
209 result.push_str(line);
210 if !line.ends_with('\n') {
211 result.push('\n');
212 }
213}
214
215struct Hunk {
220 orig_start: usize, body: Vec<String>,
222}
223
224fn parse_hunks(patch_text: &str) -> Result<Vec<Hunk>, SkillfileError> {
225 let lines: Vec<&str> = patch_text.split_inclusive('\n').collect();
226 let mut pi = 0;
227
228 while pi < lines.len() && (lines[pi].starts_with("--- ") || lines[pi].starts_with("+++ ")) {
230 pi += 1;
231 }
232
233 let mut hunks: Vec<Hunk> = Vec::new();
234
235 while pi < lines.len() {
236 let pl = lines[pi];
237 if !pl.starts_with("@@ ") {
238 pi += 1;
239 continue;
240 }
241
242 let orig_start = pl
245 .split_whitespace()
246 .nth(1) .and_then(|s| s.trim_start_matches('-').split(',').next())
248 .and_then(|n| n.parse::<usize>().ok())
249 .ok_or_else(|| SkillfileError::Manifest(format!("malformed hunk header: {pl:?}")))?;
250
251 pi += 1;
252 let body = collect_hunk_body(&lines, &mut pi);
253
254 hunks.push(Hunk { orig_start, body });
255 }
256
257 Ok(hunks)
258}
259
260fn collect_hunk_body(lines: &[&str], pi: &mut usize) -> Vec<String> {
264 let mut body: Vec<String> = Vec::new();
265 while *pi < lines.len() {
266 let hl = lines[*pi];
267 if hl.starts_with("@@ ") || hl.starts_with("--- ") || hl.starts_with("+++ ") {
268 break;
269 }
270 if hl.starts_with("\\ ") {
271 *pi += 1;
273 continue;
274 }
275 body.push(hl.to_string());
276 *pi += 1;
277 }
278 body
279}
280
281fn try_hunk_at(lines: &[String], start: usize, ctx_lines: &[&str]) -> bool {
282 if start + ctx_lines.len() > lines.len() {
283 return false;
284 }
285 for (i, expected) in ctx_lines.iter().enumerate() {
286 if lines[start + i].trim_end_matches('\n') != *expected {
287 return false;
288 }
289 }
290 true
291}
292
293struct HunkSearch<'a> {
295 lines: &'a [String],
296 min_pos: usize,
297}
298
299impl HunkSearch<'_> {
300 fn search_nearby(&self, center: usize, ctx_lines: &[&str]) -> Option<usize> {
302 (1..100usize)
303 .flat_map(|delta| [Some(center + delta), center.checked_sub(delta)])
304 .flatten()
305 .filter(|&c| c >= self.min_pos && c <= self.lines.len())
306 .find(|&c| try_hunk_at(self.lines, c, ctx_lines))
307 }
308}
309
310fn find_hunk_position(
311 ctx: &HunkSearch<'_>,
312 hunk_start: usize,
313 ctx_lines: &[&str],
314) -> Result<usize, SkillfileError> {
315 if try_hunk_at(ctx.lines, hunk_start, ctx_lines) {
316 return Ok(hunk_start);
317 }
318
319 if let Some(pos) = ctx.search_nearby(hunk_start, ctx_lines) {
320 return Ok(pos);
321 }
322
323 if !ctx_lines.is_empty() {
324 return Err(SkillfileError::Manifest(format!(
325 "context mismatch: cannot find context starting with {:?} near line {}",
326 ctx_lines[0],
327 hunk_start + 1
328 )));
329 }
330 Err(SkillfileError::Manifest(
331 "patch extends beyond end of file".into(),
332 ))
333}
334
335struct PatchState<'a> {
337 lines: &'a [String],
338 output: Vec<String>,
339 pos: usize,
340}
341
342impl<'a> PatchState<'a> {
343 fn new(lines: &'a [String]) -> Self {
344 Self {
345 lines,
346 output: Vec::new(),
347 pos: 0,
348 }
349 }
350
351 fn apply_line(&mut self, hl: &str) {
353 let Some(prefix) = hl.as_bytes().first() else {
354 return;
355 };
356 match prefix {
357 b' ' if self.pos < self.lines.len() => {
358 self.output.push(self.lines[self.pos].clone());
359 self.pos += 1;
360 }
361 b'-' => self.pos += 1,
362 b'+' => self.output.push(hl[1..].to_string()),
363 _ => {} }
365 }
366
367 fn apply_hunk(&mut self, hunk: &Hunk) {
369 for hl in &hunk.body {
370 self.apply_line(hl);
371 }
372 }
373}
374
375pub fn apply_patch_pure(original: &str, patch_text: &str) -> Result<String, SkillfileError> {
390 if patch_text.is_empty() {
391 return Ok(original.to_string());
392 }
393
394 let lines: Vec<String> = original
396 .split_inclusive('\n')
397 .map(std::string::ToString::to_string)
398 .collect();
399
400 let mut state = PatchState::new(&lines);
401
402 for hunk in parse_hunks(patch_text)? {
403 let ctx_lines: Vec<&str> = hunk
405 .body
406 .iter()
407 .filter(|hl| !hl.is_empty() && (hl.starts_with(' ') || hl.starts_with('-')))
408 .map(|hl| hl[1..].trim_end_matches('\n'))
409 .collect();
410
411 let search = HunkSearch {
412 lines: &lines,
413 min_pos: state.pos,
414 };
415 let hunk_start =
416 find_hunk_position(&search, hunk.orig_start.saturating_sub(1), &ctx_lines)?;
417
418 state
420 .output
421 .extend_from_slice(&lines[state.pos..hunk_start]);
422 state.pos = hunk_start;
423
424 state.apply_hunk(&hunk);
425 }
426
427 state.output.extend_from_slice(&lines[state.pos..]);
429 Ok(state.output.concat())
430}
431
432#[must_use]
438pub fn walkdir(dir: &Path) -> Vec<PathBuf> {
439 let mut result = Vec::new();
440 walkdir_inner(dir, &mut result);
441 result.sort();
442 result
443}
444
445fn walkdir_inner(dir: &Path, result: &mut Vec<PathBuf>) {
446 let Ok(entries) = std::fs::read_dir(dir) else {
447 return;
448 };
449 for entry in entries.flatten() {
450 let path = entry.path();
451 if path.is_dir() {
452 walkdir_inner(&path, result);
453 } else {
454 result.push(path);
455 }
456 }
457}
458
459#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::models::{EntityType, SourceFields};
467
468 fn github_entry(name: &str, entity_type: EntityType) -> Entry {
469 Entry {
470 entity_type,
471 name: name.to_string(),
472 source: SourceFields::Github {
473 owner_repo: "owner/repo".into(),
474 path_in_repo: "agents/test.md".into(),
475 ref_: "main".into(),
476 },
477 }
478 }
479
480 #[test]
483 fn generate_patch_identical_returns_empty() {
484 assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
485 }
486
487 #[test]
488 fn generate_patch_has_headers() {
489 let p = generate_patch("old\n", "new\n", "test.md");
490 assert!(p.contains("--- a/test.md"), "missing fromfile header");
491 assert!(p.contains("+++ b/test.md"), "missing tofile header");
492 }
493
494 #[test]
495 fn generate_patch_add_line() {
496 let p = generate_patch("line1\n", "line1\nline2\n", "test.md");
497 assert!(p.contains("+line2"));
498 }
499
500 #[test]
501 fn generate_patch_remove_line() {
502 let p = generate_patch("line1\nline2\n", "line1\n", "test.md");
503 assert!(p.contains("-line2"));
504 }
505
506 #[test]
507 fn generate_patch_all_lines_end_with_newline() {
508 let p = generate_patch("a\nb\n", "a\nc\n", "test.md");
509 for seg in p.split_inclusive('\n') {
510 assert!(seg.ends_with('\n'), "line does not end with \\n: {seg:?}");
511 }
512 }
513
514 #[test]
517 fn apply_patch_empty_patch_returns_original() {
518 let result = apply_patch_pure("hello\n", "").unwrap();
519 assert_eq!(result, "hello\n");
520 }
521
522 #[test]
523 fn apply_patch_round_trip_add_line() {
524 let orig = "line1\nline2\n";
525 let modified = "line1\nline2\nline3\n";
526 let patch = generate_patch(orig, modified, "test.md");
527 let result = apply_patch_pure(orig, &patch).unwrap();
528 assert_eq!(result, modified);
529 }
530
531 #[test]
532 fn apply_patch_round_trip_remove_line() {
533 let orig = "line1\nline2\nline3\n";
534 let modified = "line1\nline3\n";
535 let patch = generate_patch(orig, modified, "test.md");
536 let result = apply_patch_pure(orig, &patch).unwrap();
537 assert_eq!(result, modified);
538 }
539
540 #[test]
541 fn apply_patch_round_trip_modify_line() {
542 let orig = "# Title\n\nSome text here.\n";
543 let modified = "# Title\n\nSome modified text here.\n";
544 let patch = generate_patch(orig, modified, "test.md");
545 let result = apply_patch_pure(orig, &patch).unwrap();
546 assert_eq!(result, modified);
547 }
548
549 #[test]
550 fn apply_patch_multi_hunk() {
551 use std::fmt::Write;
552 let mut orig = String::new();
553 for i in 0..20 {
554 let _ = writeln!(orig, "line{i}");
555 }
556 let mut modified = orig.clone();
557 modified = modified.replace("line2\n", "MODIFIED2\n");
558 modified = modified.replace("line15\n", "MODIFIED15\n");
559 let patch = generate_patch(&orig, &modified, "test.md");
560 assert!(patch.contains("@@"), "should have hunk headers");
561 let result = apply_patch_pure(&orig, &patch).unwrap();
562 assert_eq!(result, modified);
563 }
564
565 #[test]
566 fn apply_patch_context_mismatch_errors() {
567 let orig = "line1\nline2\n";
568 let patch = "--- a/test.md\n+++ b/test.md\n@@ -1,2 +1,2 @@\n-totally_wrong\n+new\n";
569 let result = apply_patch_pure(orig, patch);
570 assert!(result.is_err());
571 assert!(result.unwrap_err().to_string().contains("context mismatch"));
572 }
573
574 #[test]
577 fn patch_path_single_file_agent() {
578 let entry = github_entry("my-agent", EntityType::Agent);
579 let root = Path::new("/repo");
580 let p = patch_path(&entry, root);
581 assert_eq!(
582 p,
583 Path::new("/repo/.skillfile/patches/agents/my-agent.patch")
584 );
585 }
586
587 #[test]
588 fn patch_path_single_file_skill() {
589 let entry = github_entry("my-skill", EntityType::Skill);
590 let root = Path::new("/repo");
591 let p = patch_path(&entry, root);
592 assert_eq!(
593 p,
594 Path::new("/repo/.skillfile/patches/skills/my-skill.patch")
595 );
596 }
597
598 #[test]
599 fn dir_patch_path_returns_correct() {
600 let entry = github_entry("lang-pro", EntityType::Skill);
601 let root = Path::new("/repo");
602 let p = dir_patch_path(&entry, "python.md", root);
603 assert_eq!(
604 p,
605 Path::new("/repo/.skillfile/patches/skills/lang-pro/python.md.patch")
606 );
607 }
608
609 #[test]
610 fn write_read_remove_patch_round_trip() {
611 let dir = tempfile::tempdir().unwrap();
612 let entry = github_entry("test-agent", EntityType::Agent);
613 let patch_text = "--- a/test-agent.md\n+++ b/test-agent.md\n@@ -1 +1 @@\n-old\n+new\n";
614 write_patch(&entry, patch_text, dir.path()).unwrap();
615 assert!(has_patch(&entry, dir.path()));
616 let read = read_patch(&entry, dir.path()).unwrap();
617 assert_eq!(read, patch_text);
618 remove_patch(&entry, dir.path()).unwrap();
619 assert!(!has_patch(&entry, dir.path()));
620 }
621
622 #[test]
623 fn has_dir_patch_detects_patches() {
624 let dir = tempfile::tempdir().unwrap();
625 let entry = github_entry("lang-pro", EntityType::Skill);
626 assert!(!has_dir_patch(&entry, dir.path()));
627 write_dir_patch(
628 &dir_patch_path(&entry, "python.md", dir.path()),
629 "patch content",
630 )
631 .unwrap();
632 assert!(has_dir_patch(&entry, dir.path()));
633 }
634
635 #[test]
636 fn remove_all_dir_patches_clears_dir() {
637 let dir = tempfile::tempdir().unwrap();
638 let entry = github_entry("lang-pro", EntityType::Skill);
639 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1").unwrap();
640 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2").unwrap();
641 assert!(has_dir_patch(&entry, dir.path()));
642 remove_all_dir_patches(&entry, dir.path()).unwrap();
643 assert!(!has_dir_patch(&entry, dir.path()));
644 }
645
646 #[test]
649 fn remove_patch_nonexistent_is_noop() {
650 let dir = tempfile::tempdir().unwrap();
651 let entry = github_entry("ghost-agent", EntityType::Agent);
652 assert!(!has_patch(&entry, dir.path()));
654 remove_patch(&entry, dir.path()).unwrap();
655 assert!(!has_patch(&entry, dir.path()));
656 }
657
658 #[test]
661 fn remove_patch_cleans_up_empty_parent_dir() {
662 let dir = tempfile::tempdir().unwrap();
663 let entry = github_entry("solo-skill", EntityType::Skill);
664 write_patch(&entry, "some patch text\n", dir.path()).unwrap();
665
666 let parent = patches_root(dir.path()).join("skills");
668 assert!(parent.is_dir(), "parent dir should exist after write_patch");
669
670 remove_patch(&entry, dir.path()).unwrap();
671
672 assert!(
674 !has_patch(&entry, dir.path()),
675 "patch file should be removed"
676 );
677 assert!(
678 !parent.exists(),
679 "empty parent dir should be removed after last patch is deleted"
680 );
681 }
682
683 #[test]
686 fn remove_patch_keeps_parent_dir_when_nonempty() {
687 let dir = tempfile::tempdir().unwrap();
688 let entry_a = github_entry("skill-a", EntityType::Skill);
689 let entry_b = github_entry("skill-b", EntityType::Skill);
690 write_patch(&entry_a, "patch a\n", dir.path()).unwrap();
691 write_patch(&entry_b, "patch b\n", dir.path()).unwrap();
692
693 let parent = patches_root(dir.path()).join("skills");
694 remove_patch(&entry_a, dir.path()).unwrap();
695
696 assert!(
698 parent.is_dir(),
699 "parent dir must survive when another patch still exists"
700 );
701 assert!(has_patch(&entry_b, dir.path()));
702 }
703
704 #[test]
707 fn remove_dir_patch_nonexistent_is_noop() {
708 let dir = tempfile::tempdir().unwrap();
709 let entry = github_entry("ghost-skill", EntityType::Skill);
710 remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
712 }
713
714 #[test]
717 fn remove_dir_patch_cleans_up_empty_entry_dir() {
718 let dir = tempfile::tempdir().unwrap();
719 let entry = github_entry("lang-pro", EntityType::Skill);
720 write_dir_patch(
721 &dir_patch_path(&entry, "python.md", dir.path()),
722 "patch text\n",
723 )
724 .unwrap();
725
726 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
728 assert!(
729 entry_dir.is_dir(),
730 "entry dir should exist after write_dir_patch"
731 );
732
733 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
734
735 assert!(
737 !entry_dir.exists(),
738 "entry dir should be removed when it becomes empty"
739 );
740 }
741
742 #[test]
745 fn remove_dir_patch_keeps_entry_dir_when_nonempty() {
746 let dir = tempfile::tempdir().unwrap();
747 let entry = github_entry("lang-pro", EntityType::Skill);
748 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1\n").unwrap();
749 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2\n").unwrap();
750
751 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
752 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
753
754 assert!(
756 entry_dir.is_dir(),
757 "entry dir must survive when another patch still exists"
758 );
759 }
760
761 #[test]
764 fn generate_patch_no_trailing_newline_original() {
765 let p = generate_patch("old text", "new text\n", "test.md");
767 assert!(!p.is_empty(), "patch should not be empty");
768 for seg in p.split_inclusive('\n') {
769 assert!(
770 seg.ends_with('\n'),
771 "every output line must end with \\n, got: {seg:?}"
772 );
773 }
774 }
775
776 #[test]
777 fn generate_patch_no_trailing_newline_modified() {
778 let p = generate_patch("old text\n", "new text", "test.md");
780 assert!(!p.is_empty(), "patch should not be empty");
781 for seg in p.split_inclusive('\n') {
782 assert!(
783 seg.ends_with('\n'),
784 "every output line must end with \\n, got: {seg:?}"
785 );
786 }
787 }
788
789 #[test]
790 fn generate_patch_both_inputs_no_trailing_newline() {
791 let p = generate_patch("old line", "new line", "test.md");
793 assert!(!p.is_empty(), "patch should not be empty");
794 for seg in p.split_inclusive('\n') {
795 assert!(
796 seg.ends_with('\n'),
797 "every output line must end with \\n, got: {seg:?}"
798 );
799 }
800 }
801
802 #[test]
803 fn generate_patch_no_trailing_newline_roundtrip() {
804 let orig = "line one\nline two";
807 let modified = "line one\nline changed";
808 let patch = generate_patch(orig, modified, "test.md");
809 assert!(!patch.is_empty());
810 let result = apply_patch_pure(orig, &patch).unwrap();
812 assert_eq!(
815 result.trim_end_matches('\n'),
816 modified.trim_end_matches('\n')
817 );
818 }
819
820 #[test]
823 fn apply_patch_pure_with_no_newline_marker() {
824 let orig = "line1\nline2\n";
827 let patch = concat!(
828 "--- a/test.md\n",
829 "+++ b/test.md\n",
830 "@@ -1,2 +1,2 @@\n",
831 " line1\n",
832 "-line2\n",
833 "+changed\n",
834 "\\ No newline at end of file\n",
835 );
836 let result = apply_patch_pure(orig, patch).unwrap();
837 assert_eq!(result, "line1\nchanged\n");
838 }
839
840 #[test]
843 fn walkdir_empty_directory_returns_empty() {
844 let dir = tempfile::tempdir().unwrap();
845 let files = walkdir(dir.path());
846 assert!(
847 files.is_empty(),
848 "walkdir of empty dir should return empty vec"
849 );
850 }
851
852 #[test]
853 fn walkdir_nonexistent_directory_returns_empty() {
854 let path = Path::new("/tmp/skillfile_test_does_not_exist_xyz_9999");
855 let files = walkdir(path);
856 assert!(
857 files.is_empty(),
858 "walkdir of non-existent dir should return empty vec"
859 );
860 }
861
862 #[test]
863 fn walkdir_nested_subdirectories() {
864 let dir = tempfile::tempdir().unwrap();
865 let sub = dir.path().join("sub");
866 std::fs::create_dir_all(&sub).unwrap();
867 std::fs::write(dir.path().join("top.txt"), "top").unwrap();
868 std::fs::write(sub.join("nested.txt"), "nested").unwrap();
869
870 let files = walkdir(dir.path());
871 assert_eq!(files.len(), 2, "should find both files");
872
873 let names: Vec<String> = files
874 .iter()
875 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
876 .collect();
877 assert!(names.contains(&"top.txt".to_string()));
878 assert!(names.contains(&"nested.txt".to_string()));
879 }
880
881 #[test]
882 fn walkdir_results_are_sorted() {
883 let dir = tempfile::tempdir().unwrap();
884 std::fs::write(dir.path().join("z.txt"), "z").unwrap();
885 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
886 std::fs::write(dir.path().join("m.txt"), "m").unwrap();
887
888 let files = walkdir(dir.path());
889 let sorted = {
890 let mut v = files.clone();
891 v.sort();
892 v
893 };
894 assert_eq!(files, sorted, "walkdir results must be sorted");
895 }
896
897 #[test]
900 fn apply_patch_pure_fuzzy_hunk_matching() {
901 use std::fmt::Write;
902 let mut orig = String::new();
904 for i in 1..=20 {
905 let _ = writeln!(orig, "line{i}");
906 }
907
908 let patch = concat!(
912 "--- a/test.md\n",
913 "+++ b/test.md\n",
914 "@@ -5,3 +5,3 @@\n", " line7\n",
916 "-line8\n",
917 "+CHANGED8\n",
918 " line9\n",
919 );
920
921 let result = apply_patch_pure(&orig, patch).unwrap();
922 assert!(
923 result.contains("CHANGED8\n"),
924 "fuzzy match should have applied the change"
925 );
926 assert!(
927 !result.contains("line8\n"),
928 "original line8 should have been replaced"
929 );
930 }
931
932 #[test]
935 fn apply_patch_pure_extends_beyond_eof_errors() {
936 let orig = "line1\nline2\n";
945 let patch = concat!(
946 "--- a/test.md\n",
947 "+++ b/test.md\n",
948 "@@ -999,1 +999,1 @@\n",
949 "-nonexistent_line\n",
950 "+replacement\n",
951 );
952 let result = apply_patch_pure(orig, patch);
953 assert!(
954 result.is_err(),
955 "applying a patch beyond EOF should return an error"
956 );
957 }
958}