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]
13pub fn patches_root(repo_root: &Path) -> PathBuf {
14 repo_root.join(PATCHES_DIR)
15}
16
17pub fn patch_path(entry: &Entry, repo_root: &Path) -> PathBuf {
20 patches_root(repo_root)
21 .join(entry.entity_type.dir_name())
22 .join(format!("{}.patch", entry.name))
23}
24
25#[must_use]
26pub fn has_patch(entry: &Entry, repo_root: &Path) -> bool {
27 patch_path(entry, repo_root).exists()
28}
29
30pub fn write_patch(
31 entry: &Entry,
32 patch_text: &str,
33 repo_root: &Path,
34) -> Result<(), SkillfileError> {
35 let p = patch_path(entry, repo_root);
36 if let Some(parent) = p.parent() {
37 std::fs::create_dir_all(parent)?;
38 }
39 std::fs::write(&p, patch_text)?;
40 Ok(())
41}
42
43pub fn read_patch(entry: &Entry, repo_root: &Path) -> Result<String, SkillfileError> {
44 let p = patch_path(entry, repo_root);
45 Ok(std::fs::read_to_string(&p)?)
46}
47
48pub fn remove_patch(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
50 let p = patch_path(entry, repo_root);
51 if !p.exists() {
52 return Ok(());
53 }
54 std::fs::remove_file(&p)?;
55 remove_empty_parent(&p);
56 Ok(())
57}
58
59pub fn dir_patch_path(entry: &Entry, filename: &str, repo_root: &Path) -> PathBuf {
66 patches_root(repo_root)
67 .join(entry.entity_type.dir_name())
68 .join(&entry.name)
69 .join(format!("{filename}.patch"))
70}
71
72#[must_use]
73pub fn has_dir_patch(entry: &Entry, repo_root: &Path) -> bool {
74 let d = patches_root(repo_root)
75 .join(entry.entity_type.dir_name())
76 .join(&entry.name);
77 if !d.is_dir() {
78 return false;
79 }
80 walkdir(&d)
81 .into_iter()
82 .any(|p| p.extension().is_some_and(|e| e == "patch"))
83}
84
85pub fn write_dir_patch(patch_path: &Path, patch_text: &str) -> Result<(), SkillfileError> {
86 if let Some(parent) = patch_path.parent() {
87 std::fs::create_dir_all(parent)?;
88 }
89 std::fs::write(patch_path, patch_text)?;
90 Ok(())
91}
92
93pub fn remove_dir_patch(
94 entry: &Entry,
95 filename: &str,
96 repo_root: &Path,
97) -> Result<(), SkillfileError> {
98 let p = dir_patch_path(entry, filename, repo_root);
99 if !p.exists() {
100 return Ok(());
101 }
102 std::fs::remove_file(&p)?;
103 remove_empty_parent(&p);
104 Ok(())
105}
106
107pub fn remove_all_dir_patches(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
108 let d = patches_root(repo_root)
109 .join(entry.entity_type.dir_name())
110 .join(&entry.name);
111 if d.is_dir() {
112 std::fs::remove_dir_all(&d)?;
113 }
114 Ok(())
115}
116
117fn remove_empty_parent(path: &Path) {
123 let Some(parent) = path.parent() else {
124 return;
125 };
126 if !parent.exists() {
127 return;
128 }
129 let is_empty = std::fs::read_dir(parent).map_or(true, |mut rd| rd.next().is_none());
130 if is_empty {
131 let _ = std::fs::remove_dir(parent);
132 }
133}
134
135pub fn generate_patch(original: &str, modified: &str, label: &str) -> String {
155 if original == modified {
156 return String::new();
157 }
158
159 let diff = similar::TextDiff::from_lines(original, modified);
160 let raw = format!(
161 "{}",
162 diff.unified_diff()
163 .context_radius(3)
164 .header(&format!("a/{label}"), &format!("b/{label}"))
165 );
166
167 if raw.is_empty() {
168 return String::new();
169 }
170
171 let mut result = String::new();
174 for line in raw.split_inclusive('\n') {
175 normalize_diff_line(line, &mut result);
176 }
177
178 result
179}
180
181fn normalize_diff_line(line: &str, result: &mut String) {
187 if line.starts_with("\\ ") {
188 if !result.ends_with('\n') {
190 result.push('\n');
191 }
192 return;
193 }
194 result.push_str(line);
195 if !line.ends_with('\n') {
196 result.push('\n');
197 }
198}
199
200struct Hunk {
205 orig_start: usize, body: Vec<String>,
207}
208
209fn parse_hunks(patch_text: &str) -> Result<Vec<Hunk>, SkillfileError> {
210 let lines: Vec<&str> = patch_text.split_inclusive('\n').collect();
211 let mut pi = 0;
212
213 while pi < lines.len() && (lines[pi].starts_with("--- ") || lines[pi].starts_with("+++ ")) {
215 pi += 1;
216 }
217
218 let mut hunks: Vec<Hunk> = Vec::new();
219
220 while pi < lines.len() {
221 let pl = lines[pi];
222 if !pl.starts_with("@@ ") {
223 pi += 1;
224 continue;
225 }
226
227 let orig_start = pl
230 .split_whitespace()
231 .nth(1) .and_then(|s| s.trim_start_matches('-').split(',').next())
233 .and_then(|n| n.parse::<usize>().ok())
234 .ok_or_else(|| SkillfileError::Manifest(format!("malformed hunk header: {pl:?}")))?;
235
236 pi += 1;
237 let body = collect_hunk_body(&lines, &mut pi);
238
239 hunks.push(Hunk { orig_start, body });
240 }
241
242 Ok(hunks)
243}
244
245fn collect_hunk_body(lines: &[&str], pi: &mut usize) -> Vec<String> {
246 let mut body: Vec<String> = Vec::new();
247 while *pi < lines.len() {
248 let hl = lines[*pi];
249 if hl.starts_with("@@ ") || hl.starts_with("--- ") || hl.starts_with("+++ ") {
250 break;
251 }
252 if hl.starts_with("\\ ") {
253 *pi += 1;
255 continue;
256 }
257 body.push(hl.to_string());
258 *pi += 1;
259 }
260 body
261}
262
263fn try_hunk_at(lines: &[String], start: usize, ctx_lines: &[&str]) -> bool {
264 if start + ctx_lines.len() > lines.len() {
265 return false;
266 }
267 for (i, expected) in ctx_lines.iter().enumerate() {
268 if lines[start + i].trim_end_matches(['\n', '\r']) != *expected {
269 return false;
270 }
271 }
272 true
273}
274
275struct HunkSearch<'a> {
277 lines: &'a [String],
278 min_pos: usize,
279}
280
281impl HunkSearch<'_> {
282 fn search_nearby(&self, center: usize, ctx_lines: &[&str]) -> Option<usize> {
284 (1..100usize)
285 .flat_map(|delta| [Some(center + delta), center.checked_sub(delta)])
286 .flatten()
287 .filter(|&c| c >= self.min_pos && c <= self.lines.len())
288 .find(|&c| try_hunk_at(self.lines, c, ctx_lines))
289 }
290}
291
292fn find_hunk_position(
293 ctx: &HunkSearch<'_>,
294 hunk_start: usize,
295 ctx_lines: &[&str],
296) -> Result<usize, SkillfileError> {
297 if try_hunk_at(ctx.lines, hunk_start, ctx_lines) {
298 return Ok(hunk_start);
299 }
300
301 if let Some(pos) = ctx.search_nearby(hunk_start, ctx_lines) {
302 return Ok(pos);
303 }
304
305 if !ctx_lines.is_empty() {
306 return Err(SkillfileError::Manifest(format!(
307 "context mismatch: cannot find context starting with {:?} near line {}",
308 ctx_lines[0],
309 hunk_start + 1
310 )));
311 }
312 Err(SkillfileError::Manifest(
313 "patch extends beyond end of file".into(),
314 ))
315}
316
317struct PatchState<'a> {
319 lines: &'a [String],
320 output: Vec<String>,
321 pos: usize,
322}
323
324impl<'a> PatchState<'a> {
325 fn new(lines: &'a [String]) -> Self {
326 Self {
327 lines,
328 output: Vec::new(),
329 pos: 0,
330 }
331 }
332
333 fn apply_line(&mut self, hl: &str) {
334 let Some(prefix) = hl.as_bytes().first() else {
335 return;
336 };
337 match prefix {
338 b' ' if self.pos < self.lines.len() => {
339 self.output.push(self.lines[self.pos].clone());
340 self.pos += 1;
341 }
342 b'-' => self.pos += 1,
343 b'+' => self.output.push(hl[1..].to_string()),
344 _ => {} }
346 }
347
348 fn apply_hunk(&mut self, hunk: &Hunk) {
349 for hl in &hunk.body {
350 self.apply_line(hl);
351 }
352 }
353}
354
355pub fn apply_patch_pure(original: &str, patch_text: &str) -> Result<String, SkillfileError> {
370 if patch_text.is_empty() {
371 return Ok(original.to_string());
372 }
373
374 let original = &original.replace("\r\n", "\n");
376 let patch_text = &patch_text.replace("\r\n", "\n");
377
378 let lines: Vec<String> = original
380 .split_inclusive('\n')
381 .map(ToString::to_string)
382 .collect();
383
384 let mut state = PatchState::new(&lines);
385
386 for hunk in parse_hunks(patch_text)? {
387 let ctx_lines: Vec<&str> = hunk
389 .body
390 .iter()
391 .filter(|hl| !hl.is_empty() && (hl.starts_with(' ') || hl.starts_with('-')))
392 .map(|hl| hl[1..].trim_end_matches('\n'))
393 .collect();
394
395 let search = HunkSearch {
396 lines: &lines,
397 min_pos: state.pos,
398 };
399 let hunk_start =
400 find_hunk_position(&search, hunk.orig_start.saturating_sub(1), &ctx_lines)?;
401
402 state
404 .output
405 .extend_from_slice(&lines[state.pos..hunk_start]);
406 state.pos = hunk_start;
407
408 state.apply_hunk(&hunk);
409 }
410
411 state.output.extend_from_slice(&lines[state.pos..]);
413 Ok(state.output.concat())
414}
415
416#[must_use]
422pub fn walkdir(dir: &Path) -> Vec<PathBuf> {
423 let mut result = Vec::new();
424 walkdir_inner(dir, &mut result);
425 result.sort();
426 result
427}
428
429fn walkdir_inner(dir: &Path, result: &mut Vec<PathBuf>) {
430 let Ok(entries) = std::fs::read_dir(dir) else {
431 return;
432 };
433 for entry in entries.flatten() {
434 let path = entry.path();
435 if path.is_dir() {
436 walkdir_inner(&path, result);
437 } else {
438 result.push(path);
439 }
440 }
441}
442
443#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::models::{EntityType, SourceFields};
451
452 fn github_entry(name: &str, entity_type: EntityType) -> Entry {
453 Entry {
454 entity_type,
455 name: name.to_string(),
456 source: SourceFields::Github {
457 owner_repo: "owner/repo".into(),
458 path_in_repo: "agents/test.md".into(),
459 ref_: "main".into(),
460 },
461 }
462 }
463
464 #[test]
467 fn generate_patch_identical_returns_empty() {
468 assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
469 }
470
471 #[test]
472 fn generate_patch_has_headers() {
473 let p = generate_patch("old\n", "new\n", "test.md");
474 assert!(p.contains("--- a/test.md"), "missing fromfile header");
475 assert!(p.contains("+++ b/test.md"), "missing tofile header");
476 }
477
478 #[test]
479 fn generate_patch_add_line() {
480 let p = generate_patch("line1\n", "line1\nline2\n", "test.md");
481 assert!(p.contains("+line2"));
482 }
483
484 #[test]
485 fn generate_patch_remove_line() {
486 let p = generate_patch("line1\nline2\n", "line1\n", "test.md");
487 assert!(p.contains("-line2"));
488 }
489
490 #[test]
491 fn generate_patch_all_lines_end_with_newline() {
492 let p = generate_patch("a\nb\n", "a\nc\n", "test.md");
493 for seg in p.split_inclusive('\n') {
494 assert!(seg.ends_with('\n'), "line does not end with \\n: {seg:?}");
495 }
496 }
497
498 #[test]
501 fn apply_patch_empty_patch_returns_original() {
502 let result = apply_patch_pure("hello\n", "").unwrap();
503 assert_eq!(result, "hello\n");
504 }
505
506 #[test]
507 fn apply_patch_round_trip_add_line() {
508 let orig = "line1\nline2\n";
509 let modified = "line1\nline2\nline3\n";
510 let patch = generate_patch(orig, modified, "test.md");
511 let result = apply_patch_pure(orig, &patch).unwrap();
512 assert_eq!(result, modified);
513 }
514
515 #[test]
516 fn apply_patch_round_trip_remove_line() {
517 let orig = "line1\nline2\nline3\n";
518 let modified = "line1\nline3\n";
519 let patch = generate_patch(orig, modified, "test.md");
520 let result = apply_patch_pure(orig, &patch).unwrap();
521 assert_eq!(result, modified);
522 }
523
524 #[test]
525 fn apply_patch_round_trip_modify_line() {
526 let orig = "# Title\n\nSome text here.\n";
527 let modified = "# Title\n\nSome modified text here.\n";
528 let patch = generate_patch(orig, modified, "test.md");
529 let result = apply_patch_pure(orig, &patch).unwrap();
530 assert_eq!(result, modified);
531 }
532
533 #[test]
534 fn apply_patch_multi_hunk() {
535 use std::fmt::Write;
536 let mut orig = String::new();
537 for i in 0..20 {
538 let _ = writeln!(orig, "line{i}");
539 }
540 let mut modified = orig.clone();
541 modified = modified.replace("line2\n", "MODIFIED2\n");
542 modified = modified.replace("line15\n", "MODIFIED15\n");
543 let patch = generate_patch(&orig, &modified, "test.md");
544 assert!(patch.contains("@@"), "should have hunk headers");
545 let result = apply_patch_pure(&orig, &patch).unwrap();
546 assert_eq!(result, modified);
547 }
548
549 #[test]
550 fn apply_patch_context_mismatch_errors() {
551 let orig = "line1\nline2\n";
552 let patch = "--- a/test.md\n+++ b/test.md\n@@ -1,2 +1,2 @@\n-totally_wrong\n+new\n";
553 let result = apply_patch_pure(orig, patch);
554 assert!(result.is_err());
555 assert!(result.unwrap_err().to_string().contains("context mismatch"));
556 }
557
558 #[test]
561 fn patch_path_single_file_agent() {
562 let entry = github_entry("my-agent", EntityType::Agent);
563 let root = Path::new("/repo");
564 let p = patch_path(&entry, root);
565 assert_eq!(
566 p,
567 Path::new("/repo/.skillfile/patches/agents/my-agent.patch")
568 );
569 }
570
571 #[test]
572 fn patch_path_single_file_skill() {
573 let entry = github_entry("my-skill", EntityType::Skill);
574 let root = Path::new("/repo");
575 let p = patch_path(&entry, root);
576 assert_eq!(
577 p,
578 Path::new("/repo/.skillfile/patches/skills/my-skill.patch")
579 );
580 }
581
582 #[test]
583 fn dir_patch_path_returns_correct() {
584 let entry = github_entry("lang-pro", EntityType::Skill);
585 let root = Path::new("/repo");
586 let p = dir_patch_path(&entry, "python.md", root);
587 assert_eq!(
588 p,
589 Path::new("/repo/.skillfile/patches/skills/lang-pro/python.md.patch")
590 );
591 }
592
593 #[test]
594 fn write_read_remove_patch_round_trip() {
595 let dir = tempfile::tempdir().unwrap();
596 let entry = github_entry("test-agent", EntityType::Agent);
597 let patch_text = "--- a/test-agent.md\n+++ b/test-agent.md\n@@ -1 +1 @@\n-old\n+new\n";
598 write_patch(&entry, patch_text, dir.path()).unwrap();
599 assert!(has_patch(&entry, dir.path()));
600 let read = read_patch(&entry, dir.path()).unwrap();
601 assert_eq!(read, patch_text);
602 remove_patch(&entry, dir.path()).unwrap();
603 assert!(!has_patch(&entry, dir.path()));
604 }
605
606 #[test]
607 fn has_dir_patch_detects_patches() {
608 let dir = tempfile::tempdir().unwrap();
609 let entry = github_entry("lang-pro", EntityType::Skill);
610 assert!(!has_dir_patch(&entry, dir.path()));
611 write_dir_patch(
612 &dir_patch_path(&entry, "python.md", dir.path()),
613 "patch content",
614 )
615 .unwrap();
616 assert!(has_dir_patch(&entry, dir.path()));
617 }
618
619 #[test]
620 fn remove_all_dir_patches_clears_dir() {
621 let dir = tempfile::tempdir().unwrap();
622 let entry = github_entry("lang-pro", EntityType::Skill);
623 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1").unwrap();
624 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2").unwrap();
625 assert!(has_dir_patch(&entry, dir.path()));
626 remove_all_dir_patches(&entry, dir.path()).unwrap();
627 assert!(!has_dir_patch(&entry, dir.path()));
628 }
629
630 #[test]
633 fn remove_patch_nonexistent_is_noop() {
634 let dir = tempfile::tempdir().unwrap();
635 let entry = github_entry("ghost-agent", EntityType::Agent);
636 assert!(!has_patch(&entry, dir.path()));
638 remove_patch(&entry, dir.path()).unwrap();
639 assert!(!has_patch(&entry, dir.path()));
640 }
641
642 #[test]
645 fn remove_patch_cleans_up_empty_parent_dir() {
646 let dir = tempfile::tempdir().unwrap();
647 let entry = github_entry("solo-skill", EntityType::Skill);
648 write_patch(&entry, "some patch text\n", dir.path()).unwrap();
649
650 let parent = patches_root(dir.path()).join("skills");
652 assert!(parent.is_dir(), "parent dir should exist after write_patch");
653
654 remove_patch(&entry, dir.path()).unwrap();
655
656 assert!(
658 !has_patch(&entry, dir.path()),
659 "patch file should be removed"
660 );
661 assert!(
662 !parent.exists(),
663 "empty parent dir should be removed after last patch is deleted"
664 );
665 }
666
667 #[test]
670 fn remove_patch_keeps_parent_dir_when_nonempty() {
671 let dir = tempfile::tempdir().unwrap();
672 let entry_a = github_entry("skill-a", EntityType::Skill);
673 let entry_b = github_entry("skill-b", EntityType::Skill);
674 write_patch(&entry_a, "patch a\n", dir.path()).unwrap();
675 write_patch(&entry_b, "patch b\n", dir.path()).unwrap();
676
677 let parent = patches_root(dir.path()).join("skills");
678 remove_patch(&entry_a, dir.path()).unwrap();
679
680 assert!(
682 parent.is_dir(),
683 "parent dir must survive when another patch still exists"
684 );
685 assert!(has_patch(&entry_b, dir.path()));
686 }
687
688 #[test]
691 fn remove_dir_patch_nonexistent_is_noop() {
692 let dir = tempfile::tempdir().unwrap();
693 let entry = github_entry("ghost-skill", EntityType::Skill);
694 remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
696 }
697
698 #[test]
701 fn remove_dir_patch_cleans_up_empty_entry_dir() {
702 let dir = tempfile::tempdir().unwrap();
703 let entry = github_entry("lang-pro", EntityType::Skill);
704 write_dir_patch(
705 &dir_patch_path(&entry, "python.md", dir.path()),
706 "patch text\n",
707 )
708 .unwrap();
709
710 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
712 assert!(
713 entry_dir.is_dir(),
714 "entry dir should exist after write_dir_patch"
715 );
716
717 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
718
719 assert!(
721 !entry_dir.exists(),
722 "entry dir should be removed when it becomes empty"
723 );
724 }
725
726 #[test]
729 fn remove_dir_patch_keeps_entry_dir_when_nonempty() {
730 let dir = tempfile::tempdir().unwrap();
731 let entry = github_entry("lang-pro", EntityType::Skill);
732 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1\n").unwrap();
733 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2\n").unwrap();
734
735 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
736 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
737
738 assert!(
740 entry_dir.is_dir(),
741 "entry dir must survive when another patch still exists"
742 );
743 }
744
745 #[test]
748 fn generate_patch_no_trailing_newline_original() {
749 let p = generate_patch("old text", "new text\n", "test.md");
751 assert!(!p.is_empty(), "patch should not be empty");
752 for seg in p.split_inclusive('\n') {
753 assert!(
754 seg.ends_with('\n'),
755 "every output line must end with \\n, got: {seg:?}"
756 );
757 }
758 }
759
760 #[test]
761 fn generate_patch_no_trailing_newline_modified() {
762 let p = generate_patch("old text\n", "new text", "test.md");
764 assert!(!p.is_empty(), "patch should not be empty");
765 for seg in p.split_inclusive('\n') {
766 assert!(
767 seg.ends_with('\n'),
768 "every output line must end with \\n, got: {seg:?}"
769 );
770 }
771 }
772
773 #[test]
774 fn generate_patch_both_inputs_no_trailing_newline() {
775 let p = generate_patch("old line", "new line", "test.md");
777 assert!(!p.is_empty(), "patch should not be empty");
778 for seg in p.split_inclusive('\n') {
779 assert!(
780 seg.ends_with('\n'),
781 "every output line must end with \\n, got: {seg:?}"
782 );
783 }
784 }
785
786 #[test]
787 fn generate_patch_no_trailing_newline_roundtrip() {
788 let orig = "line one\nline two";
791 let modified = "line one\nline changed";
792 let patch = generate_patch(orig, modified, "test.md");
793 assert!(!patch.is_empty());
794 let result = apply_patch_pure(orig, &patch).unwrap();
796 assert_eq!(
799 result.trim_end_matches('\n'),
800 modified.trim_end_matches('\n')
801 );
802 }
803
804 #[test]
807 fn apply_patch_pure_with_no_newline_marker() {
808 let orig = "line1\nline2\n";
811 let patch = concat!(
812 "--- a/test.md\n",
813 "+++ b/test.md\n",
814 "@@ -1,2 +1,2 @@\n",
815 " line1\n",
816 "-line2\n",
817 "+changed\n",
818 "\\ No newline at end of file\n",
819 );
820 let result = apply_patch_pure(orig, patch).unwrap();
821 assert_eq!(result, "line1\nchanged\n");
822 }
823
824 #[test]
827 fn walkdir_empty_directory_returns_empty() {
828 let dir = tempfile::tempdir().unwrap();
829 let files = walkdir(dir.path());
830 assert!(
831 files.is_empty(),
832 "walkdir of empty dir should return empty vec"
833 );
834 }
835
836 #[test]
837 fn walkdir_nonexistent_directory_returns_empty() {
838 let path = Path::new("/tmp/skillfile_test_does_not_exist_xyz_9999");
839 let files = walkdir(path);
840 assert!(
841 files.is_empty(),
842 "walkdir of non-existent dir should return empty vec"
843 );
844 }
845
846 #[test]
847 fn walkdir_nested_subdirectories() {
848 let dir = tempfile::tempdir().unwrap();
849 let sub = dir.path().join("sub");
850 std::fs::create_dir_all(&sub).unwrap();
851 std::fs::write(dir.path().join("top.txt"), "top").unwrap();
852 std::fs::write(sub.join("nested.txt"), "nested").unwrap();
853
854 let files = walkdir(dir.path());
855 assert_eq!(files.len(), 2, "should find both files");
856
857 let names: Vec<String> = files
858 .iter()
859 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
860 .collect();
861 assert!(names.contains(&"top.txt".to_string()));
862 assert!(names.contains(&"nested.txt".to_string()));
863 }
864
865 #[test]
866 fn walkdir_results_are_sorted() {
867 let dir = tempfile::tempdir().unwrap();
868 std::fs::write(dir.path().join("z.txt"), "z").unwrap();
869 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
870 std::fs::write(dir.path().join("m.txt"), "m").unwrap();
871
872 let files = walkdir(dir.path());
873 let sorted = {
874 let mut v = files.clone();
875 v.sort();
876 v
877 };
878 assert_eq!(files, sorted, "walkdir results must be sorted");
879 }
880
881 #[test]
884 fn apply_patch_pure_handles_crlf_original() {
885 let orig_lf = "line1\nline2\nline3\n";
886 let modified = "line1\nchanged\nline3\n";
887 let patch = generate_patch(orig_lf, modified, "test.md");
888
889 let orig_crlf = "line1\r\nline2\r\nline3\r\n";
891 let result = apply_patch_pure(orig_crlf, &patch).unwrap();
892 assert_eq!(result, modified);
893 }
894
895 #[test]
896 fn apply_patch_pure_handles_crlf_patch() {
897 let orig = "line1\nline2\nline3\n";
898 let modified = "line1\nchanged\nline3\n";
899 let patch_lf = generate_patch(orig, modified, "test.md");
900
901 let patch_crlf = patch_lf.replace('\n', "\r\n");
903 let result = apply_patch_pure(orig, &patch_crlf).unwrap();
904 assert_eq!(result, modified);
905 }
906
907 #[test]
910 fn apply_patch_pure_fuzzy_hunk_matching() {
911 use std::fmt::Write;
912 let mut orig = String::new();
914 for i in 1..=20 {
915 let _ = writeln!(orig, "line{i}");
916 }
917
918 let patch = concat!(
922 "--- a/test.md\n",
923 "+++ b/test.md\n",
924 "@@ -5,3 +5,3 @@\n", " line7\n",
926 "-line8\n",
927 "+CHANGED8\n",
928 " line9\n",
929 );
930
931 let result = apply_patch_pure(&orig, patch).unwrap();
932 assert!(
933 result.contains("CHANGED8\n"),
934 "fuzzy match should have applied the change"
935 );
936 assert!(
937 !result.contains("line8\n"),
938 "original line8 should have been replaced"
939 );
940 }
941
942 #[test]
945 fn apply_patch_pure_extends_beyond_eof_errors() {
946 let orig = "line1\nline2\n";
955 let patch = concat!(
956 "--- a/test.md\n",
957 "+++ b/test.md\n",
958 "@@ -999,1 +999,1 @@\n",
959 "-nonexistent_line\n",
960 "+replacement\n",
961 );
962 let result = apply_patch_pure(orig, patch);
963 assert!(
964 result.is_err(),
965 "applying a patch beyond EOF should return an error"
966 );
967 }
968}