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(entries) = std::fs::read_dir(dir) else {
461        return;
462    };
463    for entry in entries.flatten() {
464        let path = entry.path();
465        if path.is_dir() {
466            walkdir_inner(&path, result);
467        } else {
468            result.push(path);
469        }
470    }
471}
472
473// ---------------------------------------------------------------------------
474// Tests
475// ---------------------------------------------------------------------------
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::models::{EntityType, SourceFields};
481
482    fn github_entry(name: &str, entity_type: EntityType) -> Entry {
483        Entry {
484            entity_type,
485            name: name.to_string(),
486            source: SourceFields::Github {
487                owner_repo: "owner/repo".into(),
488                path_in_repo: "agents/test.md".into(),
489                ref_: "main".into(),
490            },
491        }
492    }
493
494    #[test]
495    fn relative_file_key_keeps_forward_slashes() {
496        let base = Path::new("cache");
497        assert_eq!(
498            relative_file_key(base, &base.join("nested/file.md")),
499            Some("nested/file.md".to_string())
500        );
501    }
502
503    #[test]
504    fn relative_file_key_normalizes_backslashes() {
505        let base = Path::new("cache");
506        assert_eq!(
507            relative_file_key(base, &base.join(r"nested\file.md")),
508            Some("nested/file.md".to_string())
509        );
510    }
511
512    #[test]
513    fn relative_file_key_rejects_paths_outside_base() {
514        assert_eq!(
515            relative_file_key(Path::new("cache"), Path::new("other/file.md")),
516            None
517        );
518    }
519
520    // --- generate_patch ---
521
522    #[test]
523    fn generate_patch_identical_returns_empty() {
524        assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
525    }
526
527    #[test]
528    fn generate_patch_has_headers() {
529        let p = generate_patch("old\n", "new\n", "test.md");
530        assert!(p.contains("--- a/test.md"), "missing fromfile header");
531        assert!(p.contains("+++ b/test.md"), "missing tofile header");
532    }
533
534    #[test]
535    fn generate_patch_add_line() {
536        let p = generate_patch("line1\n", "line1\nline2\n", "test.md");
537        assert!(p.contains("+line2"));
538    }
539
540    #[test]
541    fn generate_patch_remove_line() {
542        let p = generate_patch("line1\nline2\n", "line1\n", "test.md");
543        assert!(p.contains("-line2"));
544    }
545
546    #[test]
547    fn generate_patch_all_lines_end_with_newline() {
548        let p = generate_patch("a\nb\n", "a\nc\n", "test.md");
549        for seg in p.split_inclusive('\n') {
550            assert!(seg.ends_with('\n'), "line does not end with \\n: {seg:?}");
551        }
552    }
553
554    // --- apply_patch_pure ---
555
556    #[test]
557    fn apply_patch_empty_patch_returns_original() {
558        let result = apply_patch_pure("hello\n", "").unwrap();
559        assert_eq!(result, "hello\n");
560    }
561
562    #[test]
563    fn apply_patch_round_trip_add_line() {
564        let orig = "line1\nline2\n";
565        let modified = "line1\nline2\nline3\n";
566        let patch = generate_patch(orig, modified, "test.md");
567        let result = apply_patch_pure(orig, &patch).unwrap();
568        assert_eq!(result, modified);
569    }
570
571    #[test]
572    fn apply_patch_round_trip_remove_line() {
573        let orig = "line1\nline2\nline3\n";
574        let modified = "line1\nline3\n";
575        let patch = generate_patch(orig, modified, "test.md");
576        let result = apply_patch_pure(orig, &patch).unwrap();
577        assert_eq!(result, modified);
578    }
579
580    #[test]
581    fn apply_patch_round_trip_modify_line() {
582        let orig = "# Title\n\nSome text here.\n";
583        let modified = "# Title\n\nSome modified text here.\n";
584        let patch = generate_patch(orig, modified, "test.md");
585        let result = apply_patch_pure(orig, &patch).unwrap();
586        assert_eq!(result, modified);
587    }
588
589    #[test]
590    fn apply_patch_multi_hunk() {
591        use std::fmt::Write;
592        let mut orig = String::new();
593        for i in 0..20 {
594            let _ = writeln!(orig, "line{i}");
595        }
596        let mut modified = orig.clone();
597        modified = modified.replace("line2\n", "MODIFIED2\n");
598        modified = modified.replace("line15\n", "MODIFIED15\n");
599        let patch = generate_patch(&orig, &modified, "test.md");
600        assert!(patch.contains("@@"), "should have hunk headers");
601        let result = apply_patch_pure(&orig, &patch).unwrap();
602        assert_eq!(result, modified);
603    }
604
605    #[test]
606    fn apply_patch_context_mismatch_errors() {
607        let orig = "line1\nline2\n";
608        let patch = "--- a/test.md\n+++ b/test.md\n@@ -1,2 +1,2 @@\n-totally_wrong\n+new\n";
609        let result = apply_patch_pure(orig, patch);
610        assert!(result.is_err());
611        assert!(result.unwrap_err().to_string().contains("context mismatch"));
612    }
613
614    // --- Patch path helpers ---
615
616    #[test]
617    fn patch_path_single_file_agent() {
618        let entry = github_entry("my-agent", EntityType::Agent);
619        let root = Path::new("/repo");
620        let p = patch_path(&entry, root);
621        assert_eq!(
622            p,
623            Path::new("/repo/.skillfile/patches/agents/my-agent.patch")
624        );
625    }
626
627    #[test]
628    fn patch_path_single_file_skill() {
629        let entry = github_entry("my-skill", EntityType::Skill);
630        let root = Path::new("/repo");
631        let p = patch_path(&entry, root);
632        assert_eq!(
633            p,
634            Path::new("/repo/.skillfile/patches/skills/my-skill.patch")
635        );
636    }
637
638    #[test]
639    fn dir_patch_path_returns_correct() {
640        let entry = github_entry("lang-pro", EntityType::Skill);
641        let root = Path::new("/repo");
642        let p = dir_patch_path(&entry, "python.md", root);
643        assert_eq!(
644            p,
645            Path::new("/repo/.skillfile/patches/skills/lang-pro/python.md.patch")
646        );
647    }
648
649    #[test]
650    fn write_read_remove_patch_round_trip() {
651        let dir = tempfile::tempdir().unwrap();
652        let entry = github_entry("test-agent", EntityType::Agent);
653        let patch_text = "--- a/test-agent.md\n+++ b/test-agent.md\n@@ -1 +1 @@\n-old\n+new\n";
654        write_patch(&entry, patch_text, dir.path()).unwrap();
655        assert!(has_patch(&entry, dir.path()));
656        let read = read_patch(&entry, dir.path()).unwrap();
657        assert_eq!(read, patch_text);
658        remove_patch(&entry, dir.path()).unwrap();
659        assert!(!has_patch(&entry, dir.path()));
660    }
661
662    #[test]
663    fn has_dir_patch_detects_patches() {
664        let dir = tempfile::tempdir().unwrap();
665        let entry = github_entry("lang-pro", EntityType::Skill);
666        assert!(!has_dir_patch(&entry, dir.path()));
667        write_dir_patch(
668            &dir_patch_path(&entry, "python.md", dir.path()),
669            "patch content",
670        )
671        .unwrap();
672        assert!(has_dir_patch(&entry, dir.path()));
673    }
674
675    #[test]
676    fn remove_all_dir_patches_clears_dir() {
677        let dir = tempfile::tempdir().unwrap();
678        let entry = github_entry("lang-pro", EntityType::Skill);
679        write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1").unwrap();
680        write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2").unwrap();
681        assert!(has_dir_patch(&entry, dir.path()));
682        remove_all_dir_patches(&entry, dir.path()).unwrap();
683        assert!(!has_dir_patch(&entry, dir.path()));
684    }
685
686    // --- remove_patch: no-op when patch does not exist ---
687
688    #[test]
689    fn remove_patch_nonexistent_is_noop() {
690        let dir = tempfile::tempdir().unwrap();
691        let entry = github_entry("ghost-agent", EntityType::Agent);
692        // No patch was written — remove_patch must return Ok without panicking.
693        assert!(!has_patch(&entry, dir.path()));
694        remove_patch(&entry, dir.path()).unwrap();
695        assert!(!has_patch(&entry, dir.path()));
696    }
697
698    // --- remove_patch: parent directory cleaned up when empty ---
699
700    #[test]
701    fn remove_patch_cleans_up_empty_parent_dir() {
702        let dir = tempfile::tempdir().unwrap();
703        let entry = github_entry("solo-skill", EntityType::Skill);
704        write_patch(&entry, "some patch text\n", dir.path()).unwrap();
705
706        // Confirm that the parent directory (.skillfile/patches/skills/) was created.
707        let parent = patches_root(dir.path()).join("skills");
708        assert!(parent.is_dir(), "parent dir should exist after write_patch");
709
710        remove_patch(&entry, dir.path()).unwrap();
711
712        // The patch file and the now-empty parent dir should both be gone.
713        assert!(
714            !has_patch(&entry, dir.path()),
715            "patch file should be removed"
716        );
717        assert!(
718            !parent.exists(),
719            "empty parent dir should be removed after last patch is deleted"
720        );
721    }
722
723    // --- remove_patch: parent directory NOT cleaned up when non-empty ---
724
725    #[test]
726    fn remove_patch_keeps_parent_dir_when_nonempty() {
727        let dir = tempfile::tempdir().unwrap();
728        let entry_a = github_entry("skill-a", EntityType::Skill);
729        let entry_b = github_entry("skill-b", EntityType::Skill);
730        write_patch(&entry_a, "patch a\n", dir.path()).unwrap();
731        write_patch(&entry_b, "patch b\n", dir.path()).unwrap();
732
733        let parent = patches_root(dir.path()).join("skills");
734        remove_patch(&entry_a, dir.path()).unwrap();
735
736        // skill-b.patch still lives there — parent dir must NOT be removed.
737        assert!(
738            parent.is_dir(),
739            "parent dir must survive when another patch still exists"
740        );
741        assert!(has_patch(&entry_b, dir.path()));
742    }
743
744    // --- remove_dir_patch: no-op when patch does not exist ---
745
746    #[test]
747    fn remove_dir_patch_nonexistent_is_noop() {
748        let dir = tempfile::tempdir().unwrap();
749        let entry = github_entry("ghost-skill", EntityType::Skill);
750        // No patch was written — must return Ok without panicking.
751        remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
752    }
753
754    // --- remove_dir_patch: entry-specific directory cleaned up when empty ---
755
756    #[test]
757    fn remove_dir_patch_cleans_up_empty_entry_dir() {
758        let dir = tempfile::tempdir().unwrap();
759        let entry = github_entry("lang-pro", EntityType::Skill);
760        write_dir_patch(
761            &dir_patch_path(&entry, "python.md", dir.path()),
762            "patch text\n",
763        )
764        .unwrap();
765
766        // The entry-specific directory (.skillfile/patches/skills/lang-pro/) should exist.
767        let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
768        assert!(
769            entry_dir.is_dir(),
770            "entry dir should exist after write_dir_patch"
771        );
772
773        remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
774
775        // The single patch is gone — the entry dir should be removed too.
776        assert!(
777            !entry_dir.exists(),
778            "entry dir should be removed when it becomes empty"
779        );
780    }
781
782    // --- remove_dir_patch: entry-specific directory kept when non-empty ---
783
784    #[test]
785    fn remove_dir_patch_keeps_entry_dir_when_nonempty() {
786        let dir = tempfile::tempdir().unwrap();
787        let entry = github_entry("lang-pro", EntityType::Skill);
788        write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1\n").unwrap();
789        write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2\n").unwrap();
790
791        let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
792        remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
793
794        // typescript.md.patch still exists — entry dir must be kept.
795        assert!(
796            entry_dir.is_dir(),
797            "entry dir must survive when another patch still exists"
798        );
799    }
800
801    // --- generate_patch: inputs without trailing newline ---
802
803    #[test]
804    fn generate_patch_no_trailing_newline_original() {
805        // original has no trailing \n; all output lines must still end with \n.
806        let p = generate_patch("old text", "new text\n", "test.md");
807        assert!(!p.is_empty(), "patch should not be empty");
808        for seg in p.split_inclusive('\n') {
809            assert!(
810                seg.ends_with('\n'),
811                "every output line must end with \\n, got: {seg:?}"
812            );
813        }
814    }
815
816    #[test]
817    fn generate_patch_no_trailing_newline_modified() {
818        // modified has no trailing \n; all output lines must still end with \n.
819        let p = generate_patch("old text\n", "new text", "test.md");
820        assert!(!p.is_empty(), "patch should not be empty");
821        for seg in p.split_inclusive('\n') {
822            assert!(
823                seg.ends_with('\n'),
824                "every output line must end with \\n, got: {seg:?}"
825            );
826        }
827    }
828
829    #[test]
830    fn generate_patch_both_inputs_no_trailing_newline() {
831        // Neither original nor modified ends with \n.
832        let p = generate_patch("old line", "new line", "test.md");
833        assert!(!p.is_empty(), "patch should not be empty");
834        for seg in p.split_inclusive('\n') {
835            assert!(
836                seg.ends_with('\n'),
837                "every output line must end with \\n, got: {seg:?}"
838            );
839        }
840    }
841
842    #[test]
843    fn generate_patch_no_trailing_newline_roundtrip() {
844        let orig = "line one\nline two";
845        let modified = "line one\nline changed";
846        let patch = generate_patch(orig, modified, "test.md");
847        assert!(!patch.is_empty());
848        assert!(patch.contains("\\ No newline at end of file"));
849        let result = apply_patch_pure(orig, &patch).unwrap();
850        assert_eq!(result, modified);
851    }
852
853    #[test]
854    fn generate_patch_normalizes_crlf() {
855        let orig = "line one\r\nline two\r\n";
856        let modified = "line one\r\nline changed\r\n";
857        let patch = generate_patch(orig, modified, "test.md");
858        assert!(!patch.is_empty());
859        assert!(!patch.contains('\r'));
860        let result = apply_patch_pure(orig, &patch).unwrap();
861        assert_eq!(result, "line one\nline changed\n");
862    }
863
864    #[test]
865    fn generate_patch_ignores_crlf_only_changes() {
866        assert!(generate_patch("line one\r\n", "line one\n", "test.md").is_empty());
867    }
868
869    #[test]
870    fn text_content_eq_normalizes_only_crlf() {
871        assert!(text_content_eq(
872            "line one\r\nline two\r\n",
873            "line one\nline two\n"
874        ));
875        assert!(!text_content_eq(
876            "line one\nline two",
877            "line one\nline two\n"
878        ));
879    }
880
881    // --- apply_patch_pure: "\ No newline at end of file" marker in patch ---
882
883    #[test]
884    fn apply_patch_pure_with_no_newline_marker() {
885        // A patch that was generated externally may contain the "\ No newline at
886        // end of file" marker.  parse_hunks() must skip it cleanly.
887        let orig = "line1\nline2\n";
888        let patch = concat!(
889            "--- a/test.md\n",
890            "+++ b/test.md\n",
891            "@@ -1,2 +1,2 @@\n",
892            " line1\n",
893            "-line2\n",
894            "+changed\n",
895            "\\ No newline at end of file\n",
896        );
897        let result = apply_patch_pure(orig, patch).unwrap();
898        assert_eq!(result, "line1\nchanged");
899    }
900
901    // --- walkdir: edge cases ---
902
903    #[test]
904    fn walkdir_empty_directory_returns_empty() {
905        let dir = tempfile::tempdir().unwrap();
906        let files = walkdir(dir.path());
907        assert!(
908            files.is_empty(),
909            "walkdir of empty dir should return empty vec"
910        );
911    }
912
913    #[test]
914    fn walkdir_nonexistent_directory_returns_empty() {
915        let path = Path::new("/tmp/skillfile_test_does_not_exist_xyz_9999");
916        let files = walkdir(path);
917        assert!(
918            files.is_empty(),
919            "walkdir of non-existent dir should return empty vec"
920        );
921    }
922
923    #[test]
924    fn walkdir_nested_subdirectories() {
925        let dir = tempfile::tempdir().unwrap();
926        let sub = dir.path().join("sub");
927        std::fs::create_dir_all(&sub).unwrap();
928        std::fs::write(dir.path().join("top.txt"), "top").unwrap();
929        std::fs::write(sub.join("nested.txt"), "nested").unwrap();
930
931        let files = walkdir(dir.path());
932        assert_eq!(files.len(), 2, "should find both files");
933
934        let names: Vec<String> = files
935            .iter()
936            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
937            .collect();
938        assert!(names.contains(&"top.txt".to_string()));
939        assert!(names.contains(&"nested.txt".to_string()));
940    }
941
942    #[test]
943    fn walkdir_results_are_sorted() {
944        let dir = tempfile::tempdir().unwrap();
945        std::fs::write(dir.path().join("z.txt"), "z").unwrap();
946        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
947        std::fs::write(dir.path().join("m.txt"), "m").unwrap();
948
949        let files = walkdir(dir.path());
950        let sorted = {
951            let mut v = files.clone();
952            v.sort();
953            v
954        };
955        assert_eq!(files, sorted, "walkdir results must be sorted");
956    }
957
958    // --- apply_patch_pure: CRLF handling ---
959
960    #[test]
961    fn apply_patch_pure_handles_crlf_original() {
962        let orig_lf = "line1\nline2\nline3\n";
963        let modified = "line1\nchanged\nline3\n";
964        let patch = generate_patch(orig_lf, modified, "test.md");
965
966        // Apply the LF-generated patch to a CRLF original
967        let orig_crlf = "line1\r\nline2\r\nline3\r\n";
968        let result = apply_patch_pure(orig_crlf, &patch).unwrap();
969        assert_eq!(result, modified);
970    }
971
972    #[test]
973    fn apply_patch_pure_handles_crlf_patch() {
974        let orig = "line1\nline2\nline3\n";
975        let modified = "line1\nchanged\nline3\n";
976        let patch_lf = generate_patch(orig, modified, "test.md");
977
978        // Convert patch itself to CRLF
979        let patch_crlf = patch_lf.replace('\n', "\r\n");
980        let result = apply_patch_pure(orig, &patch_crlf).unwrap();
981        assert_eq!(result, modified);
982    }
983
984    // --- apply_patch_pure: fuzzy hunk matching ---
985
986    #[test]
987    fn apply_patch_pure_fuzzy_hunk_matching() {
988        use std::fmt::Write;
989        // Build an original with 20 lines.
990        let mut orig = String::new();
991        for i in 1..=20 {
992            let _ = writeln!(orig, "line{i}");
993        }
994
995        // Construct a patch whose hunk header claims the context starts at line 5
996        // (1-based), but the actual content we want to change is at line 7.
997        // find_hunk_position will search ±100 lines and should find the match.
998        let patch = concat!(
999            "--- a/test.md\n",
1000            "+++ b/test.md\n",
1001            "@@ -5,3 +5,3 @@\n", // header says line 5, but context matches line 7
1002            " line7\n",
1003            "-line8\n",
1004            "+CHANGED8\n",
1005            " line9\n",
1006        );
1007
1008        let result = apply_patch_pure(&orig, patch).unwrap();
1009        assert!(
1010            result.contains("CHANGED8\n"),
1011            "fuzzy match should have applied the change"
1012        );
1013        assert!(
1014            !result.contains("line8\n"),
1015            "original line8 should have been replaced"
1016        );
1017    }
1018
1019    // --- apply_patch_pure: patch extends beyond end of file ---
1020
1021    #[test]
1022    fn apply_patch_pure_extends_beyond_eof_errors() {
1023        // A patch with an empty context list and hunk start beyond the file length
1024        // triggers the "patch extends beyond end of file" error path in
1025        // find_hunk_position when ctx_lines is empty.
1026        //
1027        // We craft a hunk header that places the hunk at line 999 of a 2-line file
1028        // and supply a context line that won't match anywhere — this exercises the
1029        // "context mismatch" branch (which is what fires when ctx_lines is non-empty
1030        // and nothing is found within ±100 of the declared position).
1031        let orig = "line1\nline2\n";
1032        let patch = concat!(
1033            "--- a/test.md\n",
1034            "+++ b/test.md\n",
1035            "@@ -999,1 +999,1 @@\n",
1036            "-nonexistent_line\n",
1037            "+replacement\n",
1038        );
1039        let result = apply_patch_pure(orig, patch);
1040        assert!(
1041            result.is_err(),
1042            "applying a patch beyond EOF should return an error"
1043        );
1044    }
1045}