Skip to main content

skillfile_deploy/
install.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use skillfile_core::conflict::{read_conflict, write_conflict};
5use skillfile_core::error::SkillfileError;
6use skillfile_core::lock::{lock_key, read_lock};
7use skillfile_core::models::{
8    short_sha, ConflictState, Entry, InstallOptions, InstallTarget, Manifest,
9};
10use skillfile_core::parser::{parse_manifest, MANIFEST_NAME};
11use skillfile_core::patch::{
12    apply_patch_pure, dir_patch_path, generate_patch, has_patch, patches_root, read_patch,
13    remove_patch, walkdir, write_dir_patch, write_patch,
14};
15use skillfile_core::progress;
16use skillfile_sources::strategy::{content_file, is_dir_entry};
17use skillfile_sources::sync::{cmd_sync, vendor_dir_for};
18
19use crate::adapter::{adapters, DeployRequest};
20use crate::paths::{installed_dir_files, installed_path, source_path};
21
22// ---------------------------------------------------------------------------
23// Patch application helpers
24// ---------------------------------------------------------------------------
25
26/// Convert a patch application error into PatchConflict for the given entry.
27fn to_patch_conflict(err: &SkillfileError, entry_name: &str) -> SkillfileError {
28    SkillfileError::PatchConflict {
29        message: err.to_string(),
30        entry_name: entry_name.to_string(),
31    }
32}
33
34/// Bundles the entry being patched with the repo root for patch operations.
35struct PatchCtx<'a> {
36    entry: &'a Entry,
37    repo_root: &'a Path,
38}
39
40/// Rebase a patch file against a new cache: write the updated patch or remove it
41/// if the upstream content already equals the patched result.
42fn rebase_single_patch(
43    ctx: &PatchCtx<'_>,
44    source: &Path,
45    patched: &str,
46) -> Result<(), SkillfileError> {
47    let cache_text = std::fs::read_to_string(source)?;
48    let new_patch = generate_patch(&cache_text, patched, &format!("{}.md", ctx.entry.name));
49    if new_patch.is_empty() {
50        remove_patch(ctx.entry, ctx.repo_root)?;
51    } else {
52        write_patch(ctx.entry, &new_patch, ctx.repo_root)?;
53    }
54    Ok(())
55}
56
57/// Apply stored patch (if any) to a single installed file, then rebase the patch
58/// against the new cache content so status comparisons remain correct.
59fn apply_single_file_patch(
60    ctx: &PatchCtx<'_>,
61    dest: &Path,
62    source: &Path,
63) -> Result<(), SkillfileError> {
64    if !has_patch(ctx.entry, ctx.repo_root) {
65        return Ok(());
66    }
67    let patch_text = read_patch(ctx.entry, ctx.repo_root)?;
68    let original = std::fs::read_to_string(dest)?;
69    let patched = apply_patch_pure(&original, &patch_text)
70        .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
71    std::fs::write(dest, &patched)?;
72
73    // Rebase: regenerate patch against new cache so `diff` shows accurate deltas.
74    rebase_single_patch(ctx, source, &patched)
75}
76
77/// Apply per-file patches to all installed files of a directory entry.
78/// Rebases each patch against the new cache content after applying.
79fn apply_dir_patches(
80    ctx: &PatchCtx<'_>,
81    installed_files: &HashMap<String, PathBuf>,
82    source_dir: &Path,
83) -> Result<(), SkillfileError> {
84    let patches_dir = patches_root(ctx.repo_root)
85        .join(ctx.entry.entity_type.dir_name())
86        .join(&ctx.entry.name);
87    if !patches_dir.is_dir() {
88        return Ok(());
89    }
90
91    let patch_files: Vec<PathBuf> = walkdir(&patches_dir)
92        .into_iter()
93        .filter(|p| p.extension().is_some_and(|e| e == "patch"))
94        .collect();
95
96    for patch_file in patch_files {
97        let Some(rel) = patch_file
98            .strip_prefix(&patches_dir)
99            .ok()
100            .and_then(|p| p.to_str())
101            .and_then(|s| s.strip_suffix(".patch"))
102            .map(str::to_string)
103        else {
104            continue;
105        };
106
107        let Some(target) = installed_files.get(&rel).filter(|p| p.exists()) else {
108            continue;
109        };
110
111        let patch_text = std::fs::read_to_string(&patch_file)?;
112        let original = std::fs::read_to_string(target)?;
113        let patched = apply_patch_pure(&original, &patch_text)
114            .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
115        std::fs::write(target, &patched)?;
116
117        // Rebase: regenerate patch against new cache content.
118        let cache_file = source_dir.join(&rel);
119        if !cache_file.exists() {
120            continue;
121        }
122        let cache_text = std::fs::read_to_string(&cache_file)?;
123        let new_patch = generate_patch(&cache_text, &patched, &rel);
124        if new_patch.is_empty() {
125            std::fs::remove_file(&patch_file)?;
126        } else {
127            write_dir_patch(&dir_patch_path(ctx.entry, &rel, ctx.repo_root), &new_patch)?;
128        }
129    }
130    Ok(())
131}
132
133// ---------------------------------------------------------------------------
134// Auto-pin helpers (used by install --update)
135// ---------------------------------------------------------------------------
136
137/// Check whether applying `patch_text` to `cache_text` reproduces `installed_text`.
138///
139/// Returns `true` when the patch already describes the installed content (no re-pin
140/// needed), or when the patch is inconsistent with the cache (preserve without
141/// clobbering). Returns `false` when the installed content has edits beyond what
142/// the patch captures.
143fn patch_already_covers(patch_text: &str, cache_text: &str, installed_text: &str) -> bool {
144    match apply_patch_pure(cache_text, patch_text) {
145        Ok(expected) if installed_text == expected => true, // no new edits
146        Err(_) => true,                                     // cache inconsistent — preserve
147        Ok(_) => false,                                     // additional edits — fall through
148    }
149}
150
151/// Return `true` if the stored patch already describes the installed content,
152/// meaning no re-pin is needed.
153fn should_skip_pin(ctx: &PatchCtx<'_>, cache_text: &str, installed_text: &str) -> bool {
154    if !has_patch(ctx.entry, ctx.repo_root) {
155        return false;
156    }
157    let Ok(pt) = read_patch(ctx.entry, ctx.repo_root) else {
158        return false;
159    };
160    patch_already_covers(&pt, cache_text, installed_text)
161}
162
163/// Compare installed vs cache; write patch if they differ. Silent on missing prerequisites.
164fn auto_pin_entry(entry: &Entry, manifest: &Manifest, repo_root: &Path) {
165    if entry.source_type() == "local" {
166        return;
167    }
168
169    let Ok(locked) = read_lock(repo_root) else {
170        return;
171    };
172    let key = lock_key(entry);
173    if !locked.contains_key(&key) {
174        return;
175    }
176
177    let vdir = vendor_dir_for(entry, repo_root);
178
179    if is_dir_entry(entry) {
180        auto_pin_dir_entry(entry, manifest, repo_root);
181        return;
182    }
183
184    let cf = content_file(entry);
185    if cf.is_empty() {
186        return;
187    }
188    let cache_file = vdir.join(&cf);
189    if !cache_file.exists() {
190        return;
191    }
192
193    let Ok(dest) = installed_path(entry, manifest, repo_root) else {
194        return;
195    };
196    if !dest.exists() {
197        return;
198    }
199
200    let Ok(cache_text) = std::fs::read_to_string(&cache_file) else {
201        return;
202    };
203    let Ok(installed_text) = std::fs::read_to_string(&dest) else {
204        return;
205    };
206
207    // If already pinned, check if stored patch still describes the installed content exactly.
208    let ctx = PatchCtx { entry, repo_root };
209    if should_skip_pin(&ctx, &cache_text, &installed_text) {
210        return;
211    }
212
213    let patch_text = generate_patch(&cache_text, &installed_text, &format!("{}.md", entry.name));
214    if !patch_text.is_empty() && write_patch(entry, &patch_text, repo_root).is_ok() {
215        progress!(
216            "  {}: local changes auto-saved to .skillfile/patches/",
217            entry.name
218        );
219    }
220}
221
222/// Check a single cache file against its installed counterpart and generate/update
223/// the patch if the user has modified the installed copy. Returns the filename if
224/// a new patch was written.
225struct AutoPinCtx<'a> {
226    vdir: &'a Path,
227    entry: &'a Entry,
228    installed: &'a HashMap<String, PathBuf>,
229    repo_root: &'a Path,
230}
231
232/// Return `true` if the dir-entry patch file at `patch_path` already describes
233/// the transition from `cache_text` to `installed_text`.
234fn dir_patch_already_matches(patch_path: &Path, cache_text: &str, installed_text: &str) -> bool {
235    if !patch_path.exists() {
236        return false;
237    }
238    let Ok(pt) = std::fs::read_to_string(patch_path) else {
239        return false;
240    };
241    patch_already_covers(&pt, cache_text, installed_text)
242}
243
244fn try_auto_pin_file(cache_file: &Path, ctx: &AutoPinCtx<'_>) -> Option<String> {
245    if cache_file.file_name().is_some_and(|n| n == ".meta") {
246        return None;
247    }
248    let filename = cache_file
249        .strip_prefix(ctx.vdir)
250        .ok()?
251        .to_str()?
252        .to_string();
253    let inst_path = match ctx.installed.get(&filename) {
254        Some(p) if p.exists() => p,
255        _ => return None,
256    };
257
258    let cache_text = std::fs::read_to_string(cache_file).ok()?;
259    let installed_text = std::fs::read_to_string(inst_path).ok()?;
260
261    // Check if stored dir patch still matches
262    let p = dir_patch_path(ctx.entry, &filename, ctx.repo_root);
263    if dir_patch_already_matches(&p, &cache_text, &installed_text) {
264        return None;
265    }
266
267    let patch_text = generate_patch(&cache_text, &installed_text, &filename);
268    if !patch_text.is_empty()
269        && write_dir_patch(
270            &dir_patch_path(ctx.entry, &filename, ctx.repo_root),
271            &patch_text,
272        )
273        .is_ok()
274    {
275        Some(filename)
276    } else {
277        None
278    }
279}
280
281/// Auto-pin each modified file in a directory entry's installed copy.
282fn auto_pin_dir_entry(entry: &Entry, manifest: &Manifest, repo_root: &Path) {
283    let vdir = &vendor_dir_for(entry, repo_root);
284    if !vdir.is_dir() {
285        return;
286    }
287    let Ok(installed) = installed_dir_files(entry, manifest, repo_root) else {
288        return;
289    };
290    if installed.is_empty() {
291        return;
292    }
293
294    let ctx = AutoPinCtx {
295        vdir,
296        entry,
297        installed: &installed,
298        repo_root,
299    };
300    let pinned: Vec<String> = walkdir(vdir)
301        .into_iter()
302        .filter_map(|f| try_auto_pin_file(&f, &ctx))
303        .collect();
304
305    if !pinned.is_empty() {
306        progress!(
307            "  {}: local changes auto-saved to .skillfile/patches/ ({})",
308            entry.name,
309            pinned.join(", ")
310        );
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Core install entry point
316// ---------------------------------------------------------------------------
317
318/// Context for the `install_entry` public API.
319pub struct InstallCtx<'a> {
320    pub repo_root: &'a Path,
321    pub opts: Option<&'a InstallOptions>,
322}
323
324/// Deploy one entry to its installed path via the platform adapter.
325///
326/// The adapter owns all platform-specific logic (target dirs, flat vs. nested).
327/// This function handles cross-cutting concerns: source resolution,
328/// missing-source warnings, and patch application.
329///
330/// Returns `Err(PatchConflict)` if a stored patch fails to apply cleanly.
331pub fn install_entry(
332    entry: &Entry,
333    target: &InstallTarget,
334    ctx: &InstallCtx<'_>,
335) -> Result<(), SkillfileError> {
336    let default_opts = InstallOptions::default();
337    let opts = ctx.opts.unwrap_or(&default_opts);
338
339    let all_adapters = adapters();
340    let Some(adapter) = all_adapters.get(&target.adapter) else {
341        return Ok(());
342    };
343
344    if !adapter.supports(entry.entity_type) {
345        return Ok(());
346    }
347
348    let source = match source_path(entry, ctx.repo_root) {
349        Some(p) if p.exists() => p,
350        _ => {
351            eprintln!("  warning: source missing for {}, skipping", entry.name);
352            return Ok(());
353        }
354    };
355
356    let is_dir = is_dir_entry(entry) || source.is_dir();
357    let installed = adapter.deploy_entry(&DeployRequest {
358        entry,
359        source: &source,
360        scope: target.scope,
361        repo_root: ctx.repo_root,
362        opts,
363    });
364
365    if installed.is_empty() || opts.dry_run {
366        return Ok(());
367    }
368
369    let patch_ctx = PatchCtx {
370        entry,
371        repo_root: ctx.repo_root,
372    };
373    if is_dir {
374        apply_dir_patches(&patch_ctx, &installed, &source)?;
375    } else {
376        let key = format!("{}.md", entry.name);
377        if let Some(dest) = installed.get(&key) {
378            apply_single_file_patch(&patch_ctx, dest, &source)?;
379        }
380    }
381
382    Ok(())
383}
384
385// ---------------------------------------------------------------------------
386// Precondition check
387// ---------------------------------------------------------------------------
388
389fn check_preconditions(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
390    if manifest.install_targets.is_empty() {
391        return Err(SkillfileError::Manifest(
392            "No install targets configured. Run `skillfile init` first.".into(),
393        ));
394    }
395
396    if let Some(conflict) = read_conflict(repo_root)? {
397        return Err(SkillfileError::Install(format!(
398            "pending conflict for '{}' — \
399             run `skillfile diff {}` to review, \
400             or `skillfile resolve {}` to merge",
401            conflict.entry, conflict.entry, conflict.entry
402        )));
403    }
404
405    Ok(())
406}
407
408// ---------------------------------------------------------------------------
409// Deploy all entries, handling patch conflicts
410// ---------------------------------------------------------------------------
411
412/// Format a SHA transition hint for conflict error messages.
413fn sha_transition_hint(old_sha: &str, new_sha: &str) -> String {
414    if !old_sha.is_empty() && !new_sha.is_empty() && old_sha != new_sha {
415        format!(
416            "\n  upstream: {} \u{2192} {}",
417            short_sha(old_sha),
418            short_sha(new_sha)
419        )
420    } else {
421        String::new()
422    }
423}
424
425/// Shared lock maps threaded through `deploy_all` and `handle_patch_conflict`.
426struct LockMaps<'a> {
427    locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
428    old_locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
429}
430
431/// Context threaded through the deploy pipeline.
432struct DeployCtx<'a> {
433    repo_root: &'a Path,
434    opts: &'a InstallOptions,
435    maps: LockMaps<'a>,
436}
437
438/// Handle a patch conflict during deployment: write conflict state and return an error.
439fn handle_patch_conflict(
440    entry: &Entry,
441    entry_name: &str,
442    ctx: &DeployCtx<'_>,
443) -> Result<(), SkillfileError> {
444    let key = lock_key(entry);
445    let old_sha = ctx
446        .maps
447        .old_locked
448        .get(&key)
449        .map(|l| l.sha.clone())
450        .unwrap_or_default();
451    let new_sha = ctx
452        .maps
453        .locked
454        .get(&key)
455        .map_or_else(|| old_sha.clone(), |l| l.sha.clone());
456
457    write_conflict(
458        ctx.repo_root,
459        &ConflictState {
460            entry: entry_name.to_string(),
461            entity_type: entry.entity_type,
462            old_sha: old_sha.clone(),
463            new_sha: new_sha.clone(),
464        },
465    )?;
466
467    let sha_info = sha_transition_hint(&old_sha, &new_sha);
468    Err(SkillfileError::Install(format!(
469        "upstream changes to '{entry_name}' conflict with your customisations.{sha_info}\n\
470         Your pinned edits could not be applied to the new upstream version.\n\
471         Run `skillfile diff {entry_name}` to review what changed upstream.\n\
472         Run `skillfile resolve {entry_name}` when ready to merge.\n\
473         Run `skillfile resolve --abort` to discard the conflict and keep the old version."
474    )))
475}
476
477/// Install one entry and translate a `PatchConflict` into a full conflict error.
478fn install_entry_or_conflict(
479    entry: &Entry,
480    target: &InstallTarget,
481    ctx: &DeployCtx<'_>,
482) -> Result<(), SkillfileError> {
483    let install_ctx = InstallCtx {
484        repo_root: ctx.repo_root,
485        opts: Some(ctx.opts),
486    };
487    match install_entry(entry, target, &install_ctx) {
488        Ok(()) => Ok(()),
489        Err(SkillfileError::PatchConflict { entry_name, .. }) => {
490            handle_patch_conflict(entry, &entry_name, ctx)
491        }
492        Err(e) => Err(e),
493    }
494}
495
496fn deploy_all(manifest: &Manifest, ctx: &DeployCtx<'_>) -> Result<(), SkillfileError> {
497    let mode = if ctx.opts.dry_run { " [dry-run]" } else { "" };
498    let all_adapters = adapters();
499
500    for target in &manifest.install_targets {
501        if !all_adapters.contains(&target.adapter) {
502            eprintln!("warning: unknown platform '{}', skipping", target.adapter);
503            continue;
504        }
505        progress!(
506            "Installing for {} ({}){mode}...",
507            target.adapter,
508            target.scope
509        );
510        for entry in &manifest.entries {
511            install_entry_or_conflict(entry, target, ctx)?;
512        }
513    }
514
515    Ok(())
516}
517
518// ---------------------------------------------------------------------------
519// cmd_install
520// ---------------------------------------------------------------------------
521
522/// Populate `manifest.install_targets` from `extra_targets` when the manifest
523/// has none of its own. Emits a progress message when targets are injected.
524fn apply_extra_targets(manifest: &mut Manifest, extra_targets: Option<&[InstallTarget]>) {
525    let Some(targets) = extra_targets else {
526        return;
527    };
528    if !targets.is_empty() {
529        progress!("Using platform targets from personal config (Skillfile has no install lines).");
530    }
531    manifest.install_targets = targets.to_vec();
532}
533
534/// Parse the manifest, apply extra targets if the Skillfile has none, and
535/// return the parsed manifest. Extracted to reduce cognitive complexity of
536/// `cmd_install`.
537fn load_manifest(
538    repo_root: &Path,
539    extra_targets: Option<&[InstallTarget]>,
540) -> Result<Manifest, SkillfileError> {
541    let manifest_path = repo_root.join(MANIFEST_NAME);
542    if !manifest_path.exists() {
543        return Err(SkillfileError::Manifest(format!(
544            "{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
545            repo_root.display()
546        )));
547    }
548
549    let result = parse_manifest(&manifest_path)?;
550    for w in &result.warnings {
551        eprintln!("{w}");
552    }
553    let mut manifest = result.manifest;
554
555    // If the Skillfile has no install targets, fall back to caller-provided targets
556    // (e.g. from user-global config).
557    if manifest.install_targets.is_empty() {
558        apply_extra_targets(&mut manifest, extra_targets);
559    }
560
561    Ok(manifest)
562}
563
564/// Run the auto-pin pass over all entries when `--update` is requested.
565fn auto_pin_all(manifest: &Manifest, repo_root: &Path) {
566    for entry in &manifest.entries {
567        auto_pin_entry(entry, manifest, repo_root);
568    }
569}
570
571/// Print the first-install hint listing the configured platforms.
572fn print_first_install_hint(manifest: &Manifest) {
573    let platforms: Vec<String> = manifest
574        .install_targets
575        .iter()
576        .map(|t| format!("{} ({})", t.adapter, t.scope))
577        .collect();
578    progress!("  Configured platforms: {}", platforms.join(", "));
579    progress!("  Run `skillfile init` to add or change platforms.");
580}
581
582/// Options for `cmd_install` beyond the common `repo_root`.
583pub struct CmdInstallOpts<'a> {
584    pub dry_run: bool,
585    pub update: bool,
586    pub extra_targets: Option<&'a [InstallTarget]>,
587}
588
589pub fn cmd_install(repo_root: &Path, opts: &CmdInstallOpts<'_>) -> Result<(), SkillfileError> {
590    let manifest = load_manifest(repo_root, opts.extra_targets)?;
591
592    check_preconditions(&manifest, repo_root)?;
593
594    // Detect first install (cache dir absent → fresh clone or first run).
595    let cache_dir = repo_root.join(".skillfile").join("cache");
596    let first_install = !cache_dir.exists();
597
598    // Read old locked state before sync (used for SHA context in conflict messages).
599    let old_locked = read_lock(repo_root).unwrap_or_default();
600
601    // Auto-pin local edits before re-fetching upstream (--update only).
602    if opts.update && !opts.dry_run {
603        auto_pin_all(&manifest, repo_root);
604    }
605
606    // Ensure cache dir exists (used as first-install marker and by sync).
607    if !opts.dry_run {
608        std::fs::create_dir_all(&cache_dir)?;
609    }
610
611    // Fetch any missing or stale entries.
612    cmd_sync(&skillfile_sources::sync::SyncCmdOpts {
613        repo_root,
614        dry_run: opts.dry_run,
615        entry_filter: None,
616        update: opts.update,
617    })?;
618
619    // Read new locked state (written by sync).
620    let locked = read_lock(repo_root).unwrap_or_default();
621
622    // Deploy to all configured platform targets.
623    let install_opts = InstallOptions {
624        dry_run: opts.dry_run,
625        overwrite: opts.update,
626    };
627    let deploy_ctx = DeployCtx {
628        repo_root,
629        opts: &install_opts,
630        maps: LockMaps {
631            locked: &locked,
632            old_locked: &old_locked,
633        },
634    };
635    deploy_all(&manifest, &deploy_ctx)?;
636
637    if !opts.dry_run {
638        progress!("Done.");
639
640        // On first install, show configured platforms and hint about `init`.
641        // Helps the clone scenario: user clones a repo with a Skillfile targeting
642        // platforms they may not use, and needs to know how to add theirs.
643        if first_install {
644            print_first_install_hint(&manifest);
645        }
646    }
647
648    Ok(())
649}
650
651// ---------------------------------------------------------------------------
652// Tests
653// ---------------------------------------------------------------------------
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use skillfile_core::models::{
659        EntityType, Entry, InstallTarget, LockEntry, Scope, SourceFields,
660    };
661    use std::collections::BTreeMap;
662    use std::path::{Path, PathBuf};
663
664    // -----------------------------------------------------------------------
665    // Fixture helpers — filesystem-only, no cross-crate function calls
666    // -----------------------------------------------------------------------
667
668    /// Return the path for a single-file entry patch.
669    /// `.skillfile/patches/<type>s/<name>.patch`
670    fn patch_fixture_path(dir: &Path, entry: &Entry) -> PathBuf {
671        dir.join(".skillfile/patches")
672            .join(entry.entity_type.dir_name())
673            .join(format!("{}.patch", entry.name))
674    }
675
676    /// Return the path for a per-file patch within a directory entry.
677    /// `.skillfile/patches/<type>s/<name>/<rel>.patch`
678    fn dir_patch_fixture_path(dir: &Path, entry: &Entry, rel: &str) -> PathBuf {
679        dir.join(".skillfile/patches")
680            .join(entry.entity_type.dir_name())
681            .join(&entry.name)
682            .join(format!("{rel}.patch"))
683    }
684
685    /// Write a single-file patch fixture to the correct path.
686    fn write_patch_fixture(dir: &Path, entry: &Entry, text: &str) {
687        let p = patch_fixture_path(dir, entry);
688        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
689        std::fs::write(p, text).unwrap();
690    }
691
692    /// Write `Skillfile.lock` as JSON. Uses `serde_json` — no cross-crate call.
693    fn write_lock_fixture(dir: &Path, locked: &BTreeMap<String, LockEntry>) {
694        let json = serde_json::to_string_pretty(locked).unwrap();
695        std::fs::write(dir.join("Skillfile.lock"), format!("{json}\n")).unwrap();
696    }
697
698    /// Write `.skillfile/conflict` JSON from a `ConflictState`.
699    fn write_conflict_fixture(dir: &Path, state: &ConflictState) {
700        let p = dir.join(".skillfile/conflict");
701        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
702        let json = serde_json::to_string_pretty(state).unwrap();
703        std::fs::write(p, format!("{json}\n")).unwrap();
704    }
705
706    /// Return `true` if any `.patch` file exists under the directory-entry patch dir.
707    fn has_dir_patch_fixture(dir: &Path, entry: &Entry) -> bool {
708        let d = dir
709            .join(".skillfile/patches")
710            .join(entry.entity_type.dir_name())
711            .join(&entry.name);
712        if !d.is_dir() {
713            return false;
714        }
715        std::fs::read_dir(&d)
716            .map(|rd| {
717                rd.filter_map(std::result::Result::ok)
718                    .any(|e| e.path().extension().is_some_and(|x| x == "patch"))
719            })
720            .unwrap_or(false)
721    }
722
723    // -----------------------------------------------------------------------
724    // Entry and target builders
725    // -----------------------------------------------------------------------
726
727    fn make_agent_entry(name: &str) -> Entry {
728        Entry {
729            entity_type: EntityType::Agent,
730            name: name.into(),
731            source: SourceFields::Github {
732                owner_repo: "owner/repo".into(),
733                path_in_repo: "agents/agent.md".into(),
734                ref_: "main".into(),
735            },
736        }
737    }
738
739    fn make_local_entry(name: &str, path: &str) -> Entry {
740        Entry {
741            entity_type: EntityType::Skill,
742            name: name.into(),
743            source: SourceFields::Local { path: path.into() },
744        }
745    }
746
747    fn make_target(adapter: &str, scope: Scope) -> InstallTarget {
748        InstallTarget {
749            adapter: adapter.into(),
750            scope,
751        }
752    }
753
754    // -- install_entry: local source --
755
756    #[test]
757    fn install_local_entry_copy() {
758        let dir = tempfile::tempdir().unwrap();
759        let source_file = dir.path().join("skills/my-skill.md");
760        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
761        std::fs::write(&source_file, "# My Skill").unwrap();
762
763        let entry = make_local_entry("my-skill", "skills/my-skill.md");
764        let target = make_target("claude-code", Scope::Local);
765        install_entry(
766            &entry,
767            &target,
768            &InstallCtx {
769                repo_root: dir.path(),
770                opts: None,
771            },
772        )
773        .unwrap();
774
775        let dest = dir.path().join(".claude/skills/my-skill.md");
776        assert!(dest.exists());
777        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
778    }
779
780    #[test]
781    fn install_local_dir_entry_copy() {
782        let dir = tempfile::tempdir().unwrap();
783        // Local source is a directory (not a .md file)
784        let source_dir = dir.path().join("skills/python-testing");
785        std::fs::create_dir_all(&source_dir).unwrap();
786        std::fs::write(source_dir.join("SKILL.md"), "# Python Testing").unwrap();
787        std::fs::write(source_dir.join("examples.md"), "# Examples").unwrap();
788
789        let entry = make_local_entry("python-testing", "skills/python-testing");
790        let target = make_target("claude-code", Scope::Local);
791        install_entry(
792            &entry,
793            &target,
794            &InstallCtx {
795                repo_root: dir.path(),
796                opts: None,
797            },
798        )
799        .unwrap();
800
801        // Must be deployed as a directory (nested mode), not as a single .md file
802        let dest = dir.path().join(".claude/skills/python-testing");
803        assert!(dest.is_dir(), "local dir entry must deploy as directory");
804        assert_eq!(
805            std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
806            "# Python Testing"
807        );
808        assert_eq!(
809            std::fs::read_to_string(dest.join("examples.md")).unwrap(),
810            "# Examples"
811        );
812        // Must NOT create a .md file at the target
813        assert!(
814            !dir.path().join(".claude/skills/python-testing.md").exists(),
815            "should not create python-testing.md for a dir source"
816        );
817    }
818
819    #[test]
820    fn install_entry_dry_run_no_write() {
821        let dir = tempfile::tempdir().unwrap();
822        let source_file = dir.path().join("skills/my-skill.md");
823        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
824        std::fs::write(&source_file, "# My Skill").unwrap();
825
826        let entry = make_local_entry("my-skill", "skills/my-skill.md");
827        let target = make_target("claude-code", Scope::Local);
828        let opts = InstallOptions {
829            dry_run: true,
830            ..Default::default()
831        };
832        install_entry(
833            &entry,
834            &target,
835            &InstallCtx {
836                repo_root: dir.path(),
837                opts: Some(&opts),
838            },
839        )
840        .unwrap();
841
842        let dest = dir.path().join(".claude/skills/my-skill.md");
843        assert!(!dest.exists());
844    }
845
846    #[test]
847    fn install_entry_overwrites_existing() {
848        let dir = tempfile::tempdir().unwrap();
849        let source_file = dir.path().join("skills/my-skill.md");
850        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
851        std::fs::write(&source_file, "# New content").unwrap();
852
853        let dest_dir = dir.path().join(".claude/skills");
854        std::fs::create_dir_all(&dest_dir).unwrap();
855        let dest = dest_dir.join("my-skill.md");
856        std::fs::write(&dest, "# Old content").unwrap();
857
858        let entry = make_local_entry("my-skill", "skills/my-skill.md");
859        let target = make_target("claude-code", Scope::Local);
860        install_entry(
861            &entry,
862            &target,
863            &InstallCtx {
864                repo_root: dir.path(),
865                opts: None,
866            },
867        )
868        .unwrap();
869
870        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
871    }
872
873    // -- install_entry: github (vendored) source --
874
875    #[test]
876    fn install_github_entry_copy() {
877        let dir = tempfile::tempdir().unwrap();
878        let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
879        std::fs::create_dir_all(&vdir).unwrap();
880        std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
881
882        let entry = make_agent_entry("my-agent");
883        let target = make_target("claude-code", Scope::Local);
884        install_entry(
885            &entry,
886            &target,
887            &InstallCtx {
888                repo_root: dir.path(),
889                opts: None,
890            },
891        )
892        .unwrap();
893
894        let dest = dir.path().join(".claude/agents/my-agent.md");
895        assert!(dest.exists());
896        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
897    }
898
899    #[test]
900    fn install_github_dir_entry_copy() {
901        let dir = tempfile::tempdir().unwrap();
902        let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
903        std::fs::create_dir_all(&vdir).unwrap();
904        std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
905        std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
906
907        let entry = Entry {
908            entity_type: EntityType::Skill,
909            name: "python-pro".into(),
910            source: SourceFields::Github {
911                owner_repo: "owner/repo".into(),
912                path_in_repo: "skills/python-pro".into(),
913                ref_: "main".into(),
914            },
915        };
916        let target = make_target("claude-code", Scope::Local);
917        install_entry(
918            &entry,
919            &target,
920            &InstallCtx {
921                repo_root: dir.path(),
922                opts: None,
923            },
924        )
925        .unwrap();
926
927        let dest = dir.path().join(".claude/skills/python-pro");
928        assert!(dest.is_dir());
929        assert_eq!(
930            std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
931            "# Python Pro"
932        );
933    }
934
935    #[test]
936    fn install_agent_dir_entry_explodes_to_individual_files() {
937        let dir = tempfile::tempdir().unwrap();
938        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
939        std::fs::create_dir_all(&vdir).unwrap();
940        std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
941        std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
942        std::fs::write(vdir.join(".meta"), "{}").unwrap();
943
944        let entry = Entry {
945            entity_type: EntityType::Agent,
946            name: "core-dev".into(),
947            source: SourceFields::Github {
948                owner_repo: "owner/repo".into(),
949                path_in_repo: "categories/core-dev".into(),
950                ref_: "main".into(),
951            },
952        };
953        let target = make_target("claude-code", Scope::Local);
954        install_entry(
955            &entry,
956            &target,
957            &InstallCtx {
958                repo_root: dir.path(),
959                opts: None,
960            },
961        )
962        .unwrap();
963
964        let agents_dir = dir.path().join(".claude/agents");
965        assert_eq!(
966            std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
967            "# Backend"
968        );
969        assert_eq!(
970            std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
971            "# Frontend"
972        );
973        // No "core-dev" directory should exist — flat mode
974        assert!(!agents_dir.join("core-dev").exists());
975    }
976
977    #[test]
978    fn install_entry_missing_source_warns() {
979        let dir = tempfile::tempdir().unwrap();
980        let entry = make_agent_entry("my-agent");
981        let target = make_target("claude-code", Scope::Local);
982
983        // Should return Ok without error — just a warning
984        install_entry(
985            &entry,
986            &target,
987            &InstallCtx {
988                repo_root: dir.path(),
989                opts: None,
990            },
991        )
992        .unwrap();
993    }
994
995    // -- Patch application during install --
996
997    #[test]
998    fn install_applies_existing_patch() {
999        let dir = tempfile::tempdir().unwrap();
1000
1001        // Set up cache
1002        let vdir = dir.path().join(".skillfile/cache/skills/test");
1003        std::fs::create_dir_all(&vdir).unwrap();
1004        std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
1005
1006        // Write a patch using filesystem fixture helper.
1007        let entry = Entry {
1008            entity_type: EntityType::Skill,
1009            name: "test".into(),
1010            source: SourceFields::Github {
1011                owner_repo: "owner/repo".into(),
1012                path_in_repo: "skills/test.md".into(),
1013                ref_: "main".into(),
1014            },
1015        };
1016        // Hand-written unified diff: "Original." → "Modified."
1017        let patch_text =
1018            "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Test\n \n-Original.\n+Modified.\n";
1019        write_patch_fixture(dir.path(), &entry, patch_text);
1020
1021        let target = make_target("claude-code", Scope::Local);
1022        install_entry(
1023            &entry,
1024            &target,
1025            &InstallCtx {
1026                repo_root: dir.path(),
1027                opts: None,
1028            },
1029        )
1030        .unwrap();
1031
1032        let dest = dir.path().join(".claude/skills/test.md");
1033        assert_eq!(
1034            std::fs::read_to_string(&dest).unwrap(),
1035            "# Test\n\nModified.\n"
1036        );
1037    }
1038
1039    #[test]
1040    fn install_patch_conflict_returns_error() {
1041        let dir = tempfile::tempdir().unwrap();
1042
1043        let vdir = dir.path().join(".skillfile/cache/skills/test");
1044        std::fs::create_dir_all(&vdir).unwrap();
1045        // Cache has completely different content from what the patch expects
1046        std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
1047
1048        let entry = Entry {
1049            entity_type: EntityType::Skill,
1050            name: "test".into(),
1051            source: SourceFields::Github {
1052                owner_repo: "owner/repo".into(),
1053                path_in_repo: "skills/test.md".into(),
1054                ref_: "main".into(),
1055            },
1056        };
1057        // Write a patch that expects a line that doesn't exist
1058        let bad_patch =
1059            "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
1060        write_patch_fixture(dir.path(), &entry, bad_patch);
1061
1062        // Deploy the entry
1063        let installed_dir = dir.path().join(".claude/skills");
1064        std::fs::create_dir_all(&installed_dir).unwrap();
1065        std::fs::write(
1066            installed_dir.join("test.md"),
1067            "totally different\ncontent\n",
1068        )
1069        .unwrap();
1070
1071        let target = make_target("claude-code", Scope::Local);
1072        let result = install_entry(
1073            &entry,
1074            &target,
1075            &InstallCtx {
1076                repo_root: dir.path(),
1077                opts: None,
1078            },
1079        );
1080        assert!(result.is_err());
1081        // Should be a PatchConflict error
1082        matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
1083    }
1084
1085    // -- Multi-adapter --
1086
1087    #[test]
1088    fn install_local_skill_gemini_cli() {
1089        let dir = tempfile::tempdir().unwrap();
1090        let source_file = dir.path().join("skills/my-skill.md");
1091        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1092        std::fs::write(&source_file, "# My Skill").unwrap();
1093
1094        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1095        let target = make_target("gemini-cli", Scope::Local);
1096        install_entry(
1097            &entry,
1098            &target,
1099            &InstallCtx {
1100                repo_root: dir.path(),
1101                opts: None,
1102            },
1103        )
1104        .unwrap();
1105
1106        let dest = dir.path().join(".gemini/skills/my-skill.md");
1107        assert!(dest.exists());
1108        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
1109    }
1110
1111    #[test]
1112    fn install_local_skill_codex() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let source_file = dir.path().join("skills/my-skill.md");
1115        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1116        std::fs::write(&source_file, "# My Skill").unwrap();
1117
1118        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1119        let target = make_target("codex", Scope::Local);
1120        install_entry(
1121            &entry,
1122            &target,
1123            &InstallCtx {
1124                repo_root: dir.path(),
1125                opts: None,
1126            },
1127        )
1128        .unwrap();
1129
1130        let dest = dir.path().join(".codex/skills/my-skill.md");
1131        assert!(dest.exists());
1132        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
1133    }
1134
1135    #[test]
1136    fn codex_skips_agent_entries() {
1137        let dir = tempfile::tempdir().unwrap();
1138        let entry = make_agent_entry("my-agent");
1139        let target = make_target("codex", Scope::Local);
1140        install_entry(
1141            &entry,
1142            &target,
1143            &InstallCtx {
1144                repo_root: dir.path(),
1145                opts: None,
1146            },
1147        )
1148        .unwrap();
1149
1150        assert!(!dir.path().join(".codex").exists());
1151    }
1152
1153    #[test]
1154    fn install_github_agent_gemini_cli() {
1155        let dir = tempfile::tempdir().unwrap();
1156        let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
1157        std::fs::create_dir_all(&vdir).unwrap();
1158        std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
1159
1160        let entry = make_agent_entry("my-agent");
1161        let target = make_target("gemini-cli", Scope::Local);
1162        install_entry(
1163            &entry,
1164            &target,
1165            &InstallCtx {
1166                repo_root: dir.path(),
1167                opts: Some(&InstallOptions::default()),
1168            },
1169        )
1170        .unwrap();
1171
1172        let dest = dir.path().join(".gemini/agents/my-agent.md");
1173        assert!(dest.exists());
1174        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
1175    }
1176
1177    #[test]
1178    fn install_skill_multi_adapter() {
1179        for adapter in &["claude-code", "gemini-cli", "codex"] {
1180            let dir = tempfile::tempdir().unwrap();
1181            let source_file = dir.path().join("skills/my-skill.md");
1182            std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1183            std::fs::write(&source_file, "# Multi Skill").unwrap();
1184
1185            let entry = make_local_entry("my-skill", "skills/my-skill.md");
1186            let target = make_target(adapter, Scope::Local);
1187            install_entry(
1188                &entry,
1189                &target,
1190                &InstallCtx {
1191                    repo_root: dir.path(),
1192                    opts: None,
1193                },
1194            )
1195            .unwrap();
1196
1197            let prefix = match *adapter {
1198                "claude-code" => ".claude",
1199                "gemini-cli" => ".gemini",
1200                "codex" => ".codex",
1201                _ => unreachable!(),
1202            };
1203            let dest = dir.path().join(format!("{prefix}/skills/my-skill.md"));
1204            assert!(dest.exists(), "Failed for adapter {adapter}");
1205            assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
1206        }
1207    }
1208
1209    // -- cmd_install --
1210
1211    #[test]
1212    fn cmd_install_no_manifest() {
1213        let dir = tempfile::tempdir().unwrap();
1214        let result = cmd_install(
1215            dir.path(),
1216            &CmdInstallOpts {
1217                dry_run: false,
1218                update: false,
1219                extra_targets: None,
1220            },
1221        );
1222        assert!(result.is_err());
1223        assert!(result.unwrap_err().to_string().contains("not found"));
1224    }
1225
1226    #[test]
1227    fn cmd_install_no_install_targets() {
1228        let dir = tempfile::tempdir().unwrap();
1229        std::fs::write(
1230            dir.path().join("Skillfile"),
1231            "local  skill  foo  skills/foo.md\n",
1232        )
1233        .unwrap();
1234
1235        let result = cmd_install(
1236            dir.path(),
1237            &CmdInstallOpts {
1238                dry_run: false,
1239                update: false,
1240                extra_targets: None,
1241            },
1242        );
1243        assert!(result.is_err());
1244        assert!(result
1245            .unwrap_err()
1246            .to_string()
1247            .contains("No install targets"));
1248    }
1249
1250    #[test]
1251    fn cmd_install_extra_targets_fallback() {
1252        let dir = tempfile::tempdir().unwrap();
1253        // Skillfile with entries but NO install lines.
1254        std::fs::write(
1255            dir.path().join("Skillfile"),
1256            "local  skill  foo  skills/foo.md\n",
1257        )
1258        .unwrap();
1259        let source_file = dir.path().join("skills/foo.md");
1260        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1261        std::fs::write(&source_file, "# Foo").unwrap();
1262
1263        // Pass extra targets — should be used as fallback.
1264        let targets = vec![make_target("claude-code", Scope::Local)];
1265        cmd_install(
1266            dir.path(),
1267            &CmdInstallOpts {
1268                dry_run: false,
1269                update: false,
1270                extra_targets: Some(&targets),
1271            },
1272        )
1273        .unwrap();
1274
1275        let dest = dir.path().join(".claude/skills/foo.md");
1276        assert!(
1277            dest.exists(),
1278            "extra_targets must be used when Skillfile has none"
1279        );
1280        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
1281    }
1282
1283    #[test]
1284    fn cmd_install_skillfile_targets_win_over_extra() {
1285        let dir = tempfile::tempdir().unwrap();
1286        // Skillfile WITH install lines.
1287        std::fs::write(
1288            dir.path().join("Skillfile"),
1289            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
1290        )
1291        .unwrap();
1292        let source_file = dir.path().join("skills/foo.md");
1293        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1294        std::fs::write(&source_file, "# Foo").unwrap();
1295
1296        // Pass extra targets for gemini-cli — should be IGNORED (Skillfile wins).
1297        let targets = vec![make_target("gemini-cli", Scope::Local)];
1298        cmd_install(
1299            dir.path(),
1300            &CmdInstallOpts {
1301                dry_run: false,
1302                update: false,
1303                extra_targets: Some(&targets),
1304            },
1305        )
1306        .unwrap();
1307
1308        // claude-code (from Skillfile) should be deployed.
1309        assert!(dir.path().join(".claude/skills/foo.md").exists());
1310        // gemini-cli (from extra_targets) should NOT be deployed.
1311        assert!(!dir.path().join(".gemini").exists());
1312    }
1313
1314    #[test]
1315    fn cmd_install_dry_run_no_files() {
1316        let dir = tempfile::tempdir().unwrap();
1317        std::fs::write(
1318            dir.path().join("Skillfile"),
1319            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
1320        )
1321        .unwrap();
1322        let source_file = dir.path().join("skills/foo.md");
1323        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1324        std::fs::write(&source_file, "# Foo").unwrap();
1325
1326        cmd_install(
1327            dir.path(),
1328            &CmdInstallOpts {
1329                dry_run: true,
1330                update: false,
1331                extra_targets: None,
1332            },
1333        )
1334        .unwrap();
1335
1336        assert!(!dir.path().join(".claude").exists());
1337    }
1338
1339    #[test]
1340    fn cmd_install_deploys_to_multiple_adapters() {
1341        let dir = tempfile::tempdir().unwrap();
1342        std::fs::write(
1343            dir.path().join("Skillfile"),
1344            "install  claude-code  local\n\
1345             install  gemini-cli  local\n\
1346             install  codex  local\n\
1347             local  skill  foo  skills/foo.md\n\
1348             local  agent  bar  agents/bar.md\n",
1349        )
1350        .unwrap();
1351        std::fs::create_dir_all(dir.path().join("skills")).unwrap();
1352        std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
1353        std::fs::create_dir_all(dir.path().join("agents")).unwrap();
1354        std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
1355
1356        cmd_install(
1357            dir.path(),
1358            &CmdInstallOpts {
1359                dry_run: false,
1360                update: false,
1361                extra_targets: None,
1362            },
1363        )
1364        .unwrap();
1365
1366        // skill deployed to all three adapters
1367        assert!(dir.path().join(".claude/skills/foo.md").exists());
1368        assert!(dir.path().join(".gemini/skills/foo.md").exists());
1369        assert!(dir.path().join(".codex/skills/foo.md").exists());
1370
1371        // agent deployed to claude-code and gemini-cli but NOT codex
1372        assert!(dir.path().join(".claude/agents/bar.md").exists());
1373        assert!(dir.path().join(".gemini/agents/bar.md").exists());
1374        assert!(!dir.path().join(".codex/agents").exists());
1375    }
1376
1377    #[test]
1378    fn cmd_install_pending_conflict_blocks() {
1379        let dir = tempfile::tempdir().unwrap();
1380        std::fs::write(
1381            dir.path().join("Skillfile"),
1382            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
1383        )
1384        .unwrap();
1385
1386        write_conflict_fixture(
1387            dir.path(),
1388            &ConflictState {
1389                entry: "foo".into(),
1390                entity_type: EntityType::Skill,
1391                old_sha: "aaa".into(),
1392                new_sha: "bbb".into(),
1393            },
1394        );
1395
1396        let result = cmd_install(
1397            dir.path(),
1398            &CmdInstallOpts {
1399                dry_run: false,
1400                update: false,
1401                extra_targets: None,
1402            },
1403        );
1404        assert!(result.is_err());
1405        assert!(result.unwrap_err().to_string().contains("pending conflict"));
1406    }
1407
1408    // -----------------------------------------------------------------------
1409    // Helpers shared by the new tests below
1410    // -----------------------------------------------------------------------
1411
1412    /// Build a single-file github skill Entry.
1413    fn make_skill_entry(name: &str) -> Entry {
1414        Entry {
1415            entity_type: EntityType::Skill,
1416            name: name.into(),
1417            source: SourceFields::Github {
1418                owner_repo: "owner/repo".into(),
1419                path_in_repo: format!("skills/{name}.md"),
1420                ref_: "main".into(),
1421            },
1422        }
1423    }
1424
1425    /// Build a directory github skill Entry (path_in_repo has no `.md` suffix).
1426    fn make_dir_skill_entry(name: &str) -> Entry {
1427        Entry {
1428            entity_type: EntityType::Skill,
1429            name: name.into(),
1430            source: SourceFields::Github {
1431                owner_repo: "owner/repo".into(),
1432                path_in_repo: format!("skills/{name}"),
1433                ref_: "main".into(),
1434            },
1435        }
1436    }
1437
1438    /// Write a minimal Skillfile + Skillfile.lock for a single single-file github skill.
1439    fn setup_github_skill_repo(dir: &Path, name: &str, cache_content: &str) {
1440        // Manifest
1441        std::fs::write(
1442            dir.join("Skillfile"),
1443            format!(
1444                "install  claude-code  local\ngithub  skill  {name}  owner/repo  skills/{name}.md\n"
1445            ),
1446        )
1447        .unwrap();
1448
1449        // Lock file via filesystem fixture (no cross-crate call).
1450        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1451        locked.insert(
1452            format!("github/skill/{name}"),
1453            LockEntry {
1454                sha: "abc123def456abc123def456abc123def456abc123".into(),
1455                raw_url: format!(
1456                    "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
1457                ),
1458            },
1459        );
1460        write_lock_fixture(dir, &locked);
1461
1462        // Vendor cache
1463        let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
1464        std::fs::create_dir_all(&vdir).unwrap();
1465        std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
1466    }
1467
1468    // -----------------------------------------------------------------------
1469    // auto_pin_entry — single-file entry
1470    // -----------------------------------------------------------------------
1471
1472    #[test]
1473    fn auto_pin_entry_local_is_skipped() {
1474        let dir = tempfile::tempdir().unwrap();
1475
1476        // Local entry: auto_pin should be a no-op.
1477        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1478        let manifest = Manifest {
1479            entries: vec![entry.clone()],
1480            install_targets: vec![make_target("claude-code", Scope::Local)],
1481        };
1482
1483        // Provide installed file that differs from source — pin should NOT fire.
1484        let skills_dir = dir.path().join("skills");
1485        std::fs::create_dir_all(&skills_dir).unwrap();
1486        std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
1487
1488        auto_pin_entry(&entry, &manifest, dir.path());
1489
1490        // No patch must have been written.
1491        assert!(
1492            !patch_fixture_path(dir.path(), &entry).exists(),
1493            "local entry must never be pinned"
1494        );
1495    }
1496
1497    #[test]
1498    fn auto_pin_entry_missing_lock_is_skipped() {
1499        let dir = tempfile::tempdir().unwrap();
1500
1501        let entry = make_skill_entry("test");
1502        let manifest = Manifest {
1503            entries: vec![entry.clone()],
1504            install_targets: vec![make_target("claude-code", Scope::Local)],
1505        };
1506
1507        // No Skillfile.lock — should silently return without panicking.
1508        auto_pin_entry(&entry, &manifest, dir.path());
1509
1510        assert!(!patch_fixture_path(dir.path(), &entry).exists());
1511    }
1512
1513    #[test]
1514    fn auto_pin_entry_missing_lock_key_is_skipped() {
1515        let dir = tempfile::tempdir().unwrap();
1516
1517        // Lock exists but for a different entry.
1518        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1519        locked.insert(
1520            "github/skill/other".into(),
1521            LockEntry {
1522                sha: "aabbcc".into(),
1523                raw_url: "https://example.com/other.md".into(),
1524            },
1525        );
1526        write_lock_fixture(dir.path(), &locked);
1527
1528        let entry = make_skill_entry("test");
1529        let manifest = Manifest {
1530            entries: vec![entry.clone()],
1531            install_targets: vec![make_target("claude-code", Scope::Local)],
1532        };
1533
1534        auto_pin_entry(&entry, &manifest, dir.path());
1535
1536        assert!(!patch_fixture_path(dir.path(), &entry).exists());
1537    }
1538
1539    #[test]
1540    fn auto_pin_entry_writes_patch_when_installed_differs() {
1541        let dir = tempfile::tempdir().unwrap();
1542        let name = "my-skill";
1543
1544        let cache_content = "# My Skill\n\nOriginal content.\n";
1545        let installed_content = "# My Skill\n\nUser-modified content.\n";
1546
1547        setup_github_skill_repo(dir.path(), name, cache_content);
1548
1549        // Place a modified installed file.
1550        let installed_dir = dir.path().join(".claude/skills");
1551        std::fs::create_dir_all(&installed_dir).unwrap();
1552        std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1553
1554        let entry = make_skill_entry(name);
1555        let manifest = Manifest {
1556            entries: vec![entry.clone()],
1557            install_targets: vec![make_target("claude-code", Scope::Local)],
1558        };
1559
1560        auto_pin_entry(&entry, &manifest, dir.path());
1561
1562        assert!(
1563            patch_fixture_path(dir.path(), &entry).exists(),
1564            "patch should be written when installed differs from cache"
1565        );
1566
1567        // Verify the patch round-trips: reset the installed file to cache_content and
1568        // reinstall — the patch must produce installed_content.
1569        std::fs::write(installed_dir.join(format!("{name}.md")), cache_content).unwrap();
1570        let target = make_target("claude-code", Scope::Local);
1571        install_entry(
1572            &entry,
1573            &target,
1574            &InstallCtx {
1575                repo_root: dir.path(),
1576                opts: None,
1577            },
1578        )
1579        .unwrap();
1580        assert_eq!(
1581            std::fs::read_to_string(installed_dir.join(format!("{name}.md"))).unwrap(),
1582            installed_content,
1583        );
1584    }
1585
1586    #[test]
1587    fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1588        let dir = tempfile::tempdir().unwrap();
1589        let name = "my-skill";
1590
1591        let cache_content = "# My Skill\n\nOriginal.\n";
1592        let installed_content = "# My Skill\n\nModified.\n";
1593
1594        setup_github_skill_repo(dir.path(), name, cache_content);
1595
1596        let entry = make_skill_entry(name);
1597        let manifest = Manifest {
1598            entries: vec![entry.clone()],
1599            install_targets: vec![make_target("claude-code", Scope::Local)],
1600        };
1601
1602        // Pre-write the correct patch (cache → installed) using the fixture helper.
1603        // Hand-written unified diff: "Original." → "Modified."
1604        let patch_text = "--- a/my-skill.md\n+++ b/my-skill.md\n@@ -1,3 +1,3 @@\n # My Skill\n \n-Original.\n+Modified.\n";
1605        write_patch_fixture(dir.path(), &entry, patch_text);
1606
1607        // Write installed file that matches what the patch produces.
1608        let installed_dir = dir.path().join(".claude/skills");
1609        std::fs::create_dir_all(&installed_dir).unwrap();
1610        std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1611
1612        // Record mtime of patch so we can detect if it changed.
1613        let patch_path = patch_fixture_path(dir.path(), &entry);
1614        let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1615
1616        // Small sleep so that any write would produce a different mtime.
1617        std::thread::sleep(std::time::Duration::from_millis(20));
1618
1619        auto_pin_entry(&entry, &manifest, dir.path());
1620
1621        let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1622
1623        assert_eq!(
1624            mtime_before, mtime_after,
1625            "patch must not be rewritten when already up to date"
1626        );
1627    }
1628
1629    #[test]
1630    fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1631        let dir = tempfile::tempdir().unwrap();
1632        let name = "my-skill";
1633
1634        let cache_content = "# My Skill\n\nOriginal.\n";
1635        let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1636
1637        setup_github_skill_repo(dir.path(), name, cache_content);
1638
1639        let entry = make_skill_entry(name);
1640        let manifest = Manifest {
1641            entries: vec![entry.clone()],
1642            install_targets: vec![make_target("claude-code", Scope::Local)],
1643        };
1644
1645        // Stored patch reflects the old installed state: "Original." → "First edit."
1646        let old_patch = "--- a/my-skill.md\n+++ b/my-skill.md\n@@ -1,3 +1,3 @@\n # My Skill\n \n-Original.\n+First edit.\n";
1647        write_patch_fixture(dir.path(), &entry, old_patch);
1648
1649        // But the actual installed file has further edits.
1650        let installed_dir = dir.path().join(".claude/skills");
1651        std::fs::create_dir_all(&installed_dir).unwrap();
1652        std::fs::write(installed_dir.join(format!("{name}.md")), new_installed).unwrap();
1653
1654        auto_pin_entry(&entry, &manifest, dir.path());
1655
1656        // The patch was re-written to reflect new_installed. Verify by resetting the
1657        // installed file to cache_content and reinstalling — must yield new_installed.
1658        std::fs::write(installed_dir.join(format!("{name}.md")), cache_content).unwrap();
1659        let target = make_target("claude-code", Scope::Local);
1660        install_entry(
1661            &entry,
1662            &target,
1663            &InstallCtx {
1664                repo_root: dir.path(),
1665                opts: None,
1666            },
1667        )
1668        .unwrap();
1669        assert_eq!(
1670            std::fs::read_to_string(installed_dir.join(format!("{name}.md"))).unwrap(),
1671            new_installed,
1672            "updated patch must describe the latest installed content"
1673        );
1674    }
1675
1676    // -----------------------------------------------------------------------
1677    // auto_pin_dir_entry
1678    // -----------------------------------------------------------------------
1679
1680    #[test]
1681    fn auto_pin_dir_entry_writes_per_file_patches() {
1682        let dir = tempfile::tempdir().unwrap();
1683        let name = "lang-pro";
1684
1685        // Manifest + lock (dir entry)
1686        std::fs::write(
1687            dir.path().join("Skillfile"),
1688            format!(
1689                "install  claude-code  local\ngithub  skill  {name}  owner/repo  skills/{name}\n"
1690            ),
1691        )
1692        .unwrap();
1693        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1694        locked.insert(
1695            format!("github/skill/{name}"),
1696            LockEntry {
1697                sha: "deadbeefdeadbeefdeadbeef".into(),
1698                raw_url: format!("https://example.com/{name}"),
1699            },
1700        );
1701        write_lock_fixture(dir.path(), &locked);
1702
1703        // Vendor cache with two files.
1704        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1705        std::fs::create_dir_all(&vdir).unwrap();
1706        std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1707        std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1708
1709        // Installed dir (nested mode for skills).
1710        let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1711        std::fs::create_dir_all(&inst_dir).unwrap();
1712        std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1713        std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1714
1715        let entry = make_dir_skill_entry(name);
1716        let manifest = Manifest {
1717            entries: vec![entry.clone()],
1718            install_targets: vec![make_target("claude-code", Scope::Local)],
1719        };
1720
1721        auto_pin_entry(&entry, &manifest, dir.path());
1722
1723        // Patch for the modified file should exist.
1724        let skill_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1725        assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1726
1727        // Patch for the unmodified file should NOT exist.
1728        let examples_patch = dir_patch_fixture_path(dir.path(), &entry, "examples.md");
1729        assert!(
1730            !examples_patch.exists(),
1731            "patch for examples.md must not be written (content unchanged)"
1732        );
1733    }
1734
1735    #[test]
1736    fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1737        let dir = tempfile::tempdir().unwrap();
1738        let name = "lang-pro";
1739
1740        // Write lock so we don't bail out there.
1741        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1742        locked.insert(
1743            format!("github/skill/{name}"),
1744            LockEntry {
1745                sha: "abc".into(),
1746                raw_url: "https://example.com".into(),
1747            },
1748        );
1749        write_lock_fixture(dir.path(), &locked);
1750
1751        let entry = make_dir_skill_entry(name);
1752        let manifest = Manifest {
1753            entries: vec![entry.clone()],
1754            install_targets: vec![make_target("claude-code", Scope::Local)],
1755        };
1756
1757        // No vendor dir — must silently return without panicking.
1758        auto_pin_entry(&entry, &manifest, dir.path());
1759
1760        assert!(!has_dir_patch_fixture(dir.path(), &entry));
1761    }
1762
1763    #[test]
1764    fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1765        let dir = tempfile::tempdir().unwrap();
1766        let name = "lang-pro";
1767
1768        let cache_content = "# Lang Pro\n\nOriginal.\n";
1769        let modified = "# Lang Pro\n\nModified.\n";
1770
1771        // Write lock.
1772        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1773        locked.insert(
1774            format!("github/skill/{name}"),
1775            LockEntry {
1776                sha: "abc".into(),
1777                raw_url: "https://example.com".into(),
1778            },
1779        );
1780        write_lock_fixture(dir.path(), &locked);
1781
1782        // Vendor cache.
1783        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1784        std::fs::create_dir_all(&vdir).unwrap();
1785        std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1786
1787        // Installed dir.
1788        let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1789        std::fs::create_dir_all(&inst_dir).unwrap();
1790        std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1791
1792        let entry = make_dir_skill_entry(name);
1793        let manifest = Manifest {
1794            entries: vec![entry.clone()],
1795            install_targets: vec![make_target("claude-code", Scope::Local)],
1796        };
1797
1798        // Pre-write the correct patch: "Original." → "Modified." for SKILL.md
1799        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Lang Pro\n \n-Original.\n+Modified.\n";
1800        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1801        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1802        std::fs::write(&dp, patch_text).unwrap();
1803
1804        let patch_path = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1805        let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1806
1807        std::thread::sleep(std::time::Duration::from_millis(20));
1808
1809        auto_pin_entry(&entry, &manifest, dir.path());
1810
1811        let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1812
1813        assert_eq!(
1814            mtime_before, mtime_after,
1815            "dir patch must not be rewritten when already up to date"
1816        );
1817    }
1818
1819    // -----------------------------------------------------------------------
1820    // apply_dir_patches
1821    // -----------------------------------------------------------------------
1822
1823    #[test]
1824    fn apply_dir_patches_applies_patch_and_rebases() {
1825        let dir = tempfile::tempdir().unwrap();
1826
1827        // Old upstream → user's installed version (what the stored patch records).
1828        let cache_content = "# Skill\n\nOriginal.\n";
1829        let installed_content = "# Skill\n\nModified.\n";
1830        // New upstream has a different body line but same structure.
1831        let new_cache_content = "# Skill\n\nOriginal v2.\n";
1832        // After rebase, the rebased patch encodes the diff from new_cache to installed.
1833        // Applying that rebased patch to new_cache must yield installed_content.
1834        let expected_rebased_to_new_cache = installed_content;
1835
1836        let entry = make_dir_skill_entry("lang-pro");
1837
1838        // Create patch dir with a valid patch (old cache → installed): "Original." → "Modified."
1839        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1840        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1841        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1842        std::fs::write(&dp, patch_text).unwrap();
1843
1844        // Installed file starts at cache content (patch not yet applied).
1845        let inst_dir = dir.path().join(".claude/skills/lang-pro");
1846        std::fs::create_dir_all(&inst_dir).unwrap();
1847        std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1848
1849        // New cache (simulates upstream update).
1850        let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1851        std::fs::create_dir_all(&new_cache_dir).unwrap();
1852        std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1853
1854        // Build the installed_files map as deploy_all would.
1855        let mut installed_files = std::collections::HashMap::new();
1856        installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1857
1858        apply_dir_patches(
1859            &PatchCtx {
1860                entry: &entry,
1861                repo_root: dir.path(),
1862            },
1863            &installed_files,
1864            &new_cache_dir,
1865        )
1866        .unwrap();
1867
1868        // The installed file should have the original patch applied.
1869        let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1870        assert_eq!(installed_after, installed_content);
1871
1872        // The stored patch must now describe the diff from new_cache to installed_content.
1873        // Verify by resetting the installed file to new_cache and reinstalling — must
1874        // yield installed_content (== expected_rebased_to_new_cache).
1875        std::fs::write(inst_dir.join("SKILL.md"), new_cache_content).unwrap();
1876        let mut reinstall_files = std::collections::HashMap::new();
1877        reinstall_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1878        apply_dir_patches(
1879            &PatchCtx {
1880                entry: &entry,
1881                repo_root: dir.path(),
1882            },
1883            &reinstall_files,
1884            &new_cache_dir,
1885        )
1886        .unwrap();
1887        assert_eq!(
1888            std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap(),
1889            expected_rebased_to_new_cache,
1890            "rebased patch applied to new_cache must reproduce installed_content"
1891        );
1892    }
1893
1894    #[test]
1895    fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1896        let dir = tempfile::tempdir().unwrap();
1897
1898        // The "new" cache content IS the patched content — patch becomes a no-op.
1899        let original = "# Skill\n\nOriginal.\n";
1900        let modified = "# Skill\n\nModified.\n";
1901        // New upstream == modified, so after applying patch the result equals new cache.
1902        let new_cache = modified; // upstream caught up
1903
1904        let entry = make_dir_skill_entry("lang-pro");
1905
1906        // Hand-written patch: "Original." → "Modified."
1907        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1908        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1909        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1910        std::fs::write(&dp, patch_text).unwrap();
1911
1912        // Installed file starts at original (patch not yet applied).
1913        let inst_dir = dir.path().join(".claude/skills/lang-pro");
1914        std::fs::create_dir_all(&inst_dir).unwrap();
1915        std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1916
1917        let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1918        std::fs::create_dir_all(&new_cache_dir).unwrap();
1919        std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1920
1921        let mut installed_files = std::collections::HashMap::new();
1922        installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1923
1924        apply_dir_patches(
1925            &PatchCtx {
1926                entry: &entry,
1927                repo_root: dir.path(),
1928            },
1929            &installed_files,
1930            &new_cache_dir,
1931        )
1932        .unwrap();
1933
1934        // Patch file must be removed (rebase produced empty diff).
1935        let removed_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1936        assert!(
1937            !removed_patch.exists(),
1938            "patch file must be removed when rebase yields empty diff"
1939        );
1940    }
1941
1942    #[test]
1943    fn apply_dir_patches_no_op_when_no_patches_dir() {
1944        let dir = tempfile::tempdir().unwrap();
1945
1946        // No patches directory at all.
1947        let entry = make_dir_skill_entry("lang-pro");
1948        let installed_files = std::collections::HashMap::new();
1949        let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1950        std::fs::create_dir_all(&source_dir).unwrap();
1951
1952        // Must succeed without error.
1953        apply_dir_patches(
1954            &PatchCtx {
1955                entry: &entry,
1956                repo_root: dir.path(),
1957            },
1958            &installed_files,
1959            &source_dir,
1960        )
1961        .unwrap();
1962    }
1963
1964    // -----------------------------------------------------------------------
1965    // apply_single_file_patch — rebase removes patch when result equals cache
1966    // -----------------------------------------------------------------------
1967
1968    #[test]
1969    fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1970        let dir = tempfile::tempdir().unwrap();
1971
1972        let original = "# Skill\n\nOriginal.\n";
1973        let modified = "# Skill\n\nModified.\n";
1974        // New cache == modified: after rebase, new_patch is empty → patch removed.
1975        let new_cache = modified;
1976
1977        let entry = make_skill_entry("test");
1978
1979        // Write patch using filesystem fixture: "Original." → "Modified."
1980        let patch_text =
1981            "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1982        write_patch_fixture(dir.path(), &entry, patch_text);
1983
1984        // Set up vendor cache (the "new" version).
1985        let vdir = dir.path().join(".skillfile/cache/skills/test");
1986        std::fs::create_dir_all(&vdir).unwrap();
1987        let source = vdir.join("test.md");
1988        std::fs::write(&source, new_cache).unwrap();
1989
1990        // Installed file is the original (patch not yet applied).
1991        let installed_dir = dir.path().join(".claude/skills");
1992        std::fs::create_dir_all(&installed_dir).unwrap();
1993        let dest = installed_dir.join("test.md");
1994        std::fs::write(&dest, original).unwrap();
1995
1996        apply_single_file_patch(
1997            &PatchCtx {
1998                entry: &entry,
1999                repo_root: dir.path(),
2000            },
2001            &dest,
2002            &source,
2003        )
2004        .unwrap();
2005
2006        // The installed file must be the patched (== new cache) result.
2007        assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
2008
2009        // Patch file must have been removed.
2010        assert!(
2011            !patch_fixture_path(dir.path(), &entry).exists(),
2012            "patch must be removed when new cache already matches patched content"
2013        );
2014    }
2015
2016    #[test]
2017    fn apply_single_file_patch_rewrites_patch_after_rebase() {
2018        let dir = tempfile::tempdir().unwrap();
2019
2020        // Old upstream, user edit, new upstream (different body — no overlap with user edit).
2021        let original = "# Skill\n\nOriginal.\n";
2022        let modified = "# Skill\n\nModified.\n";
2023        let new_cache = "# Skill\n\nOriginal v2.\n";
2024        // The rebase stores generate_patch(new_cache, modified).
2025        // Applying that to new_cache must reproduce `modified`.
2026        let expected_rebased_result = modified;
2027
2028        let entry = make_skill_entry("test");
2029
2030        // Hand-written patch: "Original." → "Modified."
2031        let patch_text =
2032            "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
2033        write_patch_fixture(dir.path(), &entry, patch_text);
2034
2035        // New vendor cache (upstream updated).
2036        let vdir = dir.path().join(".skillfile/cache/skills/test");
2037        std::fs::create_dir_all(&vdir).unwrap();
2038        let source = vdir.join("test.md");
2039        std::fs::write(&source, new_cache).unwrap();
2040
2041        // Installed still at original content (patch not applied yet).
2042        let installed_dir = dir.path().join(".claude/skills");
2043        std::fs::create_dir_all(&installed_dir).unwrap();
2044        let dest = installed_dir.join("test.md");
2045        std::fs::write(&dest, original).unwrap();
2046
2047        apply_single_file_patch(
2048            &PatchCtx {
2049                entry: &entry,
2050                repo_root: dir.path(),
2051            },
2052            &dest,
2053            &source,
2054        )
2055        .unwrap();
2056
2057        // Installed must now be the patched content.
2058        assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
2059
2060        // The rebased patch must still exist (new_cache != modified).
2061        assert!(
2062            patch_fixture_path(dir.path(), &entry).exists(),
2063            "rebased patch must still exist (new_cache != modified)"
2064        );
2065        // Verify the rebased patch yields expected_rebased_result when applied to new_cache.
2066        // Reset dest to new_cache and call apply_single_file_patch again.
2067        std::fs::write(&dest, new_cache).unwrap();
2068        std::fs::write(&source, new_cache).unwrap();
2069        apply_single_file_patch(
2070            &PatchCtx {
2071                entry: &entry,
2072                repo_root: dir.path(),
2073            },
2074            &dest,
2075            &source,
2076        )
2077        .unwrap();
2078        assert_eq!(
2079            std::fs::read_to_string(&dest).unwrap(),
2080            expected_rebased_result,
2081            "rebased patch applied to new_cache must reproduce installed content"
2082        );
2083    }
2084
2085    // -----------------------------------------------------------------------
2086    // check_preconditions
2087    // -----------------------------------------------------------------------
2088
2089    #[test]
2090    fn check_preconditions_no_targets_returns_error() {
2091        let dir = tempfile::tempdir().unwrap();
2092        let manifest = Manifest {
2093            entries: vec![],
2094            install_targets: vec![],
2095        };
2096        let result = check_preconditions(&manifest, dir.path());
2097        assert!(result.is_err());
2098        assert!(result
2099            .unwrap_err()
2100            .to_string()
2101            .contains("No install targets"));
2102    }
2103
2104    #[test]
2105    fn check_preconditions_pending_conflict_returns_error() {
2106        let dir = tempfile::tempdir().unwrap();
2107        let manifest = Manifest {
2108            entries: vec![],
2109            install_targets: vec![make_target("claude-code", Scope::Local)],
2110        };
2111
2112        write_conflict_fixture(
2113            dir.path(),
2114            &ConflictState {
2115                entry: "my-skill".into(),
2116                entity_type: EntityType::Skill,
2117                old_sha: "aaa".into(),
2118                new_sha: "bbb".into(),
2119            },
2120        );
2121
2122        let result = check_preconditions(&manifest, dir.path());
2123        assert!(result.is_err());
2124        assert!(result.unwrap_err().to_string().contains("pending conflict"));
2125    }
2126
2127    #[test]
2128    fn check_preconditions_ok_with_target_and_no_conflict() {
2129        let dir = tempfile::tempdir().unwrap();
2130        let manifest = Manifest {
2131            entries: vec![],
2132            install_targets: vec![make_target("claude-code", Scope::Local)],
2133        };
2134        check_preconditions(&manifest, dir.path()).unwrap();
2135    }
2136}