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)
130 .map(|mut rd| rd.next().is_none())
131 .unwrap_or(true);
132 if is_empty {
133 let _ = std::fs::remove_dir(parent);
134 }
135}
136
137pub fn generate_patch(original: &str, modified: &str, label: &str) -> String {
157 if original == modified {
158 return String::new();
159 }
160
161 let diff = similar::TextDiff::from_lines(original, modified);
162 let raw = format!(
163 "{}",
164 diff.unified_diff()
165 .context_radius(3)
166 .header(&format!("a/{label}"), &format!("b/{label}"))
167 );
168
169 if raw.is_empty() {
170 return String::new();
171 }
172
173 let mut result = String::new();
176 for line in raw.split_inclusive('\n') {
177 normalize_diff_line(line, &mut result);
178 }
179
180 result
181}
182
183fn normalize_diff_line(line: &str, result: &mut String) {
189 if line.starts_with("\\ ") {
190 if !result.ends_with('\n') {
192 result.push('\n');
193 }
194 return;
195 }
196 result.push_str(line);
197 if !line.ends_with('\n') {
198 result.push('\n');
199 }
200}
201
202struct Hunk {
207 orig_start: usize, body: Vec<String>,
209}
210
211fn parse_hunks(patch_text: &str) -> Result<Vec<Hunk>, SkillfileError> {
212 let lines: Vec<&str> = patch_text.split_inclusive('\n').collect();
213 let mut pi = 0;
214
215 while pi < lines.len() && (lines[pi].starts_with("--- ") || lines[pi].starts_with("+++ ")) {
217 pi += 1;
218 }
219
220 let mut hunks: Vec<Hunk> = Vec::new();
221
222 while pi < lines.len() {
223 let pl = lines[pi];
224 if !pl.starts_with("@@ ") {
225 pi += 1;
226 continue;
227 }
228
229 let orig_start = pl
232 .split_whitespace()
233 .nth(1) .and_then(|s| s.trim_start_matches('-').split(',').next())
235 .and_then(|n| n.parse::<usize>().ok())
236 .ok_or_else(|| SkillfileError::Manifest(format!("malformed hunk header: {pl:?}")))?;
237
238 pi += 1;
239 let body = collect_hunk_body(&lines, &mut pi);
240
241 hunks.push(Hunk { orig_start, body });
242 }
243
244 Ok(hunks)
245}
246
247fn collect_hunk_body(lines: &[&str], pi: &mut usize) -> Vec<String> {
248 let mut body: Vec<String> = Vec::new();
249 while *pi < lines.len() {
250 let hl = lines[*pi];
251 if hl.starts_with("@@ ") || hl.starts_with("--- ") || hl.starts_with("+++ ") {
252 break;
253 }
254 if hl.starts_with("\\ ") {
255 *pi += 1;
257 continue;
258 }
259 body.push(hl.to_string());
260 *pi += 1;
261 }
262 body
263}
264
265fn try_hunk_at(lines: &[String], start: usize, ctx_lines: &[&str]) -> bool {
266 if start + ctx_lines.len() > lines.len() {
267 return false;
268 }
269 for (i, expected) in ctx_lines.iter().enumerate() {
270 if lines[start + i].trim_end_matches(['\n', '\r']) != *expected {
271 return false;
272 }
273 }
274 true
275}
276
277struct HunkSearch<'a> {
279 lines: &'a [String],
280 min_pos: usize,
281}
282
283impl HunkSearch<'_> {
284 fn search_nearby(&self, center: usize, ctx_lines: &[&str]) -> Option<usize> {
286 (1..100usize)
287 .flat_map(|delta| [Some(center + delta), center.checked_sub(delta)])
288 .flatten()
289 .filter(|&c| c >= self.min_pos && c <= self.lines.len())
290 .find(|&c| try_hunk_at(self.lines, c, ctx_lines))
291 }
292}
293
294fn find_hunk_position(
295 ctx: &HunkSearch<'_>,
296 hunk_start: usize,
297 ctx_lines: &[&str],
298) -> Result<usize, SkillfileError> {
299 if try_hunk_at(ctx.lines, hunk_start, ctx_lines) {
300 return Ok(hunk_start);
301 }
302
303 if let Some(pos) = ctx.search_nearby(hunk_start, ctx_lines) {
304 return Ok(pos);
305 }
306
307 if !ctx_lines.is_empty() {
308 return Err(SkillfileError::Manifest(format!(
309 "context mismatch: cannot find context starting with {:?} near line {}",
310 ctx_lines[0],
311 hunk_start + 1
312 )));
313 }
314 Err(SkillfileError::Manifest(
315 "patch extends beyond end of file".into(),
316 ))
317}
318
319struct PatchState<'a> {
321 lines: &'a [String],
322 output: Vec<String>,
323 pos: usize,
324}
325
326impl<'a> PatchState<'a> {
327 fn new(lines: &'a [String]) -> Self {
328 Self {
329 lines,
330 output: Vec::new(),
331 pos: 0,
332 }
333 }
334
335 fn apply_line(&mut self, hl: &str) {
336 let Some(prefix) = hl.as_bytes().first() else {
337 return;
338 };
339 match prefix {
340 b' ' if self.pos < self.lines.len() => {
341 self.output.push(self.lines[self.pos].clone());
342 self.pos += 1;
343 }
344 b'-' => self.pos += 1,
345 b'+' => self.output.push(hl[1..].to_string()),
346 _ => {} }
348 }
349
350 fn apply_hunk(&mut self, hunk: &Hunk) {
351 for hl in &hunk.body {
352 self.apply_line(hl);
353 }
354 }
355}
356
357pub fn apply_patch_pure(original: &str, patch_text: &str) -> Result<String, SkillfileError> {
372 if patch_text.is_empty() {
373 return Ok(original.to_string());
374 }
375
376 let original = &original.replace("\r\n", "\n");
378 let patch_text = &patch_text.replace("\r\n", "\n");
379
380 let lines: Vec<String> = original
382 .split_inclusive('\n')
383 .map(ToString::to_string)
384 .collect();
385
386 let mut state = PatchState::new(&lines);
387
388 for hunk in parse_hunks(patch_text)? {
389 let ctx_lines: Vec<&str> = hunk
391 .body
392 .iter()
393 .filter(|hl| !hl.is_empty() && (hl.starts_with(' ') || hl.starts_with('-')))
394 .map(|hl| hl[1..].trim_end_matches('\n'))
395 .collect();
396
397 let search = HunkSearch {
398 lines: &lines,
399 min_pos: state.pos,
400 };
401 let hunk_start =
402 find_hunk_position(&search, hunk.orig_start.saturating_sub(1), &ctx_lines)?;
403
404 state
406 .output
407 .extend_from_slice(&lines[state.pos..hunk_start]);
408 state.pos = hunk_start;
409
410 state.apply_hunk(&hunk);
411 }
412
413 state.output.extend_from_slice(&lines[state.pos..]);
415 Ok(state.output.concat())
416}
417
418#[must_use]
424pub fn walkdir(dir: &Path) -> Vec<PathBuf> {
425 let mut result = Vec::new();
426 walkdir_inner(dir, &mut result);
427 result.sort();
428 result
429}
430
431fn walkdir_inner(dir: &Path, result: &mut Vec<PathBuf>) {
432 let Ok(entries) = std::fs::read_dir(dir) else {
433 return;
434 };
435 for entry in entries.flatten() {
436 let path = entry.path();
437 if path.is_dir() {
438 walkdir_inner(&path, result);
439 } else {
440 result.push(path);
441 }
442 }
443}
444
445#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::models::{EntityType, SourceFields};
453
454 fn github_entry(name: &str, entity_type: EntityType) -> Entry {
455 Entry {
456 entity_type,
457 name: name.to_string(),
458 source: SourceFields::Github {
459 owner_repo: "owner/repo".into(),
460 path_in_repo: "agents/test.md".into(),
461 ref_: "main".into(),
462 },
463 }
464 }
465
466 #[test]
469 fn generate_patch_identical_returns_empty() {
470 assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
471 }
472
473 #[test]
474 fn generate_patch_has_headers() {
475 let p = generate_patch("old\n", "new\n", "test.md");
476 assert!(p.contains("--- a/test.md"), "missing fromfile header");
477 assert!(p.contains("+++ b/test.md"), "missing tofile header");
478 }
479
480 #[test]
481 fn generate_patch_add_line() {
482 let p = generate_patch("line1\n", "line1\nline2\n", "test.md");
483 assert!(p.contains("+line2"));
484 }
485
486 #[test]
487 fn generate_patch_remove_line() {
488 let p = generate_patch("line1\nline2\n", "line1\n", "test.md");
489 assert!(p.contains("-line2"));
490 }
491
492 #[test]
493 fn generate_patch_all_lines_end_with_newline() {
494 let p = generate_patch("a\nb\n", "a\nc\n", "test.md");
495 for seg in p.split_inclusive('\n') {
496 assert!(seg.ends_with('\n'), "line does not end with \\n: {seg:?}");
497 }
498 }
499
500 #[test]
503 fn apply_patch_empty_patch_returns_original() {
504 let result = apply_patch_pure("hello\n", "").unwrap();
505 assert_eq!(result, "hello\n");
506 }
507
508 #[test]
509 fn apply_patch_round_trip_add_line() {
510 let orig = "line1\nline2\n";
511 let modified = "line1\nline2\nline3\n";
512 let patch = generate_patch(orig, modified, "test.md");
513 let result = apply_patch_pure(orig, &patch).unwrap();
514 assert_eq!(result, modified);
515 }
516
517 #[test]
518 fn apply_patch_round_trip_remove_line() {
519 let orig = "line1\nline2\nline3\n";
520 let modified = "line1\nline3\n";
521 let patch = generate_patch(orig, modified, "test.md");
522 let result = apply_patch_pure(orig, &patch).unwrap();
523 assert_eq!(result, modified);
524 }
525
526 #[test]
527 fn apply_patch_round_trip_modify_line() {
528 let orig = "# Title\n\nSome text here.\n";
529 let modified = "# Title\n\nSome modified text here.\n";
530 let patch = generate_patch(orig, modified, "test.md");
531 let result = apply_patch_pure(orig, &patch).unwrap();
532 assert_eq!(result, modified);
533 }
534
535 #[test]
536 fn apply_patch_multi_hunk() {
537 use std::fmt::Write;
538 let mut orig = String::new();
539 for i in 0..20 {
540 let _ = writeln!(orig, "line{i}");
541 }
542 let mut modified = orig.clone();
543 modified = modified.replace("line2\n", "MODIFIED2\n");
544 modified = modified.replace("line15\n", "MODIFIED15\n");
545 let patch = generate_patch(&orig, &modified, "test.md");
546 assert!(patch.contains("@@"), "should have hunk headers");
547 let result = apply_patch_pure(&orig, &patch).unwrap();
548 assert_eq!(result, modified);
549 }
550
551 #[test]
552 fn apply_patch_context_mismatch_errors() {
553 let orig = "line1\nline2\n";
554 let patch = "--- a/test.md\n+++ b/test.md\n@@ -1,2 +1,2 @@\n-totally_wrong\n+new\n";
555 let result = apply_patch_pure(orig, patch);
556 assert!(result.is_err());
557 assert!(result.unwrap_err().to_string().contains("context mismatch"));
558 }
559
560 #[test]
563 fn patch_path_single_file_agent() {
564 let entry = github_entry("my-agent", EntityType::Agent);
565 let root = Path::new("/repo");
566 let p = patch_path(&entry, root);
567 assert_eq!(
568 p,
569 Path::new("/repo/.skillfile/patches/agents/my-agent.patch")
570 );
571 }
572
573 #[test]
574 fn patch_path_single_file_skill() {
575 let entry = github_entry("my-skill", EntityType::Skill);
576 let root = Path::new("/repo");
577 let p = patch_path(&entry, root);
578 assert_eq!(
579 p,
580 Path::new("/repo/.skillfile/patches/skills/my-skill.patch")
581 );
582 }
583
584 #[test]
585 fn dir_patch_path_returns_correct() {
586 let entry = github_entry("lang-pro", EntityType::Skill);
587 let root = Path::new("/repo");
588 let p = dir_patch_path(&entry, "python.md", root);
589 assert_eq!(
590 p,
591 Path::new("/repo/.skillfile/patches/skills/lang-pro/python.md.patch")
592 );
593 }
594
595 #[test]
596 fn write_read_remove_patch_round_trip() {
597 let dir = tempfile::tempdir().unwrap();
598 let entry = github_entry("test-agent", EntityType::Agent);
599 let patch_text = "--- a/test-agent.md\n+++ b/test-agent.md\n@@ -1 +1 @@\n-old\n+new\n";
600 write_patch(&entry, patch_text, dir.path()).unwrap();
601 assert!(has_patch(&entry, dir.path()));
602 let read = read_patch(&entry, dir.path()).unwrap();
603 assert_eq!(read, patch_text);
604 remove_patch(&entry, dir.path()).unwrap();
605 assert!(!has_patch(&entry, dir.path()));
606 }
607
608 #[test]
609 fn has_dir_patch_detects_patches() {
610 let dir = tempfile::tempdir().unwrap();
611 let entry = github_entry("lang-pro", EntityType::Skill);
612 assert!(!has_dir_patch(&entry, dir.path()));
613 write_dir_patch(
614 &dir_patch_path(&entry, "python.md", dir.path()),
615 "patch content",
616 )
617 .unwrap();
618 assert!(has_dir_patch(&entry, dir.path()));
619 }
620
621 #[test]
622 fn remove_all_dir_patches_clears_dir() {
623 let dir = tempfile::tempdir().unwrap();
624 let entry = github_entry("lang-pro", EntityType::Skill);
625 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1").unwrap();
626 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2").unwrap();
627 assert!(has_dir_patch(&entry, dir.path()));
628 remove_all_dir_patches(&entry, dir.path()).unwrap();
629 assert!(!has_dir_patch(&entry, dir.path()));
630 }
631
632 #[test]
635 fn remove_patch_nonexistent_is_noop() {
636 let dir = tempfile::tempdir().unwrap();
637 let entry = github_entry("ghost-agent", EntityType::Agent);
638 assert!(!has_patch(&entry, dir.path()));
640 remove_patch(&entry, dir.path()).unwrap();
641 assert!(!has_patch(&entry, dir.path()));
642 }
643
644 #[test]
647 fn remove_patch_cleans_up_empty_parent_dir() {
648 let dir = tempfile::tempdir().unwrap();
649 let entry = github_entry("solo-skill", EntityType::Skill);
650 write_patch(&entry, "some patch text\n", dir.path()).unwrap();
651
652 let parent = patches_root(dir.path()).join("skills");
654 assert!(parent.is_dir(), "parent dir should exist after write_patch");
655
656 remove_patch(&entry, dir.path()).unwrap();
657
658 assert!(
660 !has_patch(&entry, dir.path()),
661 "patch file should be removed"
662 );
663 assert!(
664 !parent.exists(),
665 "empty parent dir should be removed after last patch is deleted"
666 );
667 }
668
669 #[test]
672 fn remove_patch_keeps_parent_dir_when_nonempty() {
673 let dir = tempfile::tempdir().unwrap();
674 let entry_a = github_entry("skill-a", EntityType::Skill);
675 let entry_b = github_entry("skill-b", EntityType::Skill);
676 write_patch(&entry_a, "patch a\n", dir.path()).unwrap();
677 write_patch(&entry_b, "patch b\n", dir.path()).unwrap();
678
679 let parent = patches_root(dir.path()).join("skills");
680 remove_patch(&entry_a, dir.path()).unwrap();
681
682 assert!(
684 parent.is_dir(),
685 "parent dir must survive when another patch still exists"
686 );
687 assert!(has_patch(&entry_b, dir.path()));
688 }
689
690 #[test]
693 fn remove_dir_patch_nonexistent_is_noop() {
694 let dir = tempfile::tempdir().unwrap();
695 let entry = github_entry("ghost-skill", EntityType::Skill);
696 remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
698 }
699
700 #[test]
703 fn remove_dir_patch_cleans_up_empty_entry_dir() {
704 let dir = tempfile::tempdir().unwrap();
705 let entry = github_entry("lang-pro", EntityType::Skill);
706 write_dir_patch(
707 &dir_patch_path(&entry, "python.md", dir.path()),
708 "patch text\n",
709 )
710 .unwrap();
711
712 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
714 assert!(
715 entry_dir.is_dir(),
716 "entry dir should exist after write_dir_patch"
717 );
718
719 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
720
721 assert!(
723 !entry_dir.exists(),
724 "entry dir should be removed when it becomes empty"
725 );
726 }
727
728 #[test]
731 fn remove_dir_patch_keeps_entry_dir_when_nonempty() {
732 let dir = tempfile::tempdir().unwrap();
733 let entry = github_entry("lang-pro", EntityType::Skill);
734 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1\n").unwrap();
735 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2\n").unwrap();
736
737 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
738 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
739
740 assert!(
742 entry_dir.is_dir(),
743 "entry dir must survive when another patch still exists"
744 );
745 }
746
747 #[test]
750 fn generate_patch_no_trailing_newline_original() {
751 let p = generate_patch("old text", "new text\n", "test.md");
753 assert!(!p.is_empty(), "patch should not be empty");
754 for seg in p.split_inclusive('\n') {
755 assert!(
756 seg.ends_with('\n'),
757 "every output line must end with \\n, got: {seg:?}"
758 );
759 }
760 }
761
762 #[test]
763 fn generate_patch_no_trailing_newline_modified() {
764 let p = generate_patch("old text\n", "new text", "test.md");
766 assert!(!p.is_empty(), "patch should not be empty");
767 for seg in p.split_inclusive('\n') {
768 assert!(
769 seg.ends_with('\n'),
770 "every output line must end with \\n, got: {seg:?}"
771 );
772 }
773 }
774
775 #[test]
776 fn generate_patch_both_inputs_no_trailing_newline() {
777 let p = generate_patch("old line", "new line", "test.md");
779 assert!(!p.is_empty(), "patch should not be empty");
780 for seg in p.split_inclusive('\n') {
781 assert!(
782 seg.ends_with('\n'),
783 "every output line must end with \\n, got: {seg:?}"
784 );
785 }
786 }
787
788 #[test]
789 fn generate_patch_no_trailing_newline_roundtrip() {
790 let orig = "line one\nline two";
793 let modified = "line one\nline changed";
794 let patch = generate_patch(orig, modified, "test.md");
795 assert!(!patch.is_empty());
796 let result = apply_patch_pure(orig, &patch).unwrap();
798 assert_eq!(
801 result.trim_end_matches('\n'),
802 modified.trim_end_matches('\n')
803 );
804 }
805
806 #[test]
809 fn apply_patch_pure_with_no_newline_marker() {
810 let orig = "line1\nline2\n";
813 let patch = concat!(
814 "--- a/test.md\n",
815 "+++ b/test.md\n",
816 "@@ -1,2 +1,2 @@\n",
817 " line1\n",
818 "-line2\n",
819 "+changed\n",
820 "\\ No newline at end of file\n",
821 );
822 let result = apply_patch_pure(orig, patch).unwrap();
823 assert_eq!(result, "line1\nchanged\n");
824 }
825
826 #[test]
829 fn walkdir_empty_directory_returns_empty() {
830 let dir = tempfile::tempdir().unwrap();
831 let files = walkdir(dir.path());
832 assert!(
833 files.is_empty(),
834 "walkdir of empty dir should return empty vec"
835 );
836 }
837
838 #[test]
839 fn walkdir_nonexistent_directory_returns_empty() {
840 let path = Path::new("/tmp/skillfile_test_does_not_exist_xyz_9999");
841 let files = walkdir(path);
842 assert!(
843 files.is_empty(),
844 "walkdir of non-existent dir should return empty vec"
845 );
846 }
847
848 #[test]
849 fn walkdir_nested_subdirectories() {
850 let dir = tempfile::tempdir().unwrap();
851 let sub = dir.path().join("sub");
852 std::fs::create_dir_all(&sub).unwrap();
853 std::fs::write(dir.path().join("top.txt"), "top").unwrap();
854 std::fs::write(sub.join("nested.txt"), "nested").unwrap();
855
856 let files = walkdir(dir.path());
857 assert_eq!(files.len(), 2, "should find both files");
858
859 let names: Vec<String> = files
860 .iter()
861 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
862 .collect();
863 assert!(names.contains(&"top.txt".to_string()));
864 assert!(names.contains(&"nested.txt".to_string()));
865 }
866
867 #[test]
868 fn walkdir_results_are_sorted() {
869 let dir = tempfile::tempdir().unwrap();
870 std::fs::write(dir.path().join("z.txt"), "z").unwrap();
871 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
872 std::fs::write(dir.path().join("m.txt"), "m").unwrap();
873
874 let files = walkdir(dir.path());
875 let sorted = {
876 let mut v = files.clone();
877 v.sort();
878 v
879 };
880 assert_eq!(files, sorted, "walkdir results must be sorted");
881 }
882
883 #[test]
886 fn apply_patch_pure_handles_crlf_original() {
887 let orig_lf = "line1\nline2\nline3\n";
888 let modified = "line1\nchanged\nline3\n";
889 let patch = generate_patch(orig_lf, modified, "test.md");
890
891 let orig_crlf = "line1\r\nline2\r\nline3\r\n";
893 let result = apply_patch_pure(orig_crlf, &patch).unwrap();
894 assert_eq!(result, modified);
895 }
896
897 #[test]
898 fn apply_patch_pure_handles_crlf_patch() {
899 let orig = "line1\nline2\nline3\n";
900 let modified = "line1\nchanged\nline3\n";
901 let patch_lf = generate_patch(orig, modified, "test.md");
902
903 let patch_crlf = patch_lf.replace('\n', "\r\n");
905 let result = apply_patch_pure(orig, &patch_crlf).unwrap();
906 assert_eq!(result, modified);
907 }
908
909 #[test]
912 fn apply_patch_pure_fuzzy_hunk_matching() {
913 use std::fmt::Write;
914 let mut orig = String::new();
916 for i in 1..=20 {
917 let _ = writeln!(orig, "line{i}");
918 }
919
920 let patch = concat!(
924 "--- a/test.md\n",
925 "+++ b/test.md\n",
926 "@@ -5,3 +5,3 @@\n", " line7\n",
928 "-line8\n",
929 "+CHANGED8\n",
930 " line9\n",
931 );
932
933 let result = apply_patch_pure(&orig, patch).unwrap();
934 assert!(
935 result.contains("CHANGED8\n"),
936 "fuzzy match should have applied the change"
937 );
938 assert!(
939 !result.contains("line8\n"),
940 "original line8 should have been replaced"
941 );
942 }
943
944 #[test]
947 fn apply_patch_pure_extends_beyond_eof_errors() {
948 let orig = "line1\nline2\n";
957 let patch = concat!(
958 "--- a/test.md\n",
959 "+++ b/test.md\n",
960 "@@ -999,1 +999,1 @@\n",
961 "-nonexistent_line\n",
962 "+replacement\n",
963 );
964 let result = apply_patch_pure(orig, patch);
965 assert!(
966 result.is_err(),
967 "applying a patch beyond EOF should return an error"
968 );
969 }
970}