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