Skip to main content

skillfile_core/
patch.rs

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// ---------------------------------------------------------------------------
10// Path helpers
11// ---------------------------------------------------------------------------
12
13/// Build the portable key used for a file within a directory entry.
14///
15/// Keys always use forward slashes so cache, installed, and patch maps agree
16/// across platforms.
17#[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
29/// Path to the patch file for a single-file entry.
30/// e.g. `.skillfile/patches/agents/my-agent.patch`
31pub 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
60/// Remove the patch file for a single-file entry. No-op if it doesn't exist.
61pub 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
71// ---------------------------------------------------------------------------
72// Directory entry patches (one .patch file per modified file)
73// ---------------------------------------------------------------------------
74
75/// Path to a per-file patch within a directory entry.
76/// e.g. `.skillfile/patches/skills/architecture-patterns/SKILL.md.patch`
77pub 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
129// ---------------------------------------------------------------------------
130// Internal helpers
131// ---------------------------------------------------------------------------
132
133/// Remove `path`'s parent directory if it exists and is now empty. No-op otherwise.
134fn 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
147// ---------------------------------------------------------------------------
148// Diff generation
149// ---------------------------------------------------------------------------
150
151fn 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/// Compare text using the line-ending normalization applied by patches.
160/// A missing final newline remains significant.
161#[must_use]
162pub fn text_content_eq(left: &str, right: &str) -> bool {
163    normalize_crlf(left) == normalize_crlf(right)
164}
165
166/// Generate a unified diff of original → modified. Empty string if identical.
167/// All output lines are guaranteed to end with '\n'.
168/// Format: `--- a/{label}` / `+++ b/{label}`, 3 lines of context.
169///
170/// ```
171/// use skillfile_core::patch::generate_patch;
172///
173/// // Identical content produces no patch
174/// assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
175///
176/// // Different content produces a unified diff
177/// let patch = generate_patch("old\n", "new\n", "test.md");
178/// assert!(patch.contains("--- a/test.md"));
179/// assert!(patch.contains("+++ b/test.md"));
180/// ```
181pub 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    // Keep standard missing-newline markers so application can reconstruct the
205    // exact final-newline state. Patch-file lines themselves always end in \n.
206    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
214/// Process one line from a raw unified-diff output into `result`.
215///
216/// Every patch-file line is guaranteed to end with `'\n'`.
217fn 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
224// ---------------------------------------------------------------------------
225// Patch application (pure Rust, no subprocess)
226// ---------------------------------------------------------------------------
227
228struct Hunk {
229    orig_start: usize, // 1-based line number from @@ header
230    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    // Skip file headers (--- / +++ lines)
238    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        // Parse hunk header: @@ -l[,s] +l[,s] @@
252        // We only need orig_start (the -l part)
253        let orig_start = pl
254            .split_whitespace()
255            .nth(1) // "-l[,s]"
256            .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
305/// Groups the search inputs for hunk-position lookup to stay within the 3-argument limit.
306struct HunkSearch<'a> {
307    lines: &'a [String],
308    min_pos: usize,
309}
310
311impl HunkSearch<'_> {
312    /// Scan outward from `center` within ±100 lines for a position where the hunk context matches.
313    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
347/// State threaded through hunk application in [`apply_patch_pure`].
348struct 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            _ => {} // context beyond EOF or unrecognized — skip
375        }
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
385/// Apply a unified diff to original text, returning modified content.
386/// Pure implementation — no subprocess, no `patch` binary required.
387/// Only handles patches produced by [`generate_patch()`] (unified diff format).
388/// Returns an error if the patch does not apply cleanly.
389///
390/// ```
391/// use skillfile_core::patch::{generate_patch, apply_patch_pure};
392///
393/// let original = "line1\nline2\nline3\n";
394/// let modified = "line1\nchanged\nline3\n";
395/// let patch = generate_patch(original, modified, "test.md");
396/// let result = apply_patch_pure(original, &patch).unwrap();
397/// assert_eq!(result, modified);
398/// ```
399pub 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    // Normalize CRLF to LF so patches apply cleanly on Windows.
405    let original = normalize_crlf(original);
406    let patch_text = normalize_crlf(patch_text);
407
408    // Split into lines preserving newlines (like Python's splitlines(keepends=True))
409    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        // Build context: lines with ' ' or '-' prefix, stripped of prefix and trailing \n
418        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        // Copy unchanged lines before this hunk
433        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    // Copy remaining lines
442    state.output.extend_from_slice(&lines[state.pos..]);
443    Ok(state.output.concat())
444}
445
446// ---------------------------------------------------------------------------
447// Directory walking helper
448// ---------------------------------------------------------------------------
449
450/// Recursively list all files under a directory, sorted.
451#[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// ---------------------------------------------------------------------------
486// Tests
487// ---------------------------------------------------------------------------
488
489#[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    // --- generate_patch ---
533
534    #[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    // --- apply_patch_pure ---
567
568    #[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    // --- Patch path helpers ---
627
628    #[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    // --- remove_patch: no-op when patch does not exist ---
699
700    #[test]
701    fn remove_patch_nonexistent_is_noop() {
702        let dir = tempfile::tempdir().unwrap();
703        let entry = github_entry("ghost-agent", EntityType::Agent);
704        // No patch was written — remove_patch must return Ok without panicking.
705        assert!(!has_patch(&entry, dir.path()));
706        remove_patch(&entry, dir.path()).unwrap();
707        assert!(!has_patch(&entry, dir.path()));
708    }
709
710    // --- remove_patch: parent directory cleaned up when empty ---
711
712    #[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        // Confirm that the parent directory (.skillfile/patches/skills/) was created.
719        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        // The patch file and the now-empty parent dir should both be gone.
725        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    // --- remove_patch: parent directory NOT cleaned up when non-empty ---
736
737    #[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        // skill-b.patch still lives there — parent dir must NOT be removed.
749        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    // --- remove_dir_patch: no-op when patch does not exist ---
757
758    #[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        // No patch was written — must return Ok without panicking.
763        remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
764    }
765
766    // --- remove_dir_patch: entry-specific directory cleaned up when empty ---
767
768    #[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        // The entry-specific directory (.skillfile/patches/skills/lang-pro/) should exist.
779        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        // The single patch is gone — the entry dir should be removed too.
788        assert!(
789            !entry_dir.exists(),
790            "entry dir should be removed when it becomes empty"
791        );
792    }
793
794    // --- remove_dir_patch: entry-specific directory kept when non-empty ---
795
796    #[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        // typescript.md.patch still exists — entry dir must be kept.
807        assert!(
808            entry_dir.is_dir(),
809            "entry dir must survive when another patch still exists"
810        );
811    }
812
813    // --- generate_patch: inputs without trailing newline ---
814
815    #[test]
816    fn generate_patch_no_trailing_newline_original() {
817        // original has no trailing \n; all output lines must still end with \n.
818        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        // modified has no trailing \n; all output lines must still end with \n.
831        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        // Neither original nor modified ends with \n.
844        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    // --- apply_patch_pure: "\ No newline at end of file" marker in patch ---
894
895    #[test]
896    fn apply_patch_pure_with_no_newline_marker() {
897        // A patch that was generated externally may contain the "\ No newline at
898        // end of file" marker.  parse_hunks() must skip it cleanly.
899        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    // --- walkdir: edge cases ---
914
915    #[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    // --- apply_patch_pure: CRLF handling ---
989
990    #[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        // Apply the LF-generated patch to a CRLF original
997        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        // Convert patch itself to CRLF
1009        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    // --- apply_patch_pure: fuzzy hunk matching ---
1015
1016    #[test]
1017    fn apply_patch_pure_fuzzy_hunk_matching() {
1018        use std::fmt::Write;
1019        // Build an original with 20 lines.
1020        let mut orig = String::new();
1021        for i in 1..=20 {
1022            let _ = writeln!(orig, "line{i}");
1023        }
1024
1025        // Construct a patch whose hunk header claims the context starts at line 5
1026        // (1-based), but the actual content we want to change is at line 7.
1027        // find_hunk_position will search ±100 lines and should find the match.
1028        let patch = concat!(
1029            "--- a/test.md\n",
1030            "+++ b/test.md\n",
1031            "@@ -5,3 +5,3 @@\n", // header says line 5, but context matches line 7
1032            " 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    // --- apply_patch_pure: patch extends beyond end of file ---
1050
1051    #[test]
1052    fn apply_patch_pure_extends_beyond_eof_errors() {
1053        // A patch with an empty context list and hunk start beyond the file length
1054        // triggers the "patch extends beyond end of file" error path in
1055        // find_hunk_position when ctx_lines is empty.
1056        //
1057        // We craft a hunk header that places the hunk at line 999 of a 2-line file
1058        // and supply a context line that won't match anywhere — this exercises the
1059        // "context mismatch" branch (which is what fires when ctx_lines is non-empty
1060        // and nothing is found within ±100 of the declared position).
1061        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}