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.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.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");
825        std::fs::create_dir_all(&dest_dir).unwrap();
826        let dest = dest_dir.join("my-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.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");
1035        std::fs::create_dir_all(&installed_dir).unwrap();
1036        std::fs::write(
1037            installed_dir.join("test.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.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.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.path().join(format!("{prefix}/skills/my-skill.md"));
1175            assert!(dest.exists(), "Failed for adapter {adapter}");
1176            assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
1177        }
1178    }
1179
1180    // -- cmd_install --
1181
1182    #[test]
1183    fn cmd_install_no_manifest() {
1184        let dir = tempfile::tempdir().unwrap();
1185        let result = cmd_install(
1186            dir.path(),
1187            &CmdInstallOpts {
1188                dry_run: false,
1189                update: false,
1190                extra_targets: None,
1191            },
1192        );
1193        assert!(result.is_err());
1194        assert!(result.unwrap_err().to_string().contains("not found"));
1195    }
1196
1197    #[test]
1198    fn cmd_install_no_install_targets() {
1199        let dir = tempfile::tempdir().unwrap();
1200        std::fs::write(
1201            dir.path().join("Skillfile"),
1202            "local  skill  foo  skills/foo.md\n",
1203        )
1204        .unwrap();
1205
1206        let result = cmd_install(
1207            dir.path(),
1208            &CmdInstallOpts {
1209                dry_run: false,
1210                update: false,
1211                extra_targets: None,
1212            },
1213        );
1214        assert!(result.is_err());
1215        assert!(result
1216            .unwrap_err()
1217            .to_string()
1218            .contains("No install targets"));
1219    }
1220
1221    #[test]
1222    fn cmd_install_extra_targets_fallback() {
1223        let dir = tempfile::tempdir().unwrap();
1224        // Skillfile with entries but NO install lines.
1225        std::fs::write(
1226            dir.path().join("Skillfile"),
1227            "local  skill  foo  skills/foo.md\n",
1228        )
1229        .unwrap();
1230        let source_file = dir.path().join("skills/foo.md");
1231        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1232        std::fs::write(&source_file, "# Foo").unwrap();
1233
1234        // Pass extra targets — should be used as fallback.
1235        let targets = vec![make_target("claude-code", Scope::Local)];
1236        cmd_install(
1237            dir.path(),
1238            &CmdInstallOpts {
1239                dry_run: false,
1240                update: false,
1241                extra_targets: Some(&targets),
1242            },
1243        )
1244        .unwrap();
1245
1246        let dest = dir.path().join(".claude/skills/foo.md");
1247        assert!(
1248            dest.exists(),
1249            "extra_targets must be used when Skillfile has none"
1250        );
1251        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
1252    }
1253
1254    #[test]
1255    fn cmd_install_skillfile_targets_win_over_extra() {
1256        let dir = tempfile::tempdir().unwrap();
1257        // Skillfile WITH install lines.
1258        std::fs::write(
1259            dir.path().join("Skillfile"),
1260            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
1261        )
1262        .unwrap();
1263        let source_file = dir.path().join("skills/foo.md");
1264        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1265        std::fs::write(&source_file, "# Foo").unwrap();
1266
1267        // Pass extra targets for gemini-cli — should be IGNORED (Skillfile wins).
1268        let targets = vec![make_target("gemini-cli", Scope::Local)];
1269        cmd_install(
1270            dir.path(),
1271            &CmdInstallOpts {
1272                dry_run: false,
1273                update: false,
1274                extra_targets: Some(&targets),
1275            },
1276        )
1277        .unwrap();
1278
1279        // claude-code (from Skillfile) should be deployed.
1280        assert!(dir.path().join(".claude/skills/foo.md").exists());
1281        // gemini-cli (from extra_targets) should NOT be deployed.
1282        assert!(!dir.path().join(".gemini").exists());
1283    }
1284
1285    #[test]
1286    fn cmd_install_dry_run_no_files() {
1287        let dir = tempfile::tempdir().unwrap();
1288        std::fs::write(
1289            dir.path().join("Skillfile"),
1290            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
1291        )
1292        .unwrap();
1293        let source_file = dir.path().join("skills/foo.md");
1294        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1295        std::fs::write(&source_file, "# Foo").unwrap();
1296
1297        cmd_install(
1298            dir.path(),
1299            &CmdInstallOpts {
1300                dry_run: true,
1301                update: false,
1302                extra_targets: None,
1303            },
1304        )
1305        .unwrap();
1306
1307        assert!(!dir.path().join(".claude").exists());
1308    }
1309
1310    #[test]
1311    fn cmd_install_deploys_to_multiple_adapters() {
1312        let dir = tempfile::tempdir().unwrap();
1313        std::fs::write(
1314            dir.path().join("Skillfile"),
1315            "install  claude-code  local\n\
1316             install  gemini-cli  local\n\
1317             install  codex  local\n\
1318             local  skill  foo  skills/foo.md\n\
1319             local  agent  bar  agents/bar.md\n",
1320        )
1321        .unwrap();
1322        std::fs::create_dir_all(dir.path().join("skills")).unwrap();
1323        std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
1324        std::fs::create_dir_all(dir.path().join("agents")).unwrap();
1325        std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
1326
1327        cmd_install(
1328            dir.path(),
1329            &CmdInstallOpts {
1330                dry_run: false,
1331                update: false,
1332                extra_targets: None,
1333            },
1334        )
1335        .unwrap();
1336
1337        // skill deployed to all three adapters
1338        assert!(dir.path().join(".claude/skills/foo.md").exists());
1339        assert!(dir.path().join(".gemini/skills/foo.md").exists());
1340        assert!(dir.path().join(".codex/skills/foo.md").exists());
1341
1342        // agent deployed to claude-code and gemini-cli but NOT codex
1343        assert!(dir.path().join(".claude/agents/bar.md").exists());
1344        assert!(dir.path().join(".gemini/agents/bar.md").exists());
1345        assert!(!dir.path().join(".codex/agents").exists());
1346    }
1347
1348    #[test]
1349    fn cmd_install_pending_conflict_blocks() {
1350        let dir = tempfile::tempdir().unwrap();
1351        std::fs::write(
1352            dir.path().join("Skillfile"),
1353            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
1354        )
1355        .unwrap();
1356
1357        write_conflict_fixture(
1358            dir.path(),
1359            &ConflictState {
1360                entry: "foo".into(),
1361                entity_type: EntityType::Skill,
1362                old_sha: "aaa".into(),
1363                new_sha: "bbb".into(),
1364            },
1365        );
1366
1367        let result = cmd_install(
1368            dir.path(),
1369            &CmdInstallOpts {
1370                dry_run: false,
1371                update: false,
1372                extra_targets: None,
1373            },
1374        );
1375        assert!(result.is_err());
1376        assert!(result.unwrap_err().to_string().contains("pending conflict"));
1377    }
1378
1379    // -----------------------------------------------------------------------
1380    // Helpers shared by the new tests below
1381    // -----------------------------------------------------------------------
1382
1383    /// Build a single-file github skill Entry.
1384    fn make_skill_entry(name: &str) -> Entry {
1385        Entry {
1386            entity_type: EntityType::Skill,
1387            name: name.into(),
1388            source: SourceFields::Github {
1389                owner_repo: "owner/repo".into(),
1390                path_in_repo: format!("skills/{name}.md"),
1391                ref_: "main".into(),
1392            },
1393        }
1394    }
1395
1396    /// Build a directory github skill Entry (path_in_repo has no `.md` suffix).
1397    fn make_dir_skill_entry(name: &str) -> Entry {
1398        Entry {
1399            entity_type: EntityType::Skill,
1400            name: name.into(),
1401            source: SourceFields::Github {
1402                owner_repo: "owner/repo".into(),
1403                path_in_repo: format!("skills/{name}"),
1404                ref_: "main".into(),
1405            },
1406        }
1407    }
1408
1409    /// Write a minimal Skillfile + Skillfile.lock for a single single-file github skill.
1410    fn setup_github_skill_repo(dir: &Path, name: &str, cache_content: &str) {
1411        // Manifest
1412        std::fs::write(
1413            dir.join("Skillfile"),
1414            format!(
1415                "install  claude-code  local\ngithub  skill  {name}  owner/repo  skills/{name}.md\n"
1416            ),
1417        )
1418        .unwrap();
1419
1420        // Lock file via filesystem fixture (no cross-crate call).
1421        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1422        locked.insert(
1423            format!("github/skill/{name}"),
1424            LockEntry {
1425                sha: "abc123def456abc123def456abc123def456abc123".into(),
1426                raw_url: format!(
1427                    "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
1428                ),
1429            },
1430        );
1431        write_lock_fixture(dir, &locked);
1432
1433        // Vendor cache
1434        let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
1435        std::fs::create_dir_all(&vdir).unwrap();
1436        std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
1437    }
1438
1439    // -----------------------------------------------------------------------
1440    // auto_pin_entry — single-file entry
1441    // -----------------------------------------------------------------------
1442
1443    #[test]
1444    fn auto_pin_entry_local_is_skipped() {
1445        let dir = tempfile::tempdir().unwrap();
1446
1447        // Local entry: auto_pin should be a no-op.
1448        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1449        let manifest = Manifest {
1450            entries: vec![entry.clone()],
1451            install_targets: vec![make_target("claude-code", Scope::Local)],
1452        };
1453
1454        // Provide installed file that differs from source — pin should NOT fire.
1455        let skills_dir = dir.path().join("skills");
1456        std::fs::create_dir_all(&skills_dir).unwrap();
1457        std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
1458
1459        auto_pin_entry(&entry, &manifest, dir.path());
1460
1461        // No patch must have been written.
1462        assert!(
1463            !patch_fixture_path(dir.path(), &entry).exists(),
1464            "local entry must never be pinned"
1465        );
1466    }
1467
1468    #[test]
1469    fn auto_pin_entry_missing_lock_is_skipped() {
1470        let dir = tempfile::tempdir().unwrap();
1471
1472        let entry = make_skill_entry("test");
1473        let manifest = Manifest {
1474            entries: vec![entry.clone()],
1475            install_targets: vec![make_target("claude-code", Scope::Local)],
1476        };
1477
1478        // No Skillfile.lock — should silently return without panicking.
1479        auto_pin_entry(&entry, &manifest, dir.path());
1480
1481        assert!(!patch_fixture_path(dir.path(), &entry).exists());
1482    }
1483
1484    #[test]
1485    fn auto_pin_entry_missing_lock_key_is_skipped() {
1486        let dir = tempfile::tempdir().unwrap();
1487
1488        // Lock exists but for a different entry.
1489        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1490        locked.insert(
1491            "github/skill/other".into(),
1492            LockEntry {
1493                sha: "aabbcc".into(),
1494                raw_url: "https://example.com/other.md".into(),
1495            },
1496        );
1497        write_lock_fixture(dir.path(), &locked);
1498
1499        let entry = make_skill_entry("test");
1500        let manifest = Manifest {
1501            entries: vec![entry.clone()],
1502            install_targets: vec![make_target("claude-code", Scope::Local)],
1503        };
1504
1505        auto_pin_entry(&entry, &manifest, dir.path());
1506
1507        assert!(!patch_fixture_path(dir.path(), &entry).exists());
1508    }
1509
1510    #[test]
1511    fn auto_pin_entry_writes_patch_when_installed_differs() {
1512        let dir = tempfile::tempdir().unwrap();
1513        let name = "my-skill";
1514
1515        let cache_content = "# My Skill\n\nOriginal content.\n";
1516        let installed_content = "# My Skill\n\nUser-modified content.\n";
1517
1518        setup_github_skill_repo(dir.path(), name, cache_content);
1519
1520        // Place a modified installed file.
1521        let installed_dir = dir.path().join(".claude/skills");
1522        std::fs::create_dir_all(&installed_dir).unwrap();
1523        std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1524
1525        let entry = make_skill_entry(name);
1526        let manifest = Manifest {
1527            entries: vec![entry.clone()],
1528            install_targets: vec![make_target("claude-code", Scope::Local)],
1529        };
1530
1531        auto_pin_entry(&entry, &manifest, dir.path());
1532
1533        assert!(
1534            patch_fixture_path(dir.path(), &entry).exists(),
1535            "patch should be written when installed differs from cache"
1536        );
1537
1538        // Verify the patch round-trips: reset the installed file to cache_content and
1539        // reinstall — the patch must produce installed_content.
1540        std::fs::write(installed_dir.join(format!("{name}.md")), cache_content).unwrap();
1541        let target = make_target("claude-code", Scope::Local);
1542        install_entry(
1543            &entry,
1544            &target,
1545            &InstallCtx {
1546                repo_root: dir.path(),
1547                opts: None,
1548            },
1549        )
1550        .unwrap();
1551        assert_eq!(
1552            std::fs::read_to_string(installed_dir.join(format!("{name}.md"))).unwrap(),
1553            installed_content,
1554        );
1555    }
1556
1557    #[test]
1558    fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1559        let dir = tempfile::tempdir().unwrap();
1560        let name = "my-skill";
1561
1562        let cache_content = "# My Skill\n\nOriginal.\n";
1563        let installed_content = "# My Skill\n\nModified.\n";
1564
1565        setup_github_skill_repo(dir.path(), name, cache_content);
1566
1567        let entry = make_skill_entry(name);
1568        let manifest = Manifest {
1569            entries: vec![entry.clone()],
1570            install_targets: vec![make_target("claude-code", Scope::Local)],
1571        };
1572
1573        // Pre-write the correct patch (cache → installed) using the fixture helper.
1574        // Hand-written unified diff: "Original." → "Modified."
1575        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";
1576        write_patch_fixture(dir.path(), &entry, patch_text);
1577
1578        // Write installed file that matches what the patch produces.
1579        let installed_dir = dir.path().join(".claude/skills");
1580        std::fs::create_dir_all(&installed_dir).unwrap();
1581        std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1582
1583        // Record mtime of patch so we can detect if it changed.
1584        let patch_path = patch_fixture_path(dir.path(), &entry);
1585        let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1586
1587        // Small sleep so that any write would produce a different mtime.
1588        std::thread::sleep(std::time::Duration::from_millis(20));
1589
1590        auto_pin_entry(&entry, &manifest, dir.path());
1591
1592        let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1593
1594        assert_eq!(
1595            mtime_before, mtime_after,
1596            "patch must not be rewritten when already up to date"
1597        );
1598    }
1599
1600    #[test]
1601    fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1602        let dir = tempfile::tempdir().unwrap();
1603        let name = "my-skill";
1604
1605        let cache_content = "# My Skill\n\nOriginal.\n";
1606        let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1607
1608        setup_github_skill_repo(dir.path(), name, cache_content);
1609
1610        let entry = make_skill_entry(name);
1611        let manifest = Manifest {
1612            entries: vec![entry.clone()],
1613            install_targets: vec![make_target("claude-code", Scope::Local)],
1614        };
1615
1616        // Stored patch reflects the old installed state: "Original." → "First edit."
1617        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";
1618        write_patch_fixture(dir.path(), &entry, old_patch);
1619
1620        // But the actual installed file has further edits.
1621        let installed_dir = dir.path().join(".claude/skills");
1622        std::fs::create_dir_all(&installed_dir).unwrap();
1623        std::fs::write(installed_dir.join(format!("{name}.md")), new_installed).unwrap();
1624
1625        auto_pin_entry(&entry, &manifest, dir.path());
1626
1627        // The patch was re-written to reflect new_installed. Verify by resetting the
1628        // installed file to cache_content and reinstalling — must yield new_installed.
1629        std::fs::write(installed_dir.join(format!("{name}.md")), cache_content).unwrap();
1630        let target = make_target("claude-code", Scope::Local);
1631        install_entry(
1632            &entry,
1633            &target,
1634            &InstallCtx {
1635                repo_root: dir.path(),
1636                opts: None,
1637            },
1638        )
1639        .unwrap();
1640        assert_eq!(
1641            std::fs::read_to_string(installed_dir.join(format!("{name}.md"))).unwrap(),
1642            new_installed,
1643            "updated patch must describe the latest installed content"
1644        );
1645    }
1646
1647    // -----------------------------------------------------------------------
1648    // auto_pin_dir_entry
1649    // -----------------------------------------------------------------------
1650
1651    #[test]
1652    fn auto_pin_dir_entry_writes_per_file_patches() {
1653        let dir = tempfile::tempdir().unwrap();
1654        let name = "lang-pro";
1655
1656        // Manifest + lock (dir entry)
1657        std::fs::write(
1658            dir.path().join("Skillfile"),
1659            format!(
1660                "install  claude-code  local\ngithub  skill  {name}  owner/repo  skills/{name}\n"
1661            ),
1662        )
1663        .unwrap();
1664        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1665        locked.insert(
1666            format!("github/skill/{name}"),
1667            LockEntry {
1668                sha: "deadbeefdeadbeefdeadbeef".into(),
1669                raw_url: format!("https://example.com/{name}"),
1670            },
1671        );
1672        write_lock_fixture(dir.path(), &locked);
1673
1674        // Vendor cache with two files.
1675        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1676        std::fs::create_dir_all(&vdir).unwrap();
1677        std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1678        std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1679
1680        // Installed dir (nested mode for skills).
1681        let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1682        std::fs::create_dir_all(&inst_dir).unwrap();
1683        std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1684        std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1685
1686        let entry = make_dir_skill_entry(name);
1687        let manifest = Manifest {
1688            entries: vec![entry.clone()],
1689            install_targets: vec![make_target("claude-code", Scope::Local)],
1690        };
1691
1692        auto_pin_entry(&entry, &manifest, dir.path());
1693
1694        // Patch for the modified file should exist.
1695        let skill_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1696        assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1697
1698        // Patch for the unmodified file should NOT exist.
1699        let examples_patch = dir_patch_fixture_path(dir.path(), &entry, "examples.md");
1700        assert!(
1701            !examples_patch.exists(),
1702            "patch for examples.md must not be written (content unchanged)"
1703        );
1704    }
1705
1706    #[test]
1707    fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1708        let dir = tempfile::tempdir().unwrap();
1709        let name = "lang-pro";
1710
1711        // Write lock so we don't bail out there.
1712        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1713        locked.insert(
1714            format!("github/skill/{name}"),
1715            LockEntry {
1716                sha: "abc".into(),
1717                raw_url: "https://example.com".into(),
1718            },
1719        );
1720        write_lock_fixture(dir.path(), &locked);
1721
1722        let entry = make_dir_skill_entry(name);
1723        let manifest = Manifest {
1724            entries: vec![entry.clone()],
1725            install_targets: vec![make_target("claude-code", Scope::Local)],
1726        };
1727
1728        // No vendor dir — must silently return without panicking.
1729        auto_pin_entry(&entry, &manifest, dir.path());
1730
1731        assert!(!has_dir_patch_fixture(dir.path(), &entry));
1732    }
1733
1734    #[test]
1735    fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1736        let dir = tempfile::tempdir().unwrap();
1737        let name = "lang-pro";
1738
1739        let cache_content = "# Lang Pro\n\nOriginal.\n";
1740        let modified = "# Lang Pro\n\nModified.\n";
1741
1742        // Write lock.
1743        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1744        locked.insert(
1745            format!("github/skill/{name}"),
1746            LockEntry {
1747                sha: "abc".into(),
1748                raw_url: "https://example.com".into(),
1749            },
1750        );
1751        write_lock_fixture(dir.path(), &locked);
1752
1753        // Vendor cache.
1754        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1755        std::fs::create_dir_all(&vdir).unwrap();
1756        std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1757
1758        // Installed dir.
1759        let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1760        std::fs::create_dir_all(&inst_dir).unwrap();
1761        std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1762
1763        let entry = make_dir_skill_entry(name);
1764        let manifest = Manifest {
1765            entries: vec![entry.clone()],
1766            install_targets: vec![make_target("claude-code", Scope::Local)],
1767        };
1768
1769        // Pre-write the correct patch: "Original." → "Modified." for SKILL.md
1770        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Lang Pro\n \n-Original.\n+Modified.\n";
1771        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1772        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1773        std::fs::write(&dp, patch_text).unwrap();
1774
1775        let patch_path = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1776        let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1777
1778        std::thread::sleep(std::time::Duration::from_millis(20));
1779
1780        auto_pin_entry(&entry, &manifest, dir.path());
1781
1782        let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1783
1784        assert_eq!(
1785            mtime_before, mtime_after,
1786            "dir patch must not be rewritten when already up to date"
1787        );
1788    }
1789
1790    // -----------------------------------------------------------------------
1791    // apply_dir_patches
1792    // -----------------------------------------------------------------------
1793
1794    #[test]
1795    fn apply_dir_patches_applies_patch_and_rebases() {
1796        let dir = tempfile::tempdir().unwrap();
1797
1798        // Old upstream → user's installed version (what the stored patch records).
1799        let cache_content = "# Skill\n\nOriginal.\n";
1800        let installed_content = "# Skill\n\nModified.\n";
1801        // New upstream has a different body line but same structure.
1802        let new_cache_content = "# Skill\n\nOriginal v2.\n";
1803        // After rebase, the rebased patch encodes the diff from new_cache to installed.
1804        // Applying that rebased patch to new_cache must yield installed_content.
1805        let expected_rebased_to_new_cache = installed_content;
1806
1807        let entry = make_dir_skill_entry("lang-pro");
1808
1809        // Create patch dir with a valid patch (old cache → installed): "Original." → "Modified."
1810        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1811        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1812        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1813        std::fs::write(&dp, patch_text).unwrap();
1814
1815        // Installed file starts at cache content (patch not yet applied).
1816        let inst_dir = dir.path().join(".claude/skills/lang-pro");
1817        std::fs::create_dir_all(&inst_dir).unwrap();
1818        std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1819
1820        // New cache (simulates upstream update).
1821        let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1822        std::fs::create_dir_all(&new_cache_dir).unwrap();
1823        std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1824
1825        // Build the installed_files map as deploy_all would.
1826        let mut installed_files = std::collections::HashMap::new();
1827        installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1828
1829        apply_dir_patches(
1830            &PatchCtx {
1831                entry: &entry,
1832                repo_root: dir.path(),
1833            },
1834            &installed_files,
1835            &new_cache_dir,
1836        )
1837        .unwrap();
1838
1839        // The installed file should have the original patch applied.
1840        let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1841        assert_eq!(installed_after, installed_content);
1842
1843        // The stored patch must now describe the diff from new_cache to installed_content.
1844        // Verify by resetting the installed file to new_cache and reinstalling — must
1845        // yield installed_content (== expected_rebased_to_new_cache).
1846        std::fs::write(inst_dir.join("SKILL.md"), new_cache_content).unwrap();
1847        let mut reinstall_files = std::collections::HashMap::new();
1848        reinstall_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1849        apply_dir_patches(
1850            &PatchCtx {
1851                entry: &entry,
1852                repo_root: dir.path(),
1853            },
1854            &reinstall_files,
1855            &new_cache_dir,
1856        )
1857        .unwrap();
1858        assert_eq!(
1859            std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap(),
1860            expected_rebased_to_new_cache,
1861            "rebased patch applied to new_cache must reproduce installed_content"
1862        );
1863    }
1864
1865    #[test]
1866    fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1867        let dir = tempfile::tempdir().unwrap();
1868
1869        // The "new" cache content IS the patched content — patch becomes a no-op.
1870        let original = "# Skill\n\nOriginal.\n";
1871        let modified = "# Skill\n\nModified.\n";
1872        // New upstream == modified, so after applying patch the result equals new cache.
1873        let new_cache = modified; // upstream caught up
1874
1875        let entry = make_dir_skill_entry("lang-pro");
1876
1877        // Hand-written patch: "Original." → "Modified."
1878        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1879        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1880        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1881        std::fs::write(&dp, patch_text).unwrap();
1882
1883        // Installed file starts at original (patch not yet applied).
1884        let inst_dir = dir.path().join(".claude/skills/lang-pro");
1885        std::fs::create_dir_all(&inst_dir).unwrap();
1886        std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1887
1888        let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1889        std::fs::create_dir_all(&new_cache_dir).unwrap();
1890        std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1891
1892        let mut installed_files = std::collections::HashMap::new();
1893        installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1894
1895        apply_dir_patches(
1896            &PatchCtx {
1897                entry: &entry,
1898                repo_root: dir.path(),
1899            },
1900            &installed_files,
1901            &new_cache_dir,
1902        )
1903        .unwrap();
1904
1905        // Patch file must be removed (rebase produced empty diff).
1906        let removed_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1907        assert!(
1908            !removed_patch.exists(),
1909            "patch file must be removed when rebase yields empty diff"
1910        );
1911    }
1912
1913    #[test]
1914    fn apply_dir_patches_no_op_when_no_patches_dir() {
1915        let dir = tempfile::tempdir().unwrap();
1916
1917        // No patches directory at all.
1918        let entry = make_dir_skill_entry("lang-pro");
1919        let installed_files = std::collections::HashMap::new();
1920        let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1921        std::fs::create_dir_all(&source_dir).unwrap();
1922
1923        // Must succeed without error.
1924        apply_dir_patches(
1925            &PatchCtx {
1926                entry: &entry,
1927                repo_root: dir.path(),
1928            },
1929            &installed_files,
1930            &source_dir,
1931        )
1932        .unwrap();
1933    }
1934
1935    // -----------------------------------------------------------------------
1936    // apply_single_file_patch — rebase removes patch when result equals cache
1937    // -----------------------------------------------------------------------
1938
1939    #[test]
1940    fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1941        let dir = tempfile::tempdir().unwrap();
1942
1943        let original = "# Skill\n\nOriginal.\n";
1944        let modified = "# Skill\n\nModified.\n";
1945        // New cache == modified: after rebase, new_patch is empty → patch removed.
1946        let new_cache = modified;
1947
1948        let entry = make_skill_entry("test");
1949
1950        // Write patch using filesystem fixture: "Original." → "Modified."
1951        let patch_text =
1952            "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1953        write_patch_fixture(dir.path(), &entry, patch_text);
1954
1955        // Set up vendor cache (the "new" version).
1956        let vdir = dir.path().join(".skillfile/cache/skills/test");
1957        std::fs::create_dir_all(&vdir).unwrap();
1958        let source = vdir.join("test.md");
1959        std::fs::write(&source, new_cache).unwrap();
1960
1961        // Installed file is the original (patch not yet applied).
1962        let installed_dir = dir.path().join(".claude/skills");
1963        std::fs::create_dir_all(&installed_dir).unwrap();
1964        let dest = installed_dir.join("test.md");
1965        std::fs::write(&dest, original).unwrap();
1966
1967        apply_single_file_patch(
1968            &PatchCtx {
1969                entry: &entry,
1970                repo_root: dir.path(),
1971            },
1972            &dest,
1973            &source,
1974        )
1975        .unwrap();
1976
1977        // The installed file must be the patched (== new cache) result.
1978        assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1979
1980        // Patch file must have been removed.
1981        assert!(
1982            !patch_fixture_path(dir.path(), &entry).exists(),
1983            "patch must be removed when new cache already matches patched content"
1984        );
1985    }
1986
1987    #[test]
1988    fn apply_single_file_patch_rewrites_patch_after_rebase() {
1989        let dir = tempfile::tempdir().unwrap();
1990
1991        // Old upstream, user edit, new upstream (different body — no overlap with user edit).
1992        let original = "# Skill\n\nOriginal.\n";
1993        let modified = "# Skill\n\nModified.\n";
1994        let new_cache = "# Skill\n\nOriginal v2.\n";
1995        // The rebase stores generate_patch(new_cache, modified).
1996        // Applying that to new_cache must reproduce `modified`.
1997        let expected_rebased_result = modified;
1998
1999        let entry = make_skill_entry("test");
2000
2001        // Hand-written patch: "Original." → "Modified."
2002        let patch_text =
2003            "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
2004        write_patch_fixture(dir.path(), &entry, patch_text);
2005
2006        // New vendor cache (upstream updated).
2007        let vdir = dir.path().join(".skillfile/cache/skills/test");
2008        std::fs::create_dir_all(&vdir).unwrap();
2009        let source = vdir.join("test.md");
2010        std::fs::write(&source, new_cache).unwrap();
2011
2012        // Installed still at original content (patch not applied yet).
2013        let installed_dir = dir.path().join(".claude/skills");
2014        std::fs::create_dir_all(&installed_dir).unwrap();
2015        let dest = installed_dir.join("test.md");
2016        std::fs::write(&dest, original).unwrap();
2017
2018        apply_single_file_patch(
2019            &PatchCtx {
2020                entry: &entry,
2021                repo_root: dir.path(),
2022            },
2023            &dest,
2024            &source,
2025        )
2026        .unwrap();
2027
2028        // Installed must now be the patched content.
2029        assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
2030
2031        // The rebased patch must still exist (new_cache != modified).
2032        assert!(
2033            patch_fixture_path(dir.path(), &entry).exists(),
2034            "rebased patch must still exist (new_cache != modified)"
2035        );
2036        // Verify the rebased patch yields expected_rebased_result when applied to new_cache.
2037        // Reset dest to new_cache and call apply_single_file_patch again.
2038        std::fs::write(&dest, new_cache).unwrap();
2039        std::fs::write(&source, new_cache).unwrap();
2040        apply_single_file_patch(
2041            &PatchCtx {
2042                entry: &entry,
2043                repo_root: dir.path(),
2044            },
2045            &dest,
2046            &source,
2047        )
2048        .unwrap();
2049        assert_eq!(
2050            std::fs::read_to_string(&dest).unwrap(),
2051            expected_rebased_result,
2052            "rebased patch applied to new_cache must reproduce installed content"
2053        );
2054    }
2055
2056    // -----------------------------------------------------------------------
2057    // check_preconditions
2058    // -----------------------------------------------------------------------
2059
2060    #[test]
2061    fn check_preconditions_no_targets_returns_error() {
2062        let dir = tempfile::tempdir().unwrap();
2063        let manifest = Manifest {
2064            entries: vec![],
2065            install_targets: vec![],
2066        };
2067        let result = check_preconditions(&manifest, dir.path());
2068        assert!(result.is_err());
2069        assert!(result
2070            .unwrap_err()
2071            .to_string()
2072            .contains("No install targets"));
2073    }
2074
2075    #[test]
2076    fn check_preconditions_pending_conflict_returns_error() {
2077        let dir = tempfile::tempdir().unwrap();
2078        let manifest = Manifest {
2079            entries: vec![],
2080            install_targets: vec![make_target("claude-code", Scope::Local)],
2081        };
2082
2083        write_conflict_fixture(
2084            dir.path(),
2085            &ConflictState {
2086                entry: "my-skill".into(),
2087                entity_type: EntityType::Skill,
2088                old_sha: "aaa".into(),
2089                new_sha: "bbb".into(),
2090            },
2091        );
2092
2093        let result = check_preconditions(&manifest, dir.path());
2094        assert!(result.is_err());
2095        assert!(result.unwrap_err().to_string().contains("pending conflict"));
2096    }
2097
2098    #[test]
2099    fn check_preconditions_ok_with_target_and_no_conflict() {
2100        let dir = tempfile::tempdir().unwrap();
2101        let manifest = Manifest {
2102            entries: vec![],
2103            install_targets: vec![make_target("claude-code", Scope::Local)],
2104        };
2105        check_preconditions(&manifest, dir.path()).unwrap();
2106    }
2107}