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