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