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