Skip to main content

skillfile_deploy/
install.rs

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