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