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