1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4use crate::error::SkillfileError;
5use crate::models::Entry;
6
7pub const PATCHES_DIR: &str = ".skillfile/patches";
8
9#[must_use]
18pub fn relative_file_key(base: &Path, path: &Path) -> Option<String> {
19 path.strip_prefix(base)
20 .ok()
21 .map(|relative| relative.to_string_lossy().replace('\\', "/"))
22}
23
24#[must_use]
25pub fn patches_root(repo_root: &Path) -> PathBuf {
26 repo_root.join(PATCHES_DIR)
27}
28
29pub fn patch_path(entry: &Entry, repo_root: &Path) -> PathBuf {
32 patches_root(repo_root)
33 .join(entry.entity_type.dir_name())
34 .join(format!("{}.patch", entry.name))
35}
36
37#[must_use]
38pub fn has_patch(entry: &Entry, repo_root: &Path) -> bool {
39 patch_path(entry, repo_root).exists()
40}
41
42pub fn write_patch(
43 entry: &Entry,
44 patch_text: &str,
45 repo_root: &Path,
46) -> Result<(), SkillfileError> {
47 let p = patch_path(entry, repo_root);
48 if let Some(parent) = p.parent() {
49 std::fs::create_dir_all(parent)?;
50 }
51 std::fs::write(&p, patch_text)?;
52 Ok(())
53}
54
55pub fn read_patch(entry: &Entry, repo_root: &Path) -> Result<String, SkillfileError> {
56 let p = patch_path(entry, repo_root);
57 Ok(std::fs::read_to_string(&p)?)
58}
59
60pub fn remove_patch(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
62 let p = patch_path(entry, repo_root);
63 if !p.exists() {
64 return Ok(());
65 }
66 std::fs::remove_file(&p)?;
67 remove_empty_parent(&p);
68 Ok(())
69}
70
71pub fn dir_patch_path(entry: &Entry, filename: &str, repo_root: &Path) -> PathBuf {
78 patches_root(repo_root)
79 .join(entry.entity_type.dir_name())
80 .join(&entry.name)
81 .join(format!("{filename}.patch"))
82}
83
84#[must_use]
85pub fn has_dir_patch(entry: &Entry, repo_root: &Path) -> bool {
86 let d = patches_root(repo_root)
87 .join(entry.entity_type.dir_name())
88 .join(&entry.name);
89 if !d.is_dir() {
90 return false;
91 }
92 walkdir(&d)
93 .into_iter()
94 .any(|p| p.extension().is_some_and(|e| e == "patch"))
95}
96
97pub fn write_dir_patch(patch_path: &Path, patch_text: &str) -> Result<(), SkillfileError> {
98 if let Some(parent) = patch_path.parent() {
99 std::fs::create_dir_all(parent)?;
100 }
101 std::fs::write(patch_path, patch_text)?;
102 Ok(())
103}
104
105pub fn remove_dir_patch(
106 entry: &Entry,
107 filename: &str,
108 repo_root: &Path,
109) -> Result<(), SkillfileError> {
110 let p = dir_patch_path(entry, filename, repo_root);
111 if !p.exists() {
112 return Ok(());
113 }
114 std::fs::remove_file(&p)?;
115 remove_empty_parent(&p);
116 Ok(())
117}
118
119pub fn remove_all_dir_patches(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
120 let d = patches_root(repo_root)
121 .join(entry.entity_type.dir_name())
122 .join(&entry.name);
123 if d.is_dir() {
124 std::fs::remove_dir_all(&d)?;
125 }
126 Ok(())
127}
128
129fn remove_empty_parent(path: &Path) {
135 let Some(parent) = path.parent() else {
136 return;
137 };
138 if !parent.exists() {
139 return;
140 }
141 let is_empty = std::fs::read_dir(parent).map_or(true, |mut rd| rd.next().is_none());
142 if is_empty {
143 let _ = std::fs::remove_dir(parent);
144 }
145}
146
147fn normalize_crlf(text: &str) -> Cow<'_, str> {
152 if text.contains("\r\n") {
153 Cow::Owned(text.replace("\r\n", "\n"))
154 } else {
155 Cow::Borrowed(text)
156 }
157}
158
159#[must_use]
162pub fn text_content_eq(left: &str, right: &str) -> bool {
163 normalize_crlf(left) == normalize_crlf(right)
164}
165
166pub fn generate_patch(original: &str, modified: &str, label: &str) -> String {
182 if original == modified {
183 return String::new();
184 }
185
186 let original = normalize_crlf(original);
187 let modified = normalize_crlf(modified);
188 if original == modified {
189 return String::new();
190 }
191
192 let diff = similar::TextDiff::from_lines(&original, &modified);
193 let raw = format!(
194 "{}",
195 diff.unified_diff()
196 .context_radius(3)
197 .header(&format!("a/{label}"), &format!("b/{label}"))
198 );
199
200 if raw.is_empty() {
201 return String::new();
202 }
203
204 let mut result = String::new();
207 for line in raw.split_inclusive('\n') {
208 normalize_diff_line(line, &mut result);
209 }
210
211 result
212}
213
214fn normalize_diff_line(line: &str, result: &mut String) {
218 result.push_str(line);
219 if !line.ends_with('\n') {
220 result.push('\n');
221 }
222}
223
224struct Hunk {
229 orig_start: usize, body: Vec<String>,
231}
232
233fn parse_hunks(patch_text: &str) -> Result<Vec<Hunk>, SkillfileError> {
234 let lines: Vec<&str> = patch_text.split_inclusive('\n').collect();
235 let mut pi = 0;
236
237 while pi < lines.len() && (lines[pi].starts_with("--- ") || lines[pi].starts_with("+++ ")) {
239 pi += 1;
240 }
241
242 let mut hunks: Vec<Hunk> = Vec::new();
243
244 while pi < lines.len() {
245 let pl = lines[pi];
246 if !pl.starts_with("@@ ") {
247 pi += 1;
248 continue;
249 }
250
251 let orig_start = pl
254 .split_whitespace()
255 .nth(1) .and_then(|s| s.trim_start_matches('-').split(',').next())
257 .and_then(|n| n.parse::<usize>().ok())
258 .ok_or_else(|| SkillfileError::Manifest(format!("malformed hunk header: {pl:?}")))?;
259
260 pi += 1;
261 let body = collect_hunk_body(&lines, &mut pi);
262
263 hunks.push(Hunk { orig_start, body });
264 }
265
266 Ok(hunks)
267}
268
269fn remove_line_ending(line: &mut String) {
270 if line.ends_with('\n') {
271 line.pop();
272 }
273}
274
275fn collect_hunk_body(lines: &[&str], pi: &mut usize) -> Vec<String> {
276 let mut body: Vec<String> = Vec::new();
277 while *pi < lines.len() {
278 let hl = lines[*pi];
279 if hl.starts_with("@@ ") || hl.starts_with("--- ") || hl.starts_with("+++ ") {
280 break;
281 }
282 if hl.starts_with("\\ ") {
283 body.last_mut().into_iter().for_each(remove_line_ending);
284 *pi += 1;
285 continue;
286 }
287 body.push(hl.to_string());
288 *pi += 1;
289 }
290 body
291}
292
293fn try_hunk_at(lines: &[String], start: usize, ctx_lines: &[&str]) -> bool {
294 if start + ctx_lines.len() > lines.len() {
295 return false;
296 }
297 for (i, expected) in ctx_lines.iter().enumerate() {
298 if lines[start + i].trim_end_matches(['\n', '\r']) != *expected {
299 return false;
300 }
301 }
302 true
303}
304
305struct HunkSearch<'a> {
307 lines: &'a [String],
308 min_pos: usize,
309}
310
311impl HunkSearch<'_> {
312 fn search_nearby(&self, center: usize, ctx_lines: &[&str]) -> Option<usize> {
314 (1..100usize)
315 .flat_map(|delta| [Some(center + delta), center.checked_sub(delta)])
316 .flatten()
317 .filter(|&c| c >= self.min_pos && c <= self.lines.len())
318 .find(|&c| try_hunk_at(self.lines, c, ctx_lines))
319 }
320}
321
322fn find_hunk_position(
323 ctx: &HunkSearch<'_>,
324 hunk_start: usize,
325 ctx_lines: &[&str],
326) -> Result<usize, SkillfileError> {
327 if try_hunk_at(ctx.lines, hunk_start, ctx_lines) {
328 return Ok(hunk_start);
329 }
330
331 if let Some(pos) = ctx.search_nearby(hunk_start, ctx_lines) {
332 return Ok(pos);
333 }
334
335 if !ctx_lines.is_empty() {
336 return Err(SkillfileError::Manifest(format!(
337 "context mismatch: cannot find context starting with {:?} near line {}",
338 ctx_lines[0],
339 hunk_start + 1
340 )));
341 }
342 Err(SkillfileError::Manifest(
343 "patch extends beyond end of file".into(),
344 ))
345}
346
347struct PatchState<'a> {
349 lines: &'a [String],
350 output: Vec<String>,
351 pos: usize,
352}
353
354impl<'a> PatchState<'a> {
355 fn new(lines: &'a [String]) -> Self {
356 Self {
357 lines,
358 output: Vec::new(),
359 pos: 0,
360 }
361 }
362
363 fn apply_line(&mut self, hl: &str) {
364 let Some(prefix) = hl.as_bytes().first() else {
365 return;
366 };
367 match prefix {
368 b' ' if self.pos < self.lines.len() => {
369 self.output.push(self.lines[self.pos].clone());
370 self.pos += 1;
371 }
372 b'-' => self.pos += 1,
373 b'+' => self.output.push(hl[1..].to_string()),
374 _ => {} }
376 }
377
378 fn apply_hunk(&mut self, hunk: &Hunk) {
379 for hl in &hunk.body {
380 self.apply_line(hl);
381 }
382 }
383}
384
385pub fn apply_patch_pure(original: &str, patch_text: &str) -> Result<String, SkillfileError> {
400 if patch_text.is_empty() {
401 return Ok(original.to_string());
402 }
403
404 let original = normalize_crlf(original);
406 let patch_text = normalize_crlf(patch_text);
407
408 let lines: Vec<String> = original
410 .split_inclusive('\n')
411 .map(ToString::to_string)
412 .collect();
413
414 let mut state = PatchState::new(&lines);
415
416 for hunk in parse_hunks(&patch_text)? {
417 let ctx_lines: Vec<&str> = hunk
419 .body
420 .iter()
421 .filter(|hl| !hl.is_empty() && (hl.starts_with(' ') || hl.starts_with('-')))
422 .map(|hl| hl[1..].trim_end_matches('\n'))
423 .collect();
424
425 let search = HunkSearch {
426 lines: &lines,
427 min_pos: state.pos,
428 };
429 let hunk_start =
430 find_hunk_position(&search, hunk.orig_start.saturating_sub(1), &ctx_lines)?;
431
432 state
434 .output
435 .extend_from_slice(&lines[state.pos..hunk_start]);
436 state.pos = hunk_start;
437
438 state.apply_hunk(&hunk);
439 }
440
441 state.output.extend_from_slice(&lines[state.pos..]);
443 Ok(state.output.concat())
444}
445
446#[must_use]
452pub fn walkdir(dir: &Path) -> Vec<PathBuf> {
453 let mut result = Vec::new();
454 walkdir_inner(dir, &mut result);
455 result.sort();
456 result
457}
458
459fn walkdir_inner(dir: &Path, result: &mut Vec<PathBuf>) {
460 let Ok(metadata) = std::fs::symlink_metadata(dir) else {
461 return;
462 };
463 if !metadata.is_dir() || metadata.file_type().is_symlink() {
464 return;
465 }
466 let Ok(entries) = std::fs::read_dir(dir) else {
467 return;
468 };
469 for entry in entries.flatten() {
470 let path = entry.path();
471 let Ok(file_type) = entry.file_type() else {
472 continue;
473 };
474 if file_type.is_symlink() {
475 continue;
476 }
477 if file_type.is_dir() {
478 walkdir_inner(&path, result);
479 } else if file_type.is_file() {
480 result.push(path);
481 }
482 }
483}
484
485#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::models::{EntityType, SourceFields};
493
494 fn github_entry(name: &str, entity_type: EntityType) -> Entry {
495 Entry {
496 entity_type,
497 name: name.to_string(),
498 source: SourceFields::Github {
499 owner_repo: "owner/repo".into(),
500 path_in_repo: "agents/test.md".into(),
501 ref_: "main".into(),
502 },
503 }
504 }
505
506 #[test]
507 fn relative_file_key_keeps_forward_slashes() {
508 let base = Path::new("cache");
509 assert_eq!(
510 relative_file_key(base, &base.join("nested/file.md")),
511 Some("nested/file.md".to_string())
512 );
513 }
514
515 #[test]
516 fn relative_file_key_normalizes_backslashes() {
517 let base = Path::new("cache");
518 assert_eq!(
519 relative_file_key(base, &base.join(r"nested\file.md")),
520 Some("nested/file.md".to_string())
521 );
522 }
523
524 #[test]
525 fn relative_file_key_rejects_paths_outside_base() {
526 assert_eq!(
527 relative_file_key(Path::new("cache"), Path::new("other/file.md")),
528 None
529 );
530 }
531
532 #[test]
535 fn generate_patch_identical_returns_empty() {
536 assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
537 }
538
539 #[test]
540 fn generate_patch_has_headers() {
541 let p = generate_patch("old\n", "new\n", "test.md");
542 assert!(p.contains("--- a/test.md"), "missing fromfile header");
543 assert!(p.contains("+++ b/test.md"), "missing tofile header");
544 }
545
546 #[test]
547 fn generate_patch_add_line() {
548 let p = generate_patch("line1\n", "line1\nline2\n", "test.md");
549 assert!(p.contains("+line2"));
550 }
551
552 #[test]
553 fn generate_patch_remove_line() {
554 let p = generate_patch("line1\nline2\n", "line1\n", "test.md");
555 assert!(p.contains("-line2"));
556 }
557
558 #[test]
559 fn generate_patch_all_lines_end_with_newline() {
560 let p = generate_patch("a\nb\n", "a\nc\n", "test.md");
561 for seg in p.split_inclusive('\n') {
562 assert!(seg.ends_with('\n'), "line does not end with \\n: {seg:?}");
563 }
564 }
565
566 #[test]
569 fn apply_patch_empty_patch_returns_original() {
570 let result = apply_patch_pure("hello\n", "").unwrap();
571 assert_eq!(result, "hello\n");
572 }
573
574 #[test]
575 fn apply_patch_round_trip_add_line() {
576 let orig = "line1\nline2\n";
577 let modified = "line1\nline2\nline3\n";
578 let patch = generate_patch(orig, modified, "test.md");
579 let result = apply_patch_pure(orig, &patch).unwrap();
580 assert_eq!(result, modified);
581 }
582
583 #[test]
584 fn apply_patch_round_trip_remove_line() {
585 let orig = "line1\nline2\nline3\n";
586 let modified = "line1\nline3\n";
587 let patch = generate_patch(orig, modified, "test.md");
588 let result = apply_patch_pure(orig, &patch).unwrap();
589 assert_eq!(result, modified);
590 }
591
592 #[test]
593 fn apply_patch_round_trip_modify_line() {
594 let orig = "# Title\n\nSome text here.\n";
595 let modified = "# Title\n\nSome modified text here.\n";
596 let patch = generate_patch(orig, modified, "test.md");
597 let result = apply_patch_pure(orig, &patch).unwrap();
598 assert_eq!(result, modified);
599 }
600
601 #[test]
602 fn apply_patch_multi_hunk() {
603 use std::fmt::Write;
604 let mut orig = String::new();
605 for i in 0..20 {
606 let _ = writeln!(orig, "line{i}");
607 }
608 let mut modified = orig.clone();
609 modified = modified.replace("line2\n", "MODIFIED2\n");
610 modified = modified.replace("line15\n", "MODIFIED15\n");
611 let patch = generate_patch(&orig, &modified, "test.md");
612 assert!(patch.contains("@@"), "should have hunk headers");
613 let result = apply_patch_pure(&orig, &patch).unwrap();
614 assert_eq!(result, modified);
615 }
616
617 #[test]
618 fn apply_patch_context_mismatch_errors() {
619 let orig = "line1\nline2\n";
620 let patch = "--- a/test.md\n+++ b/test.md\n@@ -1,2 +1,2 @@\n-totally_wrong\n+new\n";
621 let result = apply_patch_pure(orig, patch);
622 assert!(result.is_err());
623 assert!(result.unwrap_err().to_string().contains("context mismatch"));
624 }
625
626 #[test]
629 fn patch_path_single_file_agent() {
630 let entry = github_entry("my-agent", EntityType::Agent);
631 let root = Path::new("/repo");
632 let p = patch_path(&entry, root);
633 assert_eq!(
634 p,
635 Path::new("/repo/.skillfile/patches/agents/my-agent.patch")
636 );
637 }
638
639 #[test]
640 fn patch_path_single_file_skill() {
641 let entry = github_entry("my-skill", EntityType::Skill);
642 let root = Path::new("/repo");
643 let p = patch_path(&entry, root);
644 assert_eq!(
645 p,
646 Path::new("/repo/.skillfile/patches/skills/my-skill.patch")
647 );
648 }
649
650 #[test]
651 fn dir_patch_path_returns_correct() {
652 let entry = github_entry("lang-pro", EntityType::Skill);
653 let root = Path::new("/repo");
654 let p = dir_patch_path(&entry, "python.md", root);
655 assert_eq!(
656 p,
657 Path::new("/repo/.skillfile/patches/skills/lang-pro/python.md.patch")
658 );
659 }
660
661 #[test]
662 fn write_read_remove_patch_round_trip() {
663 let dir = tempfile::tempdir().unwrap();
664 let entry = github_entry("test-agent", EntityType::Agent);
665 let patch_text = "--- a/test-agent.md\n+++ b/test-agent.md\n@@ -1 +1 @@\n-old\n+new\n";
666 write_patch(&entry, patch_text, dir.path()).unwrap();
667 assert!(has_patch(&entry, dir.path()));
668 let read = read_patch(&entry, dir.path()).unwrap();
669 assert_eq!(read, patch_text);
670 remove_patch(&entry, dir.path()).unwrap();
671 assert!(!has_patch(&entry, dir.path()));
672 }
673
674 #[test]
675 fn has_dir_patch_detects_patches() {
676 let dir = tempfile::tempdir().unwrap();
677 let entry = github_entry("lang-pro", EntityType::Skill);
678 assert!(!has_dir_patch(&entry, dir.path()));
679 write_dir_patch(
680 &dir_patch_path(&entry, "python.md", dir.path()),
681 "patch content",
682 )
683 .unwrap();
684 assert!(has_dir_patch(&entry, dir.path()));
685 }
686
687 #[test]
688 fn remove_all_dir_patches_clears_dir() {
689 let dir = tempfile::tempdir().unwrap();
690 let entry = github_entry("lang-pro", EntityType::Skill);
691 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1").unwrap();
692 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2").unwrap();
693 assert!(has_dir_patch(&entry, dir.path()));
694 remove_all_dir_patches(&entry, dir.path()).unwrap();
695 assert!(!has_dir_patch(&entry, dir.path()));
696 }
697
698 #[test]
701 fn remove_patch_nonexistent_is_noop() {
702 let dir = tempfile::tempdir().unwrap();
703 let entry = github_entry("ghost-agent", EntityType::Agent);
704 assert!(!has_patch(&entry, dir.path()));
706 remove_patch(&entry, dir.path()).unwrap();
707 assert!(!has_patch(&entry, dir.path()));
708 }
709
710 #[test]
713 fn remove_patch_cleans_up_empty_parent_dir() {
714 let dir = tempfile::tempdir().unwrap();
715 let entry = github_entry("solo-skill", EntityType::Skill);
716 write_patch(&entry, "some patch text\n", dir.path()).unwrap();
717
718 let parent = patches_root(dir.path()).join("skills");
720 assert!(parent.is_dir(), "parent dir should exist after write_patch");
721
722 remove_patch(&entry, dir.path()).unwrap();
723
724 assert!(
726 !has_patch(&entry, dir.path()),
727 "patch file should be removed"
728 );
729 assert!(
730 !parent.exists(),
731 "empty parent dir should be removed after last patch is deleted"
732 );
733 }
734
735 #[test]
738 fn remove_patch_keeps_parent_dir_when_nonempty() {
739 let dir = tempfile::tempdir().unwrap();
740 let entry_a = github_entry("skill-a", EntityType::Skill);
741 let entry_b = github_entry("skill-b", EntityType::Skill);
742 write_patch(&entry_a, "patch a\n", dir.path()).unwrap();
743 write_patch(&entry_b, "patch b\n", dir.path()).unwrap();
744
745 let parent = patches_root(dir.path()).join("skills");
746 remove_patch(&entry_a, dir.path()).unwrap();
747
748 assert!(
750 parent.is_dir(),
751 "parent dir must survive when another patch still exists"
752 );
753 assert!(has_patch(&entry_b, dir.path()));
754 }
755
756 #[test]
759 fn remove_dir_patch_nonexistent_is_noop() {
760 let dir = tempfile::tempdir().unwrap();
761 let entry = github_entry("ghost-skill", EntityType::Skill);
762 remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
764 }
765
766 #[test]
769 fn remove_dir_patch_cleans_up_empty_entry_dir() {
770 let dir = tempfile::tempdir().unwrap();
771 let entry = github_entry("lang-pro", EntityType::Skill);
772 write_dir_patch(
773 &dir_patch_path(&entry, "python.md", dir.path()),
774 "patch text\n",
775 )
776 .unwrap();
777
778 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
780 assert!(
781 entry_dir.is_dir(),
782 "entry dir should exist after write_dir_patch"
783 );
784
785 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
786
787 assert!(
789 !entry_dir.exists(),
790 "entry dir should be removed when it becomes empty"
791 );
792 }
793
794 #[test]
797 fn remove_dir_patch_keeps_entry_dir_when_nonempty() {
798 let dir = tempfile::tempdir().unwrap();
799 let entry = github_entry("lang-pro", EntityType::Skill);
800 write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1\n").unwrap();
801 write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2\n").unwrap();
802
803 let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
804 remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
805
806 assert!(
808 entry_dir.is_dir(),
809 "entry dir must survive when another patch still exists"
810 );
811 }
812
813 #[test]
816 fn generate_patch_no_trailing_newline_original() {
817 let p = generate_patch("old text", "new text\n", "test.md");
819 assert!(!p.is_empty(), "patch should not be empty");
820 for seg in p.split_inclusive('\n') {
821 assert!(
822 seg.ends_with('\n'),
823 "every output line must end with \\n, got: {seg:?}"
824 );
825 }
826 }
827
828 #[test]
829 fn generate_patch_no_trailing_newline_modified() {
830 let p = generate_patch("old text\n", "new text", "test.md");
832 assert!(!p.is_empty(), "patch should not be empty");
833 for seg in p.split_inclusive('\n') {
834 assert!(
835 seg.ends_with('\n'),
836 "every output line must end with \\n, got: {seg:?}"
837 );
838 }
839 }
840
841 #[test]
842 fn generate_patch_both_inputs_no_trailing_newline() {
843 let p = generate_patch("old line", "new line", "test.md");
845 assert!(!p.is_empty(), "patch should not be empty");
846 for seg in p.split_inclusive('\n') {
847 assert!(
848 seg.ends_with('\n'),
849 "every output line must end with \\n, got: {seg:?}"
850 );
851 }
852 }
853
854 #[test]
855 fn generate_patch_no_trailing_newline_roundtrip() {
856 let orig = "line one\nline two";
857 let modified = "line one\nline changed";
858 let patch = generate_patch(orig, modified, "test.md");
859 assert!(!patch.is_empty());
860 assert!(patch.contains("\\ No newline at end of file"));
861 let result = apply_patch_pure(orig, &patch).unwrap();
862 assert_eq!(result, modified);
863 }
864
865 #[test]
866 fn generate_patch_normalizes_crlf() {
867 let orig = "line one\r\nline two\r\n";
868 let modified = "line one\r\nline changed\r\n";
869 let patch = generate_patch(orig, modified, "test.md");
870 assert!(!patch.is_empty());
871 assert!(!patch.contains('\r'));
872 let result = apply_patch_pure(orig, &patch).unwrap();
873 assert_eq!(result, "line one\nline changed\n");
874 }
875
876 #[test]
877 fn generate_patch_ignores_crlf_only_changes() {
878 assert!(generate_patch("line one\r\n", "line one\n", "test.md").is_empty());
879 }
880
881 #[test]
882 fn text_content_eq_normalizes_only_crlf() {
883 assert!(text_content_eq(
884 "line one\r\nline two\r\n",
885 "line one\nline two\n"
886 ));
887 assert!(!text_content_eq(
888 "line one\nline two",
889 "line one\nline two\n"
890 ));
891 }
892
893 #[test]
896 fn apply_patch_pure_with_no_newline_marker() {
897 let orig = "line1\nline2\n";
900 let patch = concat!(
901 "--- a/test.md\n",
902 "+++ b/test.md\n",
903 "@@ -1,2 +1,2 @@\n",
904 " line1\n",
905 "-line2\n",
906 "+changed\n",
907 "\\ No newline at end of file\n",
908 );
909 let result = apply_patch_pure(orig, patch).unwrap();
910 assert_eq!(result, "line1\nchanged");
911 }
912
913 #[test]
916 fn walkdir_empty_directory_returns_empty() {
917 let dir = tempfile::tempdir().unwrap();
918 let files = walkdir(dir.path());
919 assert!(
920 files.is_empty(),
921 "walkdir of empty dir should return empty vec"
922 );
923 }
924
925 #[test]
926 fn walkdir_nonexistent_directory_returns_empty() {
927 let path = Path::new("/tmp/skillfile_test_does_not_exist_xyz_9999");
928 let files = walkdir(path);
929 assert!(
930 files.is_empty(),
931 "walkdir of non-existent dir should return empty vec"
932 );
933 }
934
935 #[test]
936 fn walkdir_nested_subdirectories() {
937 let dir = tempfile::tempdir().unwrap();
938 let sub = dir.path().join("sub");
939 std::fs::create_dir_all(&sub).unwrap();
940 std::fs::write(dir.path().join("top.txt"), "top").unwrap();
941 std::fs::write(sub.join("nested.txt"), "nested").unwrap();
942
943 let files = walkdir(dir.path());
944 assert_eq!(files.len(), 2, "should find both files");
945
946 let names: Vec<String> = files
947 .iter()
948 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
949 .collect();
950 assert!(names.contains(&"top.txt".to_string()));
951 assert!(names.contains(&"nested.txt".to_string()));
952 }
953
954 #[cfg(unix)]
955 #[test]
956 fn walkdir_skips_symlinked_files_and_directories() {
957 use std::os::unix::fs::symlink;
958
959 let dir = tempfile::tempdir().unwrap();
960 let source = dir.path().join("source");
961 let outside = dir.path().join("outside");
962 std::fs::create_dir_all(&source).unwrap();
963 std::fs::create_dir_all(&outside).unwrap();
964 std::fs::write(source.join("real.md"), "real").unwrap();
965 std::fs::write(outside.join("secret.md"), "secret").unwrap();
966 symlink(outside.join("secret.md"), source.join("linked-file.md")).unwrap();
967 symlink(&outside, source.join("linked-dir")).unwrap();
968
969 assert_eq!(walkdir(&source), vec![source.join("real.md")]);
970 }
971
972 #[test]
973 fn walkdir_results_are_sorted() {
974 let dir = tempfile::tempdir().unwrap();
975 std::fs::write(dir.path().join("z.txt"), "z").unwrap();
976 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
977 std::fs::write(dir.path().join("m.txt"), "m").unwrap();
978
979 let files = walkdir(dir.path());
980 let sorted = {
981 let mut v = files.clone();
982 v.sort();
983 v
984 };
985 assert_eq!(files, sorted, "walkdir results must be sorted");
986 }
987
988 #[test]
991 fn apply_patch_pure_handles_crlf_original() {
992 let orig_lf = "line1\nline2\nline3\n";
993 let modified = "line1\nchanged\nline3\n";
994 let patch = generate_patch(orig_lf, modified, "test.md");
995
996 let orig_crlf = "line1\r\nline2\r\nline3\r\n";
998 let result = apply_patch_pure(orig_crlf, &patch).unwrap();
999 assert_eq!(result, modified);
1000 }
1001
1002 #[test]
1003 fn apply_patch_pure_handles_crlf_patch() {
1004 let orig = "line1\nline2\nline3\n";
1005 let modified = "line1\nchanged\nline3\n";
1006 let patch_lf = generate_patch(orig, modified, "test.md");
1007
1008 let patch_crlf = patch_lf.replace('\n', "\r\n");
1010 let result = apply_patch_pure(orig, &patch_crlf).unwrap();
1011 assert_eq!(result, modified);
1012 }
1013
1014 #[test]
1017 fn apply_patch_pure_fuzzy_hunk_matching() {
1018 use std::fmt::Write;
1019 let mut orig = String::new();
1021 for i in 1..=20 {
1022 let _ = writeln!(orig, "line{i}");
1023 }
1024
1025 let patch = concat!(
1029 "--- a/test.md\n",
1030 "+++ b/test.md\n",
1031 "@@ -5,3 +5,3 @@\n", " line7\n",
1033 "-line8\n",
1034 "+CHANGED8\n",
1035 " line9\n",
1036 );
1037
1038 let result = apply_patch_pure(&orig, patch).unwrap();
1039 assert!(
1040 result.contains("CHANGED8\n"),
1041 "fuzzy match should have applied the change"
1042 );
1043 assert!(
1044 !result.contains("line8\n"),
1045 "original line8 should have been replaced"
1046 );
1047 }
1048
1049 #[test]
1052 fn apply_patch_pure_extends_beyond_eof_errors() {
1053 let orig = "line1\nline2\n";
1062 let patch = concat!(
1063 "--- a/test.md\n",
1064 "+++ b/test.md\n",
1065 "@@ -999,1 +999,1 @@\n",
1066 "-nonexistent_line\n",
1067 "+replacement\n",
1068 );
1069 let result = apply_patch_pure(orig, patch);
1070 assert!(
1071 result.is_err(),
1072 "applying a patch beyond EOF should return an error"
1073 );
1074 }
1075}