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', '\r']) != *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 original = &original.replace("\r\n", "\n");
396 let patch_text = &patch_text.replace("\r\n", "\n");
397
398 let lines: Vec<String> = original
400 .split_inclusive('\n')
401 .map(ToString::to_string)
402 .collect();
403
404 let mut state = PatchState::new(&lines);
405
406 for hunk in parse_hunks(patch_text)? {
407 let ctx_lines: Vec<&str> = hunk
409 .body
410 .iter()
411 .filter(|hl| !hl.is_empty() && (hl.starts_with(' ') || hl.starts_with('-')))
412 .map(|hl| hl[1..].trim_end_matches('\n'))
413 .collect();
414
415 let search = HunkSearch {
416 lines: &lines,
417 min_pos: state.pos,
418 };
419 let hunk_start =
420 find_hunk_position(&search, hunk.orig_start.saturating_sub(1), &ctx_lines)?;
421
422 state
424 .output
425 .extend_from_slice(&lines[state.pos..hunk_start]);
426 state.pos = hunk_start;
427
428 state.apply_hunk(&hunk);
429 }
430
431 state.output.extend_from_slice(&lines[state.pos..]);
433 Ok(state.output.concat())
434}
435
436#[must_use]
442pub fn walkdir(dir: &Path) -> Vec<PathBuf> {
443 let mut result = Vec::new();
444 walkdir_inner(dir, &mut result);
445 result.sort();
446 result
447}
448
449fn walkdir_inner(dir: &Path, result: &mut Vec<PathBuf>) {
450 let Ok(entries) = std::fs::read_dir(dir) else {
451 return;
452 };
453 for entry in entries.flatten() {
454 let path = entry.path();
455 if path.is_dir() {
456 walkdir_inner(&path, result);
457 } else {
458 result.push(path);
459 }
460 }
461}
462
463#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::models::{EntityType, SourceFields};
471
472 fn github_entry(name: &str, entity_type: EntityType) -> Entry {
473 Entry {
474 entity_type,
475 name: name.to_string(),
476 source: SourceFields::Github {
477 owner_repo: "owner/repo".into(),
478 path_in_repo: "agents/test.md".into(),
479 ref_: "main".into(),
480 },
481 }
482 }
483
484 #[test]
487 fn generate_patch_identical_returns_empty() {
488 assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
489 }
490
491 #[test]
492 fn generate_patch_has_headers() {
493 let p = generate_patch("old\n", "new\n", "test.md");
494 assert!(p.contains("--- a/test.md"), "missing fromfile header");
495 assert!(p.contains("+++ b/test.md"), "missing tofile header");
496 }
497
498 #[test]
499 fn generate_patch_add_line() {
500 let p = generate_patch("line1\n", "line1\nline2\n", "test.md");
501 assert!(p.contains("+line2"));
502 }
503
504 #[test]
505 fn generate_patch_remove_line() {
506 let p = generate_patch("line1\nline2\n", "line1\n", "test.md");
507 assert!(p.contains("-line2"));
508 }
509
510 #[test]
511 fn generate_patch_all_lines_end_with_newline() {
512 let p = generate_patch("a\nb\n", "a\nc\n", "test.md");
513 for seg in p.split_inclusive('\n') {
514 assert!(seg.ends_with('\n'), "line does not end with \\n: {seg:?}");
515 }
516 }
517
518 #[test]
521 fn apply_patch_empty_patch_returns_original() {
522 let result = apply_patch_pure("hello\n", "").unwrap();
523 assert_eq!(result, "hello\n");
524 }
525
526 #[test]
527 fn apply_patch_round_trip_add_line() {
528 let orig = "line1\nline2\n";
529 let modified = "line1\nline2\nline3\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_round_trip_remove_line() {
537 let orig = "line1\nline2\nline3\n";
538 let modified = "line1\nline3\n";
539 let patch = generate_patch(orig, modified, "test.md");
540 let result = apply_patch_pure(orig, &patch).unwrap();
541 assert_eq!(result, modified);
542 }
543
544 #[test]
545 fn apply_patch_round_trip_modify_line() {
546 let orig = "# Title\n\nSome text here.\n";
547 let modified = "# Title\n\nSome modified text here.\n";
548 let patch = generate_patch(orig, modified, "test.md");
549 let result = apply_patch_pure(orig, &patch).unwrap();
550 assert_eq!(result, modified);
551 }
552
553 #[test]
554 fn apply_patch_multi_hunk() {
555 use std::fmt::Write;
556 let mut orig = String::new();
557 for i in 0..20 {
558 let _ = writeln!(orig, "line{i}");
559 }
560 let mut modified = orig.clone();
561 modified = modified.replace("line2\n", "MODIFIED2\n");
562 modified = modified.replace("line15\n", "MODIFIED15\n");
563 let patch = generate_patch(&orig, &modified, "test.md");
564 assert!(patch.contains("@@"), "should have hunk headers");
565 let result = apply_patch_pure(&orig, &patch).unwrap();
566 assert_eq!(result, modified);
567 }
568
569 #[test]
570 fn apply_patch_context_mismatch_errors() {
571 let orig = "line1\nline2\n";
572 let patch = "--- a/test.md\n+++ b/test.md\n@@ -1,2 +1,2 @@\n-totally_wrong\n+new\n";
573 let result = apply_patch_pure(orig, patch);
574 assert!(result.is_err());
575 assert!(result.unwrap_err().to_string().contains("context mismatch"));
576 }
577
578 #[test]
581 fn patch_path_single_file_agent() {
582 let entry = github_entry("my-agent", EntityType::Agent);
583 let root = Path::new("/repo");
584 let p = patch_path(&entry, root);
585 assert_eq!(
586 p,
587 Path::new("/repo/.skillfile/patches/agents/my-agent.patch")
588 );
589 }
590
591 #[test]
592 fn patch_path_single_file_skill() {
593 let entry = github_entry("my-skill", EntityType::Skill);
594 let root = Path::new("/repo");
595 let p = patch_path(&entry, root);
596 assert_eq!(
597 p,
598 Path::new("/repo/.skillfile/patches/skills/my-skill.patch")
599 );
600 }
601
602 #[test]
603 fn dir_patch_path_returns_correct() {
604 let entry = github_entry("lang-pro", EntityType::Skill);
605 let root = Path::new("/repo");
606 let p = dir_patch_path(&entry, "python.md", root);
607 assert_eq!(
608 p,
609 Path::new("/repo/.skillfile/patches/skills/lang-pro/python.md.patch")
610 );
611 }
612
613 #[test]
614 fn write_read_remove_patch_round_trip() {
615 let dir = tempfile::tempdir().unwrap();
616 let entry = github_entry("test-agent", EntityType::Agent);
617 let patch_text = "--- a/test-agent.md\n+++ b/test-agent.md\n@@ -1 +1 @@\n-old\n+new\n";
618 write_patch(&entry, patch_text, dir.path()).unwrap();
619 assert!(has_patch(&entry, dir.path()));
620 let read = read_patch(&entry, dir.path()).unwrap();
621 assert_eq!(read, patch_text);
622 remove_patch(&entry, dir.path()).unwrap();
623 assert!(!has_patch(&entry, dir.path()));
624 }
625
626 #[test]
627 fn has_dir_patch_detects_patches() {
628 let dir = tempfile::tempdir().unwrap();
629 let entry = github_entry("lang-pro", EntityType::Skill);
630 assert!(!has_dir_patch(&entry, dir.path()));
631 write_dir_patch(
632 &dir_patch_path(&entry, "python.md", dir.path()),
633 "patch content",
634 )
635 .unwrap();
636 assert!(has_dir_patch(&entry, dir.path()));
637 }
638
639 #[test]
640 fn remove_all_dir_patches_clears_dir() {
641 let dir = tempfile::tempdir().unwrap();
642 let entry = github_entry("lang-pro", EntityType::Skill);
643 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1").unwrap();
644 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2").unwrap();
645 assert!(has_dir_patch(&entry, dir.path()));
646 remove_all_dir_patches(&entry, dir.path()).unwrap();
647 assert!(!has_dir_patch(&entry, dir.path()));
648 }
649
650 #[test]
653 fn remove_patch_nonexistent_is_noop() {
654 let dir = tempfile::tempdir().unwrap();
655 let entry = github_entry("ghost-agent", EntityType::Agent);
656 assert!(!has_patch(&entry, dir.path()));
658 remove_patch(&entry, dir.path()).unwrap();
659 assert!(!has_patch(&entry, dir.path()));
660 }
661
662 #[test]
665 fn remove_patch_cleans_up_empty_parent_dir() {
666 let dir = tempfile::tempdir().unwrap();
667 let entry = github_entry("solo-skill", EntityType::Skill);
668 write_patch(&entry, "some patch text\n", dir.path()).unwrap();
669
670 let parent = patches_root(dir.path()).join("skills");
672 assert!(parent.is_dir(), "parent dir should exist after write_patch");
673
674 remove_patch(&entry, dir.path()).unwrap();
675
676 assert!(
678 !has_patch(&entry, dir.path()),
679 "patch file should be removed"
680 );
681 assert!(
682 !parent.exists(),
683 "empty parent dir should be removed after last patch is deleted"
684 );
685 }
686
687 #[test]
690 fn remove_patch_keeps_parent_dir_when_nonempty() {
691 let dir = tempfile::tempdir().unwrap();
692 let entry_a = github_entry("skill-a", EntityType::Skill);
693 let entry_b = github_entry("skill-b", EntityType::Skill);
694 write_patch(&entry_a, "patch a\n", dir.path()).unwrap();
695 write_patch(&entry_b, "patch b\n", dir.path()).unwrap();
696
697 let parent = patches_root(dir.path()).join("skills");
698 remove_patch(&entry_a, dir.path()).unwrap();
699
700 assert!(
702 parent.is_dir(),
703 "parent dir must survive when another patch still exists"
704 );
705 assert!(has_patch(&entry_b, dir.path()));
706 }
707
708 #[test]
711 fn remove_dir_patch_nonexistent_is_noop() {
712 let dir = tempfile::tempdir().unwrap();
713 let entry = github_entry("ghost-skill", EntityType::Skill);
714 remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
716 }
717
718 #[test]
721 fn remove_dir_patch_cleans_up_empty_entry_dir() {
722 let dir = tempfile::tempdir().unwrap();
723 let entry = github_entry("lang-pro", EntityType::Skill);
724 write_dir_patch(
725 &dir_patch_path(&entry, "python.md", dir.path()),
726 "patch text\n",
727 )
728 .unwrap();
729
730 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
732 assert!(
733 entry_dir.is_dir(),
734 "entry dir should exist after write_dir_patch"
735 );
736
737 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
738
739 assert!(
741 !entry_dir.exists(),
742 "entry dir should be removed when it becomes empty"
743 );
744 }
745
746 #[test]
749 fn remove_dir_patch_keeps_entry_dir_when_nonempty() {
750 let dir = tempfile::tempdir().unwrap();
751 let entry = github_entry("lang-pro", EntityType::Skill);
752 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1\n").unwrap();
753 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2\n").unwrap();
754
755 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
756 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
757
758 assert!(
760 entry_dir.is_dir(),
761 "entry dir must survive when another patch still exists"
762 );
763 }
764
765 #[test]
768 fn generate_patch_no_trailing_newline_original() {
769 let p = generate_patch("old text", "new text\n", "test.md");
771 assert!(!p.is_empty(), "patch should not be empty");
772 for seg in p.split_inclusive('\n') {
773 assert!(
774 seg.ends_with('\n'),
775 "every output line must end with \\n, got: {seg:?}"
776 );
777 }
778 }
779
780 #[test]
781 fn generate_patch_no_trailing_newline_modified() {
782 let p = generate_patch("old text\n", "new text", "test.md");
784 assert!(!p.is_empty(), "patch should not be empty");
785 for seg in p.split_inclusive('\n') {
786 assert!(
787 seg.ends_with('\n'),
788 "every output line must end with \\n, got: {seg:?}"
789 );
790 }
791 }
792
793 #[test]
794 fn generate_patch_both_inputs_no_trailing_newline() {
795 let p = generate_patch("old line", "new line", "test.md");
797 assert!(!p.is_empty(), "patch should not be empty");
798 for seg in p.split_inclusive('\n') {
799 assert!(
800 seg.ends_with('\n'),
801 "every output line must end with \\n, got: {seg:?}"
802 );
803 }
804 }
805
806 #[test]
807 fn generate_patch_no_trailing_newline_roundtrip() {
808 let orig = "line one\nline two";
811 let modified = "line one\nline changed";
812 let patch = generate_patch(orig, modified, "test.md");
813 assert!(!patch.is_empty());
814 let result = apply_patch_pure(orig, &patch).unwrap();
816 assert_eq!(
819 result.trim_end_matches('\n'),
820 modified.trim_end_matches('\n')
821 );
822 }
823
824 #[test]
827 fn apply_patch_pure_with_no_newline_marker() {
828 let orig = "line1\nline2\n";
831 let patch = concat!(
832 "--- a/test.md\n",
833 "+++ b/test.md\n",
834 "@@ -1,2 +1,2 @@\n",
835 " line1\n",
836 "-line2\n",
837 "+changed\n",
838 "\\ No newline at end of file\n",
839 );
840 let result = apply_patch_pure(orig, patch).unwrap();
841 assert_eq!(result, "line1\nchanged\n");
842 }
843
844 #[test]
847 fn walkdir_empty_directory_returns_empty() {
848 let dir = tempfile::tempdir().unwrap();
849 let files = walkdir(dir.path());
850 assert!(
851 files.is_empty(),
852 "walkdir of empty dir should return empty vec"
853 );
854 }
855
856 #[test]
857 fn walkdir_nonexistent_directory_returns_empty() {
858 let path = Path::new("/tmp/skillfile_test_does_not_exist_xyz_9999");
859 let files = walkdir(path);
860 assert!(
861 files.is_empty(),
862 "walkdir of non-existent dir should return empty vec"
863 );
864 }
865
866 #[test]
867 fn walkdir_nested_subdirectories() {
868 let dir = tempfile::tempdir().unwrap();
869 let sub = dir.path().join("sub");
870 std::fs::create_dir_all(&sub).unwrap();
871 std::fs::write(dir.path().join("top.txt"), "top").unwrap();
872 std::fs::write(sub.join("nested.txt"), "nested").unwrap();
873
874 let files = walkdir(dir.path());
875 assert_eq!(files.len(), 2, "should find both files");
876
877 let names: Vec<String> = files
878 .iter()
879 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
880 .collect();
881 assert!(names.contains(&"top.txt".to_string()));
882 assert!(names.contains(&"nested.txt".to_string()));
883 }
884
885 #[test]
886 fn walkdir_results_are_sorted() {
887 let dir = tempfile::tempdir().unwrap();
888 std::fs::write(dir.path().join("z.txt"), "z").unwrap();
889 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
890 std::fs::write(dir.path().join("m.txt"), "m").unwrap();
891
892 let files = walkdir(dir.path());
893 let sorted = {
894 let mut v = files.clone();
895 v.sort();
896 v
897 };
898 assert_eq!(files, sorted, "walkdir results must be sorted");
899 }
900
901 #[test]
904 fn apply_patch_pure_handles_crlf_original() {
905 let orig_lf = "line1\nline2\nline3\n";
906 let modified = "line1\nchanged\nline3\n";
907 let patch = generate_patch(orig_lf, modified, "test.md");
908
909 let orig_crlf = "line1\r\nline2\r\nline3\r\n";
911 let result = apply_patch_pure(orig_crlf, &patch).unwrap();
912 assert_eq!(result, modified);
913 }
914
915 #[test]
916 fn apply_patch_pure_handles_crlf_patch() {
917 let orig = "line1\nline2\nline3\n";
918 let modified = "line1\nchanged\nline3\n";
919 let patch_lf = generate_patch(orig, modified, "test.md");
920
921 let patch_crlf = patch_lf.replace('\n', "\r\n");
923 let result = apply_patch_pure(orig, &patch_crlf).unwrap();
924 assert_eq!(result, modified);
925 }
926
927 #[test]
930 fn apply_patch_pure_fuzzy_hunk_matching() {
931 use std::fmt::Write;
932 let mut orig = String::new();
934 for i in 1..=20 {
935 let _ = writeln!(orig, "line{i}");
936 }
937
938 let patch = concat!(
942 "--- a/test.md\n",
943 "+++ b/test.md\n",
944 "@@ -5,3 +5,3 @@\n", " line7\n",
946 "-line8\n",
947 "+CHANGED8\n",
948 " line9\n",
949 );
950
951 let result = apply_patch_pure(&orig, patch).unwrap();
952 assert!(
953 result.contains("CHANGED8\n"),
954 "fuzzy match should have applied the change"
955 );
956 assert!(
957 !result.contains("line8\n"),
958 "original line8 should have been replaced"
959 );
960 }
961
962 #[test]
965 fn apply_patch_pure_extends_beyond_eof_errors() {
966 let orig = "line1\nline2\n";
975 let patch = concat!(
976 "--- a/test.md\n",
977 "+++ b/test.md\n",
978 "@@ -999,1 +999,1 @@\n",
979 "-nonexistent_line\n",
980 "+replacement\n",
981 );
982 let result = apply_patch_pure(orig, patch);
983 assert!(
984 result.is_err(),
985 "applying a patch beyond EOF should return an error"
986 );
987 }
988}