Skip to main content

skillfile_core/
patch.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::SkillfileError;
4use crate::models::Entry;
5
6pub const PATCHES_DIR: &str = ".skillfile/patches";
7
8// ---------------------------------------------------------------------------
9// Path helpers
10// ---------------------------------------------------------------------------
11
12#[must_use]
13pub fn patches_root(repo_root: &Path) -> PathBuf {
14    repo_root.join(PATCHES_DIR)
15}
16
17/// Path to the patch file for a single-file entry.
18/// e.g. `.skillfile/patches/agents/my-agent.patch`
19pub fn patch_path(entry: &Entry, repo_root: &Path) -> PathBuf {
20    patches_root(repo_root)
21        .join(entry.entity_type.dir_name())
22        .join(format!("{}.patch", entry.name))
23}
24
25#[must_use]
26pub fn has_patch(entry: &Entry, repo_root: &Path) -> bool {
27    patch_path(entry, repo_root).exists()
28}
29
30pub fn write_patch(
31    entry: &Entry,
32    patch_text: &str,
33    repo_root: &Path,
34) -> Result<(), SkillfileError> {
35    let p = patch_path(entry, repo_root);
36    if let Some(parent) = p.parent() {
37        std::fs::create_dir_all(parent)?;
38    }
39    std::fs::write(&p, patch_text)?;
40    Ok(())
41}
42
43pub fn read_patch(entry: &Entry, repo_root: &Path) -> Result<String, SkillfileError> {
44    let p = patch_path(entry, repo_root);
45    Ok(std::fs::read_to_string(&p)?)
46}
47
48/// Remove the patch file for a single-file entry. No-op if it doesn't exist.
49pub fn remove_patch(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
50    let p = patch_path(entry, repo_root);
51    if !p.exists() {
52        return Ok(());
53    }
54    std::fs::remove_file(&p)?;
55    remove_empty_parent(&p);
56    Ok(())
57}
58
59// ---------------------------------------------------------------------------
60// Directory entry patches (one .patch file per modified file)
61// ---------------------------------------------------------------------------
62
63/// Path to a per-file patch within a directory entry.
64/// e.g. `.skillfile/patches/skills/architecture-patterns/SKILL.md.patch`
65pub fn dir_patch_path(entry: &Entry, filename: &str, repo_root: &Path) -> PathBuf {
66    patches_root(repo_root)
67        .join(entry.entity_type.dir_name())
68        .join(&entry.name)
69        .join(format!("{filename}.patch"))
70}
71
72#[must_use]
73pub fn has_dir_patch(entry: &Entry, repo_root: &Path) -> bool {
74    let d = patches_root(repo_root)
75        .join(entry.entity_type.dir_name())
76        .join(&entry.name);
77    if !d.is_dir() {
78        return false;
79    }
80    walkdir(&d)
81        .into_iter()
82        .any(|p| p.extension().is_some_and(|e| e == "patch"))
83}
84
85pub fn write_dir_patch(patch_path: &Path, patch_text: &str) -> Result<(), SkillfileError> {
86    if let Some(parent) = patch_path.parent() {
87        std::fs::create_dir_all(parent)?;
88    }
89    std::fs::write(patch_path, patch_text)?;
90    Ok(())
91}
92
93pub fn remove_dir_patch(
94    entry: &Entry,
95    filename: &str,
96    repo_root: &Path,
97) -> Result<(), SkillfileError> {
98    let p = dir_patch_path(entry, filename, repo_root);
99    if !p.exists() {
100        return Ok(());
101    }
102    std::fs::remove_file(&p)?;
103    remove_empty_parent(&p);
104    Ok(())
105}
106
107pub fn remove_all_dir_patches(entry: &Entry, repo_root: &Path) -> Result<(), SkillfileError> {
108    let d = patches_root(repo_root)
109        .join(entry.entity_type.dir_name())
110        .join(&entry.name);
111    if d.is_dir() {
112        std::fs::remove_dir_all(&d)?;
113    }
114    Ok(())
115}
116
117// ---------------------------------------------------------------------------
118// Internal helpers
119// ---------------------------------------------------------------------------
120
121/// Remove `path`'s parent directory if it exists and is now empty. No-op otherwise.
122fn remove_empty_parent(path: &Path) {
123    let Some(parent) = path.parent() else {
124        return;
125    };
126    if !parent.exists() {
127        return;
128    }
129    let is_empty = std::fs::read_dir(parent)
130        .map(|mut rd| rd.next().is_none())
131        .unwrap_or(true);
132    if is_empty {
133        let _ = std::fs::remove_dir(parent);
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Diff generation
139// ---------------------------------------------------------------------------
140
141/// Generate a unified diff of original → modified. Empty string if identical.
142/// All output lines are guaranteed to end with '\n'.
143/// Format: `--- a/{label}` / `+++ b/{label}`, 3 lines of context.
144///
145/// ```
146/// use skillfile_core::patch::generate_patch;
147///
148/// // Identical content produces no patch
149/// assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
150///
151/// // Different content produces a unified diff
152/// let patch = generate_patch("old\n", "new\n", "test.md");
153/// assert!(patch.contains("--- a/test.md"));
154/// assert!(patch.contains("+++ b/test.md"));
155/// ```
156pub fn generate_patch(original: &str, modified: &str, label: &str) -> String {
157    if original == modified {
158        return String::new();
159    }
160
161    let diff = similar::TextDiff::from_lines(original, modified);
162    let raw = format!(
163        "{}",
164        diff.unified_diff()
165            .context_radius(3)
166            .header(&format!("a/{label}"), &format!("b/{label}"))
167    );
168
169    if raw.is_empty() {
170        return String::new();
171    }
172
173    // Post-process: remove "\ No newline at end of file" markers,
174    // normalize any lines not ending with \n.
175    let mut result = String::new();
176    for line in raw.split_inclusive('\n') {
177        normalize_diff_line(line, &mut result);
178    }
179
180    result
181}
182
183/// Process one line from a raw unified-diff output into `result`.
184///
185/// "\ No newline at end of file" markers are dropped (but a trailing newline is
186/// ensured on the preceding content line). Every other line is guaranteed to end
187/// with `'\n'`.
188fn normalize_diff_line(line: &str, result: &mut String) {
189    if line.starts_with("\\ ") {
190        // "\ No newline at end of file" — ensure the preceding line ends with \n
191        if !result.ends_with('\n') {
192            result.push('\n');
193        }
194        return;
195    }
196    result.push_str(line);
197    if !line.ends_with('\n') {
198        result.push('\n');
199    }
200}
201
202// ---------------------------------------------------------------------------
203// Patch application (pure Rust, no subprocess)
204// ---------------------------------------------------------------------------
205
206struct Hunk {
207    orig_start: usize, // 1-based line number from @@ header
208    body: Vec<String>,
209}
210
211fn parse_hunks(patch_text: &str) -> Result<Vec<Hunk>, SkillfileError> {
212    let lines: Vec<&str> = patch_text.split_inclusive('\n').collect();
213    let mut pi = 0;
214
215    // Skip file headers (--- / +++ lines)
216    while pi < lines.len() && (lines[pi].starts_with("--- ") || lines[pi].starts_with("+++ ")) {
217        pi += 1;
218    }
219
220    let mut hunks: Vec<Hunk> = Vec::new();
221
222    while pi < lines.len() {
223        let pl = lines[pi];
224        if !pl.starts_with("@@ ") {
225            pi += 1;
226            continue;
227        }
228
229        // Parse hunk header: @@ -l[,s] +l[,s] @@
230        // We only need orig_start (the -l part)
231        let orig_start = pl
232            .split_whitespace()
233            .nth(1) // "-l[,s]"
234            .and_then(|s| s.trim_start_matches('-').split(',').next())
235            .and_then(|n| n.parse::<usize>().ok())
236            .ok_or_else(|| SkillfileError::Manifest(format!("malformed hunk header: {pl:?}")))?;
237
238        pi += 1;
239        let body = collect_hunk_body(&lines, &mut pi);
240
241        hunks.push(Hunk { orig_start, body });
242    }
243
244    Ok(hunks)
245}
246
247fn collect_hunk_body(lines: &[&str], pi: &mut usize) -> Vec<String> {
248    let mut body: Vec<String> = Vec::new();
249    while *pi < lines.len() {
250        let hl = lines[*pi];
251        if hl.starts_with("@@ ") || hl.starts_with("--- ") || hl.starts_with("+++ ") {
252            break;
253        }
254        if hl.starts_with("\\ ") {
255            // "\ No newline at end of file" — skip
256            *pi += 1;
257            continue;
258        }
259        body.push(hl.to_string());
260        *pi += 1;
261    }
262    body
263}
264
265fn try_hunk_at(lines: &[String], start: usize, ctx_lines: &[&str]) -> bool {
266    if start + ctx_lines.len() > lines.len() {
267        return false;
268    }
269    for (i, expected) in ctx_lines.iter().enumerate() {
270        if lines[start + i].trim_end_matches(['\n', '\r']) != *expected {
271            return false;
272        }
273    }
274    true
275}
276
277/// Groups the search inputs for hunk-position lookup to stay within the 3-argument limit.
278struct HunkSearch<'a> {
279    lines: &'a [String],
280    min_pos: usize,
281}
282
283impl HunkSearch<'_> {
284    /// Scan outward from `center` within ±100 lines for a position where the hunk context matches.
285    fn search_nearby(&self, center: usize, ctx_lines: &[&str]) -> Option<usize> {
286        (1..100usize)
287            .flat_map(|delta| [Some(center + delta), center.checked_sub(delta)])
288            .flatten()
289            .filter(|&c| c >= self.min_pos && c <= self.lines.len())
290            .find(|&c| try_hunk_at(self.lines, c, ctx_lines))
291    }
292}
293
294fn find_hunk_position(
295    ctx: &HunkSearch<'_>,
296    hunk_start: usize,
297    ctx_lines: &[&str],
298) -> Result<usize, SkillfileError> {
299    if try_hunk_at(ctx.lines, hunk_start, ctx_lines) {
300        return Ok(hunk_start);
301    }
302
303    if let Some(pos) = ctx.search_nearby(hunk_start, ctx_lines) {
304        return Ok(pos);
305    }
306
307    if !ctx_lines.is_empty() {
308        return Err(SkillfileError::Manifest(format!(
309            "context mismatch: cannot find context starting with {:?} near line {}",
310            ctx_lines[0],
311            hunk_start + 1
312        )));
313    }
314    Err(SkillfileError::Manifest(
315        "patch extends beyond end of file".into(),
316    ))
317}
318
319/// State threaded through hunk application in [`apply_patch_pure`].
320struct PatchState<'a> {
321    lines: &'a [String],
322    output: Vec<String>,
323    pos: usize,
324}
325
326impl<'a> PatchState<'a> {
327    fn new(lines: &'a [String]) -> Self {
328        Self {
329            lines,
330            output: Vec::new(),
331            pos: 0,
332        }
333    }
334
335    fn apply_line(&mut self, hl: &str) {
336        let Some(prefix) = hl.as_bytes().first() else {
337            return;
338        };
339        match prefix {
340            b' ' if self.pos < self.lines.len() => {
341                self.output.push(self.lines[self.pos].clone());
342                self.pos += 1;
343            }
344            b'-' => self.pos += 1,
345            b'+' => self.output.push(hl[1..].to_string()),
346            _ => {} // context beyond EOF or unrecognized — skip
347        }
348    }
349
350    fn apply_hunk(&mut self, hunk: &Hunk) {
351        for hl in &hunk.body {
352            self.apply_line(hl);
353        }
354    }
355}
356
357/// Apply a unified diff to original text, returning modified content.
358/// Pure implementation — no subprocess, no `patch` binary required.
359/// Only handles patches produced by [`generate_patch()`] (unified diff format).
360/// Returns an error if the patch does not apply cleanly.
361///
362/// ```
363/// use skillfile_core::patch::{generate_patch, apply_patch_pure};
364///
365/// let original = "line1\nline2\nline3\n";
366/// let modified = "line1\nchanged\nline3\n";
367/// let patch = generate_patch(original, modified, "test.md");
368/// let result = apply_patch_pure(original, &patch).unwrap();
369/// assert_eq!(result, modified);
370/// ```
371pub fn apply_patch_pure(original: &str, patch_text: &str) -> Result<String, SkillfileError> {
372    if patch_text.is_empty() {
373        return Ok(original.to_string());
374    }
375
376    // Normalize CRLF to LF so patches apply cleanly on Windows.
377    let original = &original.replace("\r\n", "\n");
378    let patch_text = &patch_text.replace("\r\n", "\n");
379
380    // Split into lines preserving newlines (like Python's splitlines(keepends=True))
381    let lines: Vec<String> = original
382        .split_inclusive('\n')
383        .map(ToString::to_string)
384        .collect();
385
386    let mut state = PatchState::new(&lines);
387
388    for hunk in parse_hunks(patch_text)? {
389        // Build context: lines with ' ' or '-' prefix, stripped of prefix and trailing \n
390        let ctx_lines: Vec<&str> = hunk
391            .body
392            .iter()
393            .filter(|hl| !hl.is_empty() && (hl.starts_with(' ') || hl.starts_with('-')))
394            .map(|hl| hl[1..].trim_end_matches('\n'))
395            .collect();
396
397        let search = HunkSearch {
398            lines: &lines,
399            min_pos: state.pos,
400        };
401        let hunk_start =
402            find_hunk_position(&search, hunk.orig_start.saturating_sub(1), &ctx_lines)?;
403
404        // Copy unchanged lines before this hunk
405        state
406            .output
407            .extend_from_slice(&lines[state.pos..hunk_start]);
408        state.pos = hunk_start;
409
410        state.apply_hunk(&hunk);
411    }
412
413    // Copy remaining lines
414    state.output.extend_from_slice(&lines[state.pos..]);
415    Ok(state.output.concat())
416}
417
418// ---------------------------------------------------------------------------
419// Directory walking helper
420// ---------------------------------------------------------------------------
421
422/// Recursively list all files under a directory, sorted.
423#[must_use]
424pub fn walkdir(dir: &Path) -> Vec<PathBuf> {
425    let mut result = Vec::new();
426    walkdir_inner(dir, &mut result);
427    result.sort();
428    result
429}
430
431fn walkdir_inner(dir: &Path, result: &mut Vec<PathBuf>) {
432    let Ok(entries) = std::fs::read_dir(dir) else {
433        return;
434    };
435    for entry in entries.flatten() {
436        let path = entry.path();
437        if path.is_dir() {
438            walkdir_inner(&path, result);
439        } else {
440            result.push(path);
441        }
442    }
443}
444
445// ---------------------------------------------------------------------------
446// Tests
447// ---------------------------------------------------------------------------
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::models::{EntityType, SourceFields};
453
454    fn github_entry(name: &str, entity_type: EntityType) -> Entry {
455        Entry {
456            entity_type,
457            name: name.to_string(),
458            source: SourceFields::Github {
459                owner_repo: "owner/repo".into(),
460                path_in_repo: "agents/test.md".into(),
461                ref_: "main".into(),
462            },
463        }
464    }
465
466    // --- generate_patch ---
467
468    #[test]
469    fn generate_patch_identical_returns_empty() {
470        assert_eq!(generate_patch("hello\n", "hello\n", "test.md"), "");
471    }
472
473    #[test]
474    fn generate_patch_has_headers() {
475        let p = generate_patch("old\n", "new\n", "test.md");
476        assert!(p.contains("--- a/test.md"), "missing fromfile header");
477        assert!(p.contains("+++ b/test.md"), "missing tofile header");
478    }
479
480    #[test]
481    fn generate_patch_add_line() {
482        let p = generate_patch("line1\n", "line1\nline2\n", "test.md");
483        assert!(p.contains("+line2"));
484    }
485
486    #[test]
487    fn generate_patch_remove_line() {
488        let p = generate_patch("line1\nline2\n", "line1\n", "test.md");
489        assert!(p.contains("-line2"));
490    }
491
492    #[test]
493    fn generate_patch_all_lines_end_with_newline() {
494        let p = generate_patch("a\nb\n", "a\nc\n", "test.md");
495        for seg in p.split_inclusive('\n') {
496            assert!(seg.ends_with('\n'), "line does not end with \\n: {seg:?}");
497        }
498    }
499
500    // --- apply_patch_pure ---
501
502    #[test]
503    fn apply_patch_empty_patch_returns_original() {
504        let result = apply_patch_pure("hello\n", "").unwrap();
505        assert_eq!(result, "hello\n");
506    }
507
508    #[test]
509    fn apply_patch_round_trip_add_line() {
510        let orig = "line1\nline2\n";
511        let modified = "line1\nline2\nline3\n";
512        let patch = generate_patch(orig, modified, "test.md");
513        let result = apply_patch_pure(orig, &patch).unwrap();
514        assert_eq!(result, modified);
515    }
516
517    #[test]
518    fn apply_patch_round_trip_remove_line() {
519        let orig = "line1\nline2\nline3\n";
520        let modified = "line1\nline3\n";
521        let patch = generate_patch(orig, modified, "test.md");
522        let result = apply_patch_pure(orig, &patch).unwrap();
523        assert_eq!(result, modified);
524    }
525
526    #[test]
527    fn apply_patch_round_trip_modify_line() {
528        let orig = "# Title\n\nSome text here.\n";
529        let modified = "# Title\n\nSome modified text here.\n";
530        let patch = generate_patch(orig, modified, "test.md");
531        let result = apply_patch_pure(orig, &patch).unwrap();
532        assert_eq!(result, modified);
533    }
534
535    #[test]
536    fn apply_patch_multi_hunk() {
537        use std::fmt::Write;
538        let mut orig = String::new();
539        for i in 0..20 {
540            let _ = writeln!(orig, "line{i}");
541        }
542        let mut modified = orig.clone();
543        modified = modified.replace("line2\n", "MODIFIED2\n");
544        modified = modified.replace("line15\n", "MODIFIED15\n");
545        let patch = generate_patch(&orig, &modified, "test.md");
546        assert!(patch.contains("@@"), "should have hunk headers");
547        let result = apply_patch_pure(&orig, &patch).unwrap();
548        assert_eq!(result, modified);
549    }
550
551    #[test]
552    fn apply_patch_context_mismatch_errors() {
553        let orig = "line1\nline2\n";
554        let patch = "--- a/test.md\n+++ b/test.md\n@@ -1,2 +1,2 @@\n-totally_wrong\n+new\n";
555        let result = apply_patch_pure(orig, patch);
556        assert!(result.is_err());
557        assert!(result.unwrap_err().to_string().contains("context mismatch"));
558    }
559
560    // --- Patch path helpers ---
561
562    #[test]
563    fn patch_path_single_file_agent() {
564        let entry = github_entry("my-agent", EntityType::Agent);
565        let root = Path::new("/repo");
566        let p = patch_path(&entry, root);
567        assert_eq!(
568            p,
569            Path::new("/repo/.skillfile/patches/agents/my-agent.patch")
570        );
571    }
572
573    #[test]
574    fn patch_path_single_file_skill() {
575        let entry = github_entry("my-skill", EntityType::Skill);
576        let root = Path::new("/repo");
577        let p = patch_path(&entry, root);
578        assert_eq!(
579            p,
580            Path::new("/repo/.skillfile/patches/skills/my-skill.patch")
581        );
582    }
583
584    #[test]
585    fn dir_patch_path_returns_correct() {
586        let entry = github_entry("lang-pro", EntityType::Skill);
587        let root = Path::new("/repo");
588        let p = dir_patch_path(&entry, "python.md", root);
589        assert_eq!(
590            p,
591            Path::new("/repo/.skillfile/patches/skills/lang-pro/python.md.patch")
592        );
593    }
594
595    #[test]
596    fn write_read_remove_patch_round_trip() {
597        let dir = tempfile::tempdir().unwrap();
598        let entry = github_entry("test-agent", EntityType::Agent);
599        let patch_text = "--- a/test-agent.md\n+++ b/test-agent.md\n@@ -1 +1 @@\n-old\n+new\n";
600        write_patch(&entry, patch_text, dir.path()).unwrap();
601        assert!(has_patch(&entry, dir.path()));
602        let read = read_patch(&entry, dir.path()).unwrap();
603        assert_eq!(read, patch_text);
604        remove_patch(&entry, dir.path()).unwrap();
605        assert!(!has_patch(&entry, dir.path()));
606    }
607
608    #[test]
609    fn has_dir_patch_detects_patches() {
610        let dir = tempfile::tempdir().unwrap();
611        let entry = github_entry("lang-pro", EntityType::Skill);
612        assert!(!has_dir_patch(&entry, dir.path()));
613        write_dir_patch(
614            &dir_patch_path(&entry, "python.md", dir.path()),
615            "patch content",
616        )
617        .unwrap();
618        assert!(has_dir_patch(&entry, dir.path()));
619    }
620
621    #[test]
622    fn remove_all_dir_patches_clears_dir() {
623        let dir = tempfile::tempdir().unwrap();
624        let entry = github_entry("lang-pro", EntityType::Skill);
625        write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1").unwrap();
626        write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2").unwrap();
627        assert!(has_dir_patch(&entry, dir.path()));
628        remove_all_dir_patches(&entry, dir.path()).unwrap();
629        assert!(!has_dir_patch(&entry, dir.path()));
630    }
631
632    // --- remove_patch: no-op when patch does not exist ---
633
634    #[test]
635    fn remove_patch_nonexistent_is_noop() {
636        let dir = tempfile::tempdir().unwrap();
637        let entry = github_entry("ghost-agent", EntityType::Agent);
638        // No patch was written — remove_patch must return Ok without panicking.
639        assert!(!has_patch(&entry, dir.path()));
640        remove_patch(&entry, dir.path()).unwrap();
641        assert!(!has_patch(&entry, dir.path()));
642    }
643
644    // --- remove_patch: parent directory cleaned up when empty ---
645
646    #[test]
647    fn remove_patch_cleans_up_empty_parent_dir() {
648        let dir = tempfile::tempdir().unwrap();
649        let entry = github_entry("solo-skill", EntityType::Skill);
650        write_patch(&entry, "some patch text\n", dir.path()).unwrap();
651
652        // Confirm that the parent directory (.skillfile/patches/skills/) was created.
653        let parent = patches_root(dir.path()).join("skills");
654        assert!(parent.is_dir(), "parent dir should exist after write_patch");
655
656        remove_patch(&entry, dir.path()).unwrap();
657
658        // The patch file and the now-empty parent dir should both be gone.
659        assert!(
660            !has_patch(&entry, dir.path()),
661            "patch file should be removed"
662        );
663        assert!(
664            !parent.exists(),
665            "empty parent dir should be removed after last patch is deleted"
666        );
667    }
668
669    // --- remove_patch: parent directory NOT cleaned up when non-empty ---
670
671    #[test]
672    fn remove_patch_keeps_parent_dir_when_nonempty() {
673        let dir = tempfile::tempdir().unwrap();
674        let entry_a = github_entry("skill-a", EntityType::Skill);
675        let entry_b = github_entry("skill-b", EntityType::Skill);
676        write_patch(&entry_a, "patch a\n", dir.path()).unwrap();
677        write_patch(&entry_b, "patch b\n", dir.path()).unwrap();
678
679        let parent = patches_root(dir.path()).join("skills");
680        remove_patch(&entry_a, dir.path()).unwrap();
681
682        // skill-b.patch still lives there — parent dir must NOT be removed.
683        assert!(
684            parent.is_dir(),
685            "parent dir must survive when another patch still exists"
686        );
687        assert!(has_patch(&entry_b, dir.path()));
688    }
689
690    // --- remove_dir_patch: no-op when patch does not exist ---
691
692    #[test]
693    fn remove_dir_patch_nonexistent_is_noop() {
694        let dir = tempfile::tempdir().unwrap();
695        let entry = github_entry("ghost-skill", EntityType::Skill);
696        // No patch was written — must return Ok without panicking.
697        remove_dir_patch(&entry, "missing.md", dir.path()).unwrap();
698    }
699
700    // --- remove_dir_patch: entry-specific directory cleaned up when empty ---
701
702    #[test]
703    fn remove_dir_patch_cleans_up_empty_entry_dir() {
704        let dir = tempfile::tempdir().unwrap();
705        let entry = github_entry("lang-pro", EntityType::Skill);
706        write_dir_patch(
707            &dir_patch_path(&entry, "python.md", dir.path()),
708            "patch text\n",
709        )
710        .unwrap();
711
712        // The entry-specific directory (.skillfile/patches/skills/lang-pro/) should exist.
713        let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
714        assert!(
715            entry_dir.is_dir(),
716            "entry dir should exist after write_dir_patch"
717        );
718
719        remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
720
721        // The single patch is gone — the entry dir should be removed too.
722        assert!(
723            !entry_dir.exists(),
724            "entry dir should be removed when it becomes empty"
725        );
726    }
727
728    // --- remove_dir_patch: entry-specific directory kept when non-empty ---
729
730    #[test]
731    fn remove_dir_patch_keeps_entry_dir_when_nonempty() {
732        let dir = tempfile::tempdir().unwrap();
733        let entry = github_entry("lang-pro", EntityType::Skill);
734        write_dir_patch(&dir_patch_path(&entry, "python.md", dir.path()), "p1\n").unwrap();
735        write_dir_patch(&dir_patch_path(&entry, "typescript.md", dir.path()), "p2\n").unwrap();
736
737        let entry_dir = patches_root(dir.path()).join("skills").join("lang-pro");
738        remove_dir_patch(&entry, "python.md", dir.path()).unwrap();
739
740        // typescript.md.patch still exists — entry dir must be kept.
741        assert!(
742            entry_dir.is_dir(),
743            "entry dir must survive when another patch still exists"
744        );
745    }
746
747    // --- generate_patch: inputs without trailing newline ---
748
749    #[test]
750    fn generate_patch_no_trailing_newline_original() {
751        // original has no trailing \n; all output lines must still end with \n.
752        let p = generate_patch("old text", "new text\n", "test.md");
753        assert!(!p.is_empty(), "patch should not be empty");
754        for seg in p.split_inclusive('\n') {
755            assert!(
756                seg.ends_with('\n'),
757                "every output line must end with \\n, got: {seg:?}"
758            );
759        }
760    }
761
762    #[test]
763    fn generate_patch_no_trailing_newline_modified() {
764        // modified has no trailing \n; all output lines must still end with \n.
765        let p = generate_patch("old text\n", "new text", "test.md");
766        assert!(!p.is_empty(), "patch should not be empty");
767        for seg in p.split_inclusive('\n') {
768            assert!(
769                seg.ends_with('\n'),
770                "every output line must end with \\n, got: {seg:?}"
771            );
772        }
773    }
774
775    #[test]
776    fn generate_patch_both_inputs_no_trailing_newline() {
777        // Neither original nor modified ends with \n.
778        let p = generate_patch("old line", "new line", "test.md");
779        assert!(!p.is_empty(), "patch should not be empty");
780        for seg in p.split_inclusive('\n') {
781            assert!(
782                seg.ends_with('\n'),
783                "every output line must end with \\n, got: {seg:?}"
784            );
785        }
786    }
787
788    #[test]
789    fn generate_patch_no_trailing_newline_roundtrip() {
790        // apply_patch_pure must reconstruct the modified text even when neither
791        // side ends with a newline.
792        let orig = "line one\nline two";
793        let modified = "line one\nline changed";
794        let patch = generate_patch(orig, modified, "test.md");
795        assert!(!patch.is_empty());
796        // The patch must normalise to a clean result — at minimum not error.
797        let result = apply_patch_pure(orig, &patch).unwrap();
798        // The applied result should match modified (possibly with a trailing newline
799        // added by the normalization, so we compare trimmed content).
800        assert_eq!(
801            result.trim_end_matches('\n'),
802            modified.trim_end_matches('\n')
803        );
804    }
805
806    // --- apply_patch_pure: "\ No newline at end of file" marker in patch ---
807
808    #[test]
809    fn apply_patch_pure_with_no_newline_marker() {
810        // A patch that was generated externally may contain the "\ No newline at
811        // end of file" marker.  parse_hunks() must skip it cleanly.
812        let orig = "line1\nline2\n";
813        let patch = concat!(
814            "--- a/test.md\n",
815            "+++ b/test.md\n",
816            "@@ -1,2 +1,2 @@\n",
817            " line1\n",
818            "-line2\n",
819            "+changed\n",
820            "\\ No newline at end of file\n",
821        );
822        let result = apply_patch_pure(orig, patch).unwrap();
823        assert_eq!(result, "line1\nchanged\n");
824    }
825
826    // --- walkdir: edge cases ---
827
828    #[test]
829    fn walkdir_empty_directory_returns_empty() {
830        let dir = tempfile::tempdir().unwrap();
831        let files = walkdir(dir.path());
832        assert!(
833            files.is_empty(),
834            "walkdir of empty dir should return empty vec"
835        );
836    }
837
838    #[test]
839    fn walkdir_nonexistent_directory_returns_empty() {
840        let path = Path::new("/tmp/skillfile_test_does_not_exist_xyz_9999");
841        let files = walkdir(path);
842        assert!(
843            files.is_empty(),
844            "walkdir of non-existent dir should return empty vec"
845        );
846    }
847
848    #[test]
849    fn walkdir_nested_subdirectories() {
850        let dir = tempfile::tempdir().unwrap();
851        let sub = dir.path().join("sub");
852        std::fs::create_dir_all(&sub).unwrap();
853        std::fs::write(dir.path().join("top.txt"), "top").unwrap();
854        std::fs::write(sub.join("nested.txt"), "nested").unwrap();
855
856        let files = walkdir(dir.path());
857        assert_eq!(files.len(), 2, "should find both files");
858
859        let names: Vec<String> = files
860            .iter()
861            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
862            .collect();
863        assert!(names.contains(&"top.txt".to_string()));
864        assert!(names.contains(&"nested.txt".to_string()));
865    }
866
867    #[test]
868    fn walkdir_results_are_sorted() {
869        let dir = tempfile::tempdir().unwrap();
870        std::fs::write(dir.path().join("z.txt"), "z").unwrap();
871        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
872        std::fs::write(dir.path().join("m.txt"), "m").unwrap();
873
874        let files = walkdir(dir.path());
875        let sorted = {
876            let mut v = files.clone();
877            v.sort();
878            v
879        };
880        assert_eq!(files, sorted, "walkdir results must be sorted");
881    }
882
883    // --- apply_patch_pure: CRLF handling ---
884
885    #[test]
886    fn apply_patch_pure_handles_crlf_original() {
887        let orig_lf = "line1\nline2\nline3\n";
888        let modified = "line1\nchanged\nline3\n";
889        let patch = generate_patch(orig_lf, modified, "test.md");
890
891        // Apply the LF-generated patch to a CRLF original
892        let orig_crlf = "line1\r\nline2\r\nline3\r\n";
893        let result = apply_patch_pure(orig_crlf, &patch).unwrap();
894        assert_eq!(result, modified);
895    }
896
897    #[test]
898    fn apply_patch_pure_handles_crlf_patch() {
899        let orig = "line1\nline2\nline3\n";
900        let modified = "line1\nchanged\nline3\n";
901        let patch_lf = generate_patch(orig, modified, "test.md");
902
903        // Convert patch itself to CRLF
904        let patch_crlf = patch_lf.replace('\n', "\r\n");
905        let result = apply_patch_pure(orig, &patch_crlf).unwrap();
906        assert_eq!(result, modified);
907    }
908
909    // --- apply_patch_pure: fuzzy hunk matching ---
910
911    #[test]
912    fn apply_patch_pure_fuzzy_hunk_matching() {
913        use std::fmt::Write;
914        // Build an original with 20 lines.
915        let mut orig = String::new();
916        for i in 1..=20 {
917            let _ = writeln!(orig, "line{i}");
918        }
919
920        // Construct a patch whose hunk header claims the context starts at line 5
921        // (1-based), but the actual content we want to change is at line 7.
922        // find_hunk_position will search ±100 lines and should find the match.
923        let patch = concat!(
924            "--- a/test.md\n",
925            "+++ b/test.md\n",
926            "@@ -5,3 +5,3 @@\n", // header says line 5, but context matches line 7
927            " line7\n",
928            "-line8\n",
929            "+CHANGED8\n",
930            " line9\n",
931        );
932
933        let result = apply_patch_pure(&orig, patch).unwrap();
934        assert!(
935            result.contains("CHANGED8\n"),
936            "fuzzy match should have applied the change"
937        );
938        assert!(
939            !result.contains("line8\n"),
940            "original line8 should have been replaced"
941        );
942    }
943
944    // --- apply_patch_pure: patch extends beyond end of file ---
945
946    #[test]
947    fn apply_patch_pure_extends_beyond_eof_errors() {
948        // A patch with an empty context list and hunk start beyond the file length
949        // triggers the "patch extends beyond end of file" error path in
950        // find_hunk_position when ctx_lines is empty.
951        //
952        // We craft a hunk header that places the hunk at line 999 of a 2-line file
953        // and supply a context line that won't match anywhere — this exercises the
954        // "context mismatch" branch (which is what fires when ctx_lines is non-empty
955        // and nothing is found within ±100 of the declared position).
956        let orig = "line1\nline2\n";
957        let patch = concat!(
958            "--- a/test.md\n",
959            "+++ b/test.md\n",
960            "@@ -999,1 +999,1 @@\n",
961            "-nonexistent_line\n",
962            "+replacement\n",
963        );
964        let result = apply_patch_pure(orig, patch);
965        assert!(
966            result.is_err(),
967            "applying a patch beyond EOF should return an error"
968        );
969    }
970}