Skip to main content

skillfile_deploy/
install.rs

1use std::cell::Cell;
2use std::collections::{BTreeMap, HashMap, HashSet};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use skillfile_core::conflict::{read_conflict, write_conflict};
8use skillfile_core::error::SkillfileError;
9use skillfile_core::lock::{lock_key, read_lock};
10use skillfile_core::models::{
11    short_sha, ConflictState, Entry, InstallOptions, InstallTarget, Manifest,
12};
13use skillfile_core::parser::{parse_manifest, MANIFEST_NAME};
14use skillfile_core::patch::{
15    apply_patch_pure, dir_patch_path, generate_patch, has_patch, patch_path, patches_root,
16    read_patch, remove_dir_patch, remove_patch, walkdir, write_dir_patch, write_patch,
17};
18use skillfile_core::progress;
19use skillfile_sources::strategy::{content_file, is_dir_entry};
20use skillfile_sources::sync::{cmd_sync, vendor_dir_for};
21
22use crate::adapter::{adapters, AdapterScope, DeployRequest, DirInstallMode, PlatformAdapter};
23use crate::paths::source_path;
24
25static SNAPSHOT_COUNTER: AtomicU64 = AtomicU64::new(0);
26
27// ---------------------------------------------------------------------------
28// Patch application helpers
29// ---------------------------------------------------------------------------
30
31fn to_patch_conflict(err: &SkillfileError, entry_name: &str) -> SkillfileError {
32    SkillfileError::PatchConflict {
33        message: err.to_string(),
34        entry_name: entry_name.to_string(),
35    }
36}
37
38struct PatchCtx<'a> {
39    entry: &'a Entry,
40    repo_root: &'a Path,
41}
42
43/// Rebase a patch file against a new cache: write the updated patch or remove it
44/// if the upstream content already equals the patched result.
45fn rebase_single_patch(
46    ctx: &PatchCtx<'_>,
47    source: &Path,
48    patched: &str,
49) -> Result<(), SkillfileError> {
50    let cache_text = std::fs::read_to_string(source)?;
51    let new_patch = generate_patch(&cache_text, patched, &format!("{}.md", ctx.entry.name));
52    if new_patch.is_empty() {
53        remove_patch(ctx.entry, ctx.repo_root)?;
54    } else {
55        write_patch(ctx.entry, &new_patch, ctx.repo_root)?;
56    }
57    Ok(())
58}
59
60/// Apply stored patch (if any) to a single installed file, then rebase the patch
61/// against the new cache content so status comparisons remain correct.
62fn apply_single_file_patch(
63    ctx: &PatchCtx<'_>,
64    dest: &Path,
65    source: &Path,
66) -> Result<(), SkillfileError> {
67    if !has_patch(ctx.entry, ctx.repo_root) {
68        return Ok(());
69    }
70    let patch_text = read_patch(ctx.entry, ctx.repo_root)?;
71    let original = std::fs::read_to_string(dest)?;
72    let patched = apply_patch_pure(&original, &patch_text)
73        .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
74    std::fs::write(dest, &patched)?;
75
76    // Rebase: regenerate patch against new cache so `diff` shows accurate deltas.
77    rebase_single_patch(ctx, source, &patched)
78}
79
80/// Apply per-file patches to all installed files of a directory entry.
81/// Rebases each patch against the new cache content after applying.
82fn apply_dir_patches(
83    ctx: &PatchCtx<'_>,
84    installed_files: &HashMap<String, PathBuf>,
85    source_dir: &Path,
86) -> Result<(), SkillfileError> {
87    let patches_dir = patches_root(ctx.repo_root)
88        .join(ctx.entry.entity_type.dir_name())
89        .join(&ctx.entry.name);
90    if !patches_dir.is_dir() {
91        return Ok(());
92    }
93
94    let patch_files: Vec<PathBuf> = walkdir(&patches_dir)
95        .into_iter()
96        .filter(|p| p.extension().is_some_and(|e| e == "patch"))
97        .collect();
98
99    for patch_file in patch_files {
100        let Some(rel) = patch_file
101            .strip_prefix(&patches_dir)
102            .ok()
103            .and_then(|p| p.to_str())
104            .and_then(|s| s.strip_suffix(".patch"))
105            .map(str::to_string)
106        else {
107            continue;
108        };
109
110        let Some(target) = installed_files.get(&rel).filter(|p| p.exists()) else {
111            continue;
112        };
113
114        let patch_text = std::fs::read_to_string(&patch_file)?;
115        let original = std::fs::read_to_string(target)?;
116        let patched = apply_patch_pure(&original, &patch_text)
117            .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
118        std::fs::write(target, &patched)?;
119
120        // Rebase: regenerate patch against new cache content.
121        let cache_file = source_dir.join(&rel);
122        if !cache_file.exists() {
123            continue;
124        }
125        let cache_text = std::fs::read_to_string(&cache_file)?;
126        let new_patch = generate_patch(&cache_text, &patched, &rel);
127        if new_patch.is_empty() {
128            std::fs::remove_file(&patch_file)?;
129        } else {
130            write_dir_patch(&dir_patch_path(ctx.entry, &rel, ctx.repo_root), &new_patch)?;
131        }
132    }
133    Ok(())
134}
135
136// ---------------------------------------------------------------------------
137// Auto-pin helpers (used by install --update)
138// ---------------------------------------------------------------------------
139
140/// Check whether applying `patch_text` to `cache_text` reproduces `installed_text`.
141///
142/// Returns `true` when the patch already describes the installed content (no re-pin
143/// needed), or when the patch is inconsistent with the cache (preserve without
144/// clobbering). Returns `false` when the installed content has edits beyond what
145/// the patch captures.
146fn patch_already_covers(patch_text: &str, cache_text: &str, installed_text: &str) -> bool {
147    match apply_patch_pure(cache_text, patch_text) {
148        Ok(expected) if installed_text == expected => true, // no new edits
149        Err(_) => true,                                     // cache inconsistent — preserve
150        Ok(_) => false,                                     // additional edits — fall through
151    }
152}
153
154fn should_skip_pin(ctx: &PatchCtx<'_>, cache_text: &str, installed_text: &str) -> bool {
155    if !has_patch(ctx.entry, ctx.repo_root) {
156        return false;
157    }
158    let Ok(pt) = read_patch(ctx.entry, ctx.repo_root) else {
159        return false;
160    };
161    patch_already_covers(&pt, cache_text, installed_text)
162}
163
164fn divergent_auto_pin_error(entry_name: &str, labels: &[String]) -> SkillfileError {
165    SkillfileError::Install(format!(
166        "'{entry_name}' has divergent edits across install targets: {} — reconcile them before running `skillfile install --update`",
167        labels.join(", ")
168    ))
169}
170
171fn target_adapter(target: &InstallTarget) -> Result<&'static dyn PlatformAdapter, SkillfileError> {
172    adapters()
173        .get(&target.adapter)
174        .ok_or_else(|| SkillfileError::Manifest(format!("unknown adapter '{}'", target.adapter)))
175}
176
177struct SingleInstalledVariant {
178    label: String,
179    content: String,
180}
181
182fn installed_single_file_variants(
183    entry: &Entry,
184    manifest: &Manifest,
185    repo_root: &Path,
186) -> Result<Vec<SingleInstalledVariant>, SkillfileError> {
187    let mut variants = Vec::new();
188    for target in &manifest.install_targets {
189        let adapter = target_adapter(target)?;
190        if !adapter.supports(entry.entity_type) {
191            continue;
192        }
193        let scope = AdapterScope {
194            scope: target.scope,
195            repo_root,
196        };
197        let path = adapter.installed_path(entry, &scope);
198        if !path.exists() {
199            continue;
200        }
201        variants.push(SingleInstalledVariant {
202            label: target.to_string(),
203            content: std::fs::read_to_string(path)?,
204        });
205    }
206    Ok(variants)
207}
208
209fn representative_single_file_content(
210    entry_name: &str,
211    cache_text: &str,
212    variants: &[SingleInstalledVariant],
213) -> Result<Option<String>, SkillfileError> {
214    let modified: Vec<&SingleInstalledVariant> = variants
215        .iter()
216        .filter(|variant| variant.content != cache_text)
217        .collect();
218    if modified.is_empty() {
219        return Ok(None);
220    }
221    let representative = &modified[0].content;
222    if modified
223        .iter()
224        .any(|variant| variant.content != *representative)
225    {
226        let labels: Vec<String> = modified
227            .iter()
228            .map(|variant| variant.label.clone())
229            .collect();
230        return Err(divergent_auto_pin_error(entry_name, &labels));
231    }
232    Ok(Some(representative.clone()))
233}
234
235struct AutoPinSingleCtx<'a> {
236    entry: &'a Entry,
237    manifest: &'a Manifest,
238    repo_root: &'a Path,
239    cache_file: &'a Path,
240}
241
242fn auto_pin_single_file_entry(ctx: &AutoPinSingleCtx<'_>) -> Result<(), SkillfileError> {
243    let cache_text = std::fs::read_to_string(ctx.cache_file)?;
244    let variants = installed_single_file_variants(ctx.entry, ctx.manifest, ctx.repo_root)?;
245    let Some(installed_text) =
246        representative_single_file_content(&ctx.entry.name, &cache_text, &variants)?
247    else {
248        return Ok(());
249    };
250
251    let patch_ctx = PatchCtx {
252        entry: ctx.entry,
253        repo_root: ctx.repo_root,
254    };
255    if should_skip_pin(&patch_ctx, &cache_text, &installed_text) {
256        return Ok(());
257    }
258
259    let patch_text = generate_patch(
260        &cache_text,
261        &installed_text,
262        &format!("{}.md", ctx.entry.name),
263    );
264    if !patch_text.is_empty() && write_patch(ctx.entry, &patch_text, ctx.repo_root).is_ok() {
265        progress!(
266            "  {}: local changes auto-saved to .skillfile/patches/",
267            ctx.entry.name
268        );
269    }
270    Ok(())
271}
272
273fn auto_pin_entry(
274    entry: &Entry,
275    manifest: &Manifest,
276    repo_root: &Path,
277) -> Result<(), SkillfileError> {
278    if entry.source_type() == "local" {
279        return Ok(());
280    }
281
282    let Ok(locked) = read_lock(repo_root) else {
283        return Ok(());
284    };
285    let key = lock_key(entry);
286    if !locked.contains_key(&key) {
287        return Ok(());
288    }
289
290    let vdir = vendor_dir_for(entry, repo_root);
291
292    if is_dir_entry(entry) {
293        return auto_pin_dir_entry(entry, manifest, repo_root);
294    }
295
296    let cf = content_file(entry);
297    if cf.is_empty() {
298        return Ok(());
299    }
300    let cache_file = vdir.join(&cf);
301    if !cache_file.exists() {
302        return Ok(());
303    }
304    auto_pin_single_file_entry(&AutoPinSingleCtx {
305        entry,
306        manifest,
307        repo_root,
308        cache_file: &cache_file,
309    })
310}
311
312/// Return `true` if the dir-entry patch file at `patch_path` already describes
313/// the transition from `cache_text` to `installed_text`.
314fn dir_patch_already_matches(patch_path: &Path, cache_text: &str, installed_text: &str) -> bool {
315    if !patch_path.exists() {
316        return false;
317    }
318    let Ok(pt) = std::fs::read_to_string(patch_path) else {
319        return false;
320    };
321    patch_already_covers(&pt, cache_text, installed_text)
322}
323
324fn load_cache_files(vdir: &Path) -> BTreeMap<String, PathBuf> {
325    walkdir(vdir)
326        .into_iter()
327        .filter(|cache_file| cache_file.file_name().is_none_or(|name| name != ".meta"))
328        .filter_map(|cache_file| {
329            let filename = cache_file
330                .strip_prefix(vdir)
331                .ok()
332                .and_then(|path| path.to_str())
333                .map(str::to_string)?;
334            Some((filename, cache_file))
335        })
336        .collect()
337}
338
339struct DirInstalledVariant {
340    label: String,
341    files: HashMap<String, PathBuf>,
342}
343
344type DirModifiedMap = BTreeMap<String, String>;
345
346fn installed_dir_variants(
347    entry: &Entry,
348    manifest: &Manifest,
349    repo_root: &Path,
350) -> Result<Vec<DirInstalledVariant>, SkillfileError> {
351    let mut variants = Vec::new();
352    for target in &manifest.install_targets {
353        let adapter = target_adapter(target)?;
354        if !adapter.supports(entry.entity_type) {
355            continue;
356        }
357        let scope = AdapterScope {
358            scope: target.scope,
359            repo_root,
360        };
361        let files = adapter.installed_dir_files(entry, &scope);
362        if files.is_empty() {
363            continue;
364        }
365        variants.push(DirInstalledVariant {
366            label: target.to_string(),
367            files,
368        });
369    }
370    Ok(variants)
371}
372
373fn modified_dir_content(
374    cache_files: &BTreeMap<String, PathBuf>,
375    variant: &DirInstalledVariant,
376) -> Result<DirModifiedMap, SkillfileError> {
377    let mut modified = BTreeMap::new();
378    for (filename, cache_file) in cache_files {
379        let Some(installed_path) = variant.files.get(filename).filter(|path| path.exists()) else {
380            continue;
381        };
382        let cache_text = std::fs::read_to_string(cache_file)?;
383        let installed_text = std::fs::read_to_string(installed_path)?;
384        if installed_text != cache_text {
385            modified.insert(filename.clone(), installed_text);
386        }
387    }
388    Ok(modified)
389}
390
391fn representative_dir_changes(
392    entry_name: &str,
393    cache_files: &BTreeMap<String, PathBuf>,
394    variants: &[DirInstalledVariant],
395) -> Result<Option<DirModifiedMap>, SkillfileError> {
396    let mut modified = Vec::new();
397    for variant in variants {
398        let changed = modified_dir_content(cache_files, variant)?;
399        if !changed.is_empty() {
400            modified.push((variant.label.clone(), changed));
401        }
402    }
403    if modified.is_empty() {
404        return Ok(None);
405    }
406    let representative = &modified[0].1;
407    if modified
408        .iter()
409        .any(|(_, changed)| changed != representative)
410    {
411        let labels: Vec<String> = modified.iter().map(|(label, _)| label.clone()).collect();
412        return Err(divergent_auto_pin_error(entry_name, &labels));
413    }
414    Ok(Some(representative.clone()))
415}
416
417struct WriteDirPatchesCtx<'a> {
418    entry: &'a Entry,
419    repo_root: &'a Path,
420    cache_files: &'a BTreeMap<String, PathBuf>,
421    representative: &'a DirModifiedMap,
422}
423
424fn write_auto_pin_dir_patches(ctx: &WriteDirPatchesCtx<'_>) -> Result<Vec<String>, SkillfileError> {
425    let mut pinned = Vec::new();
426    for (filename, cache_file) in ctx.cache_files {
427        let Some(installed_text) = ctx.representative.get(filename) else {
428            remove_dir_patch(ctx.entry, filename, ctx.repo_root)?;
429            continue;
430        };
431
432        let cache_text = std::fs::read_to_string(cache_file)?;
433        let patch_path = dir_patch_path(ctx.entry, filename, ctx.repo_root);
434        if dir_patch_already_matches(&patch_path, &cache_text, installed_text) {
435            continue;
436        }
437
438        let patch_text = generate_patch(&cache_text, installed_text, filename);
439        if patch_text.is_empty() {
440            continue;
441        }
442
443        write_dir_patch(&patch_path, &patch_text)?;
444        pinned.push(filename.clone());
445    }
446    Ok(pinned)
447}
448
449fn auto_pin_dir_entry(
450    entry: &Entry,
451    manifest: &Manifest,
452    repo_root: &Path,
453) -> Result<(), SkillfileError> {
454    let vdir = &vendor_dir_for(entry, repo_root);
455    if !vdir.is_dir() {
456        return Ok(());
457    }
458    let installed = installed_dir_variants(entry, manifest, repo_root)?;
459    if installed.is_empty() {
460        return Ok(());
461    }
462    let cache_files = load_cache_files(vdir);
463    let Some(representative) = representative_dir_changes(&entry.name, &cache_files, &installed)?
464    else {
465        return Ok(());
466    };
467    let pinned = write_auto_pin_dir_patches(&WriteDirPatchesCtx {
468        entry,
469        repo_root,
470        cache_files: &cache_files,
471        representative: &representative,
472    })?;
473
474    if !pinned.is_empty() {
475        progress!(
476            "  {}: local changes auto-saved to .skillfile/patches/ ({})",
477            entry.name,
478            pinned.join(", ")
479        );
480    }
481    Ok(())
482}
483
484// ---------------------------------------------------------------------------
485// Core install entry point
486// ---------------------------------------------------------------------------
487
488pub struct InstallCtx<'a> {
489    pub repo_root: &'a Path,
490    pub opts: Option<&'a InstallOptions>,
491}
492
493/// Why an install target was skipped instead of being updated.
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum InstallSkipReason {
496    UnknownAdapter,
497    UnsupportedEntity,
498    MissingSource,
499    NothingDeployed,
500    DryRun,
501}
502
503/// Outcome of attempting to deploy one entry to one install target.
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum InstallOutcome {
506    Installed,
507    Skipped(InstallSkipReason),
508}
509
510pub fn install_entry(
511    entry: &Entry,
512    target: &InstallTarget,
513    ctx: &InstallCtx<'_>,
514) -> Result<(), SkillfileError> {
515    let _ = install_entry_with_outcome(entry, target, ctx)?;
516    Ok(())
517}
518
519fn install_failure(entry: &Entry, target: &InstallTarget, detail: &str) -> SkillfileError {
520    SkillfileError::Install(format!(
521        "failed to install '{}' to {target}: {detail}",
522        entry.name
523    ))
524}
525
526struct PathSnapshot {
527    live_path: PathBuf,
528    snapshot_path: Option<PathBuf>,
529}
530
531#[derive(Default)]
532pub struct InstallSnapshot {
533    scratch_dir: Option<PathBuf>,
534    paths: Vec<PathSnapshot>,
535    preserve_scratch: Cell<bool>,
536}
537
538impl Drop for InstallSnapshot {
539    fn drop(&mut self) {
540        if self.preserve_scratch.get() {
541            return;
542        }
543        if let Some(path) = &self.scratch_dir {
544            let _ = std::fs::remove_dir_all(path);
545            remove_empty_dir(path.parent());
546        }
547    }
548}
549
550fn remove_empty_dir(path: Option<&Path>) {
551    let Some(path) = path else {
552        return;
553    };
554    if path
555        .read_dir()
556        .is_ok_and(|mut entries| entries.next().is_none())
557    {
558        let _ = std::fs::remove_dir(path);
559    }
560}
561
562fn remove_path(path: &Path) -> std::io::Result<()> {
563    let Ok(metadata) = std::fs::symlink_metadata(path) else {
564        return Ok(());
565    };
566    let file_type = metadata.file_type();
567    #[cfg(windows)]
568    {
569        use std::os::windows::fs::FileTypeExt as _;
570
571        if file_type.is_symlink_dir() {
572            return std::fs::remove_dir(path);
573        }
574    }
575    if file_type.is_dir() {
576        std::fs::remove_dir_all(path)
577    } else {
578        std::fs::remove_file(path)
579    }
580}
581
582#[cfg(unix)]
583fn create_symlink(target: &Path, link: &Path, _is_dir: bool) -> std::io::Result<()> {
584    std::os::unix::fs::symlink(target, link)
585}
586
587#[cfg(windows)]
588fn create_symlink(target: &Path, link: &Path, is_dir: bool) -> std::io::Result<()> {
589    if is_dir {
590        std::os::windows::fs::symlink_dir(target, link)
591    } else {
592        std::os::windows::fs::symlink_file(target, link)
593    }
594}
595
596#[cfg(windows)]
597fn symlink_is_dir(path: &Path) -> std::io::Result<bool> {
598    use std::os::windows::fs::FileTypeExt as _;
599
600    Ok(std::fs::symlink_metadata(path)?
601        .file_type()
602        .is_symlink_dir())
603}
604
605#[cfg(windows)]
606fn copy_symlink(source: &Path, dest: &Path) -> std::io::Result<()> {
607    let target = std::fs::read_link(source)?;
608    create_symlink(&target, dest, symlink_is_dir(source)?)
609}
610
611#[cfg(not(windows))]
612fn copy_symlink(source: &Path, dest: &Path) -> std::io::Result<()> {
613    let target = std::fs::read_link(source)?;
614    create_symlink(&target, dest, false)
615}
616
617fn copy_path(source: &Path, dest: &Path) -> std::io::Result<()> {
618    if let Some(parent) = dest.parent() {
619        std::fs::create_dir_all(parent)?;
620    }
621    let metadata = std::fs::symlink_metadata(source)?;
622    if metadata.file_type().is_symlink() {
623        copy_symlink(source, dest)
624    } else if metadata.is_dir() {
625        copy_snapshot_dir(source, dest)
626    } else {
627        std::fs::copy(source, dest).map(|_| ())
628    }
629}
630
631fn copy_snapshot_dir(source: &Path, dest: &Path) -> std::io::Result<()> {
632    std::fs::create_dir_all(dest)?;
633    for entry in std::fs::read_dir(source)? {
634        let entry = entry?;
635        let source_path = entry.path();
636        let dest_path = dest.join(entry.file_name());
637        copy_path(&source_path, &dest_path)?;
638    }
639    Ok(())
640}
641
642fn snapshot_scratch_dir(repo_root: &Path) -> std::io::Result<PathBuf> {
643    let stamp = SystemTime::now()
644        .duration_since(UNIX_EPOCH)
645        .map_or(0, |duration| duration.as_nanos());
646    let seq = SNAPSHOT_COUNTER.fetch_add(1, Ordering::Relaxed);
647    let dir = repo_root.join(".skillfile").join("tmp").join(format!(
648        "install-snapshot-{}-{stamp}-{seq}",
649        std::process::id()
650    ));
651    std::fs::create_dir_all(&dir)?;
652    Ok(dir)
653}
654
655fn capture_path_snapshot(
656    live_path: PathBuf,
657    scratch_dir: &Path,
658    index: usize,
659) -> std::io::Result<PathSnapshot> {
660    let snapshot_path = if live_path.exists() || live_path.is_symlink() {
661        let snapshot_path = scratch_dir.join(index.to_string());
662        copy_path(&live_path, &snapshot_path)?;
663        Some(snapshot_path)
664    } else {
665        None
666    };
667    Ok(PathSnapshot {
668        live_path,
669        snapshot_path,
670    })
671}
672
673impl InstallSnapshot {
674    fn capture(repo_root: &Path, paths: Vec<PathBuf>) -> Result<Self, SkillfileError> {
675        let mut seen = HashSet::new();
676        let paths: Vec<PathBuf> = paths
677            .into_iter()
678            .filter(|path| seen.insert(path.clone()))
679            .collect();
680        if paths.is_empty() {
681            return Ok(Self::default());
682        }
683
684        let scratch_dir = snapshot_scratch_dir(repo_root)?;
685        let mut snapshot = Self {
686            scratch_dir: Some(scratch_dir.clone()),
687            paths: Vec::new(),
688            preserve_scratch: Cell::new(false),
689        };
690        for (index, path) in paths.into_iter().enumerate() {
691            snapshot
692                .paths
693                .push(capture_path_snapshot(path, &scratch_dir, index)?);
694        }
695        Ok(snapshot)
696    }
697
698    pub fn restore(&self) -> Result<(), SkillfileError> {
699        for snapshot in self.paths.iter().rev() {
700            self.restore_path(snapshot)?;
701        }
702        Ok(())
703    }
704
705    fn scratch_dir(&self) -> Option<&Path> {
706        self.scratch_dir.as_deref()
707    }
708
709    fn restore_path(&self, snapshot: &PathSnapshot) -> Result<(), SkillfileError> {
710        let result = restore_path_snapshot(snapshot);
711        if result.is_err() {
712            self.preserve_scratch.set(true);
713        }
714        result.map_err(SkillfileError::from)
715    }
716}
717
718fn restore_path_snapshot(snapshot: &PathSnapshot) -> std::io::Result<()> {
719    remove_path(&snapshot.live_path)?;
720    let Some(snapshot_path) = &snapshot.snapshot_path else {
721        return Ok(());
722    };
723    copy_path(snapshot_path, &snapshot.live_path)
724}
725
726fn forward_slash(path: &Path) -> String {
727    path.to_string_lossy().replace('\\', "/")
728}
729
730struct InstallValidationCtx<'a> {
731    entry: &'a Entry,
732    target: &'a InstallTarget,
733    repo_root: &'a Path,
734    source: &'a Path,
735    adapter: &'a dyn PlatformAdapter,
736    is_dir: bool,
737    opts: &'a InstallOptions,
738}
739
740struct InstallPlan {
741    expected: HashMap<String, PathBuf>,
742    existing_before: HashSet<String>,
743}
744
745struct ValidatedInstall {
746    installed: HashMap<String, PathBuf>,
747    outcome: InstallOutcome,
748}
749
750fn flat_expected_paths(source: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
751    walkdir(source)
752        .into_iter()
753        .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
754        .filter_map(|path| {
755            let rel = path.strip_prefix(source).ok()?;
756            let name = path.file_name()?;
757            Some((forward_slash(rel), target_dir.join(name)))
758        })
759        .collect()
760}
761
762fn nested_expected_paths(source: &Path, dest_root: &Path) -> HashMap<String, PathBuf> {
763    walkdir(source)
764        .into_iter()
765        .filter(|path| path.file_name().is_none_or(|name| name != ".meta"))
766        .filter_map(|path| {
767            let rel = path.strip_prefix(source).ok()?;
768            Some((forward_slash(rel), dest_root.join(rel)))
769        })
770        .collect()
771}
772
773fn planned_install_paths(ctx: &InstallValidationCtx<'_>) -> HashMap<String, PathBuf> {
774    let scope = AdapterScope {
775        scope: ctx.target.scope,
776        repo_root: ctx.repo_root,
777    };
778    let target_dir = ctx.adapter.target_dir(ctx.entry.entity_type, &scope);
779    if !ctx.is_dir {
780        let key = format!("{}.md", ctx.entry.name);
781        return HashMap::from([(key, ctx.adapter.installed_path(ctx.entry, &scope))]);
782    }
783
784    match ctx.adapter.dir_mode(ctx.entry.entity_type) {
785        Some(DirInstallMode::Flat) => flat_expected_paths(ctx.source, &target_dir),
786        _ => nested_expected_paths(ctx.source, &target_dir.join(&ctx.entry.name)),
787    }
788}
789
790fn patch_effect_path(ctx: &InstallValidationCtx<'_>) -> PathBuf {
791    if ctx.is_dir {
792        patches_root(ctx.repo_root)
793            .join(ctx.entry.entity_type.dir_name())
794            .join(&ctx.entry.name)
795    } else {
796        patch_path(ctx.entry, ctx.repo_root)
797    }
798}
799
800fn install_effect_paths(ctx: &InstallValidationCtx<'_>) -> Vec<PathBuf> {
801    let scope = AdapterScope {
802        scope: ctx.target.scope,
803        repo_root: ctx.repo_root,
804    };
805    let target_dir = ctx.adapter.target_dir(ctx.entry.entity_type, &scope);
806    let mut paths = if !ctx.is_dir {
807        vec![ctx.adapter.installed_path(ctx.entry, &scope)]
808    } else if ctx.adapter.dir_mode(ctx.entry.entity_type) == Some(DirInstallMode::Flat) {
809        flat_expected_paths(ctx.source, &target_dir)
810            .into_values()
811            .collect()
812    } else {
813        vec![target_dir.join(&ctx.entry.name)]
814    };
815
816    if ctx.adapter.dir_mode(ctx.entry.entity_type) != Some(DirInstallMode::Flat) {
817        paths.push(target_dir.join(format!("{}.md", ctx.entry.name)));
818    }
819    paths.push(patch_effect_path(ctx));
820    paths
821}
822
823pub fn capture_install_snapshot(
824    entry: &Entry,
825    targets: &[InstallTarget],
826    repo_root: &Path,
827) -> Result<InstallSnapshot, SkillfileError> {
828    let mut paths = Vec::new();
829    for target in targets {
830        paths.extend(install_effect_paths_for_target(entry, target, repo_root));
831    }
832    InstallSnapshot::capture(repo_root, paths)
833}
834
835fn install_effect_paths_for_target(
836    entry: &Entry,
837    target: &InstallTarget,
838    repo_root: &Path,
839) -> Vec<PathBuf> {
840    let all_adapters = adapters();
841    let Some(adapter) = all_adapters.get(&target.adapter) else {
842        return Vec::new();
843    };
844    if !adapter.supports(entry.entity_type) {
845        return Vec::new();
846    }
847    let Some(source) = source_path(entry, repo_root).filter(|path| path.exists()) else {
848        return Vec::new();
849    };
850    let default_opts = InstallOptions::default();
851    let validation_ctx = InstallValidationCtx {
852        entry,
853        target,
854        repo_root,
855        source: &source,
856        adapter,
857        is_dir: is_dir_entry(entry) || source.is_dir(),
858        opts: &default_opts,
859    };
860    install_effect_paths(&validation_ctx)
861}
862
863fn build_install_plan(ctx: &InstallValidationCtx<'_>) -> InstallPlan {
864    let expected = planned_install_paths(ctx);
865    let existing_before = expected
866        .iter()
867        .filter_map(|(key, path)| path.is_file().then_some(key.clone()))
868        .collect();
869    InstallPlan {
870        expected,
871        existing_before,
872    }
873}
874
875fn cleanup_created_files(plan: &InstallPlan, installed: &HashMap<String, PathBuf>) {
876    for (key, path) in installed {
877        if plan.existing_before.contains(key) {
878            continue;
879        }
880        let _ = std::fs::remove_file(path);
881    }
882}
883
884fn validate_installed_files(
885    ctx: &InstallValidationCtx<'_>,
886    plan: &InstallPlan,
887    installed: HashMap<String, PathBuf>,
888) -> Result<ValidatedInstall, SkillfileError> {
889    let reported_count = installed.len();
890    let existing = installed
891        .into_iter()
892        .filter(|(_, path)| path.is_file())
893        .collect::<HashMap<_, _>>();
894
895    if existing.len() != reported_count {
896        return Err(install_failure(
897            ctx.entry,
898            ctx.target,
899            "adapter reported installed files that do not exist on disk",
900        ));
901    }
902
903    let expected = plan.expected.len();
904    if expected == 0 {
905        return Ok(ValidatedInstall {
906            installed: existing,
907            outcome: InstallOutcome::Skipped(InstallSkipReason::NothingDeployed),
908        });
909    }
910
911    let present = plan.expected.values().filter(|path| path.is_file()).count();
912    if present == 0 {
913        cleanup_created_files(plan, &existing);
914        return Err(install_failure(
915            ctx.entry,
916            ctx.target,
917            "no files were written to the target platform directory",
918        ));
919    }
920
921    if present < expected {
922        cleanup_created_files(plan, &existing);
923        return Err(install_failure(
924            ctx.entry,
925            ctx.target,
926            &format!("only {present} of {expected} expected file(s) were written"),
927        ));
928    }
929
930    let outcome = if ctx.opts.overwrite || plan.existing_before.len() != expected {
931        InstallOutcome::Installed
932    } else {
933        InstallOutcome::Skipped(InstallSkipReason::NothingDeployed)
934    };
935    Ok(ValidatedInstall {
936        installed: existing,
937        outcome,
938    })
939}
940
941fn restore_on_install_error(snapshot: &InstallSnapshot, error: SkillfileError) -> SkillfileError {
942    match snapshot.restore() {
943        Ok(()) => error,
944        Err(rollback_error) => {
945            let snapshot_hint = snapshot.scratch_dir().map_or_else(String::new, |path| {
946                format!("; rollback snapshot kept at {}", path.display())
947            });
948            let rollback_detail = format!(
949                "rollback failed: {rollback_error}{snapshot_hint}; target may need manual cleanup"
950            );
951            match error {
952                SkillfileError::PatchConflict {
953                    message,
954                    entry_name,
955                } => SkillfileError::PatchConflict {
956                    message: format!("{message}; {rollback_detail}"),
957                    entry_name,
958                },
959                other => SkillfileError::Install(format!("{other}; {rollback_detail}")),
960            }
961        }
962    }
963}
964
965fn deploy_and_patch_entry(
966    validation_ctx: &InstallValidationCtx<'_>,
967    plan: &InstallPlan,
968) -> Result<InstallOutcome, SkillfileError> {
969    let installed = validation_ctx.adapter.deploy_entry(&DeployRequest {
970        entry: validation_ctx.entry,
971        source: validation_ctx.source,
972        scope: validation_ctx.target.scope,
973        repo_root: validation_ctx.repo_root,
974        opts: validation_ctx.opts,
975    });
976
977    if validation_ctx.opts.dry_run {
978        return Ok(InstallOutcome::Skipped(InstallSkipReason::DryRun));
979    }
980    let validated = validate_installed_files(validation_ctx, plan, installed)?;
981    if let InstallOutcome::Skipped(reason) = validated.outcome {
982        return Ok(InstallOutcome::Skipped(reason));
983    }
984
985    let patch_ctx = PatchCtx {
986        entry: validation_ctx.entry,
987        repo_root: validation_ctx.repo_root,
988    };
989    if validation_ctx.is_dir {
990        apply_dir_patches(&patch_ctx, &validated.installed, validation_ctx.source)?;
991    } else {
992        let key = format!("{}.md", validation_ctx.entry.name);
993        if let Some(dest) = validated.installed.get(&key) {
994            apply_single_file_patch(&patch_ctx, dest, validation_ctx.source)?;
995        }
996    }
997
998    Ok(InstallOutcome::Installed)
999}
1000
1001/// Returns `Err(PatchConflict)` if a stored patch fails to apply cleanly.
1002pub fn install_entry_with_outcome(
1003    entry: &Entry,
1004    target: &InstallTarget,
1005    ctx: &InstallCtx<'_>,
1006) -> Result<InstallOutcome, SkillfileError> {
1007    let default_opts = InstallOptions::default();
1008    let opts = ctx.opts.unwrap_or(&default_opts);
1009
1010    let all_adapters = adapters();
1011    let Some(adapter) = all_adapters.get(&target.adapter) else {
1012        return Ok(InstallOutcome::Skipped(InstallSkipReason::UnknownAdapter));
1013    };
1014
1015    if !adapter.supports(entry.entity_type) {
1016        return Ok(InstallOutcome::Skipped(
1017            InstallSkipReason::UnsupportedEntity,
1018        ));
1019    }
1020
1021    let source = match source_path(entry, ctx.repo_root) {
1022        Some(p) if p.exists() => p,
1023        _ => {
1024            eprintln!("  warning: source missing for {}, skipping", entry.name);
1025            return Ok(InstallOutcome::Skipped(InstallSkipReason::MissingSource));
1026        }
1027    };
1028
1029    let is_dir = is_dir_entry(entry) || source.is_dir();
1030    let validation_ctx = InstallValidationCtx {
1031        entry,
1032        target,
1033        repo_root: ctx.repo_root,
1034        source: &source,
1035        adapter,
1036        is_dir,
1037        opts,
1038    };
1039    let plan = build_install_plan(&validation_ctx);
1040    let snapshot = if opts.dry_run {
1041        InstallSnapshot::default()
1042    } else {
1043        InstallSnapshot::capture(ctx.repo_root, install_effect_paths(&validation_ctx))?
1044    };
1045    deploy_and_patch_entry(&validation_ctx, &plan)
1046        .map_err(|error| restore_on_install_error(&snapshot, error))
1047}
1048
1049// ---------------------------------------------------------------------------
1050// Precondition check
1051// ---------------------------------------------------------------------------
1052
1053fn check_preconditions(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
1054    if manifest.install_targets.is_empty() {
1055        return Err(SkillfileError::Manifest(
1056            "No install targets configured. Run `skillfile init` first.".into(),
1057        ));
1058    }
1059
1060    if let Some(conflict) = read_conflict(repo_root)? {
1061        return Err(SkillfileError::Install(format!(
1062            "pending conflict for '{}' — \
1063             run `skillfile diff {}` to review, \
1064             or `skillfile resolve {}` to merge",
1065            conflict.entry, conflict.entry, conflict.entry
1066        )));
1067    }
1068
1069    Ok(())
1070}
1071
1072// ---------------------------------------------------------------------------
1073// Deploy all entries, handling patch conflicts
1074// ---------------------------------------------------------------------------
1075
1076fn sha_transition_hint(old_sha: &str, new_sha: &str) -> String {
1077    if !old_sha.is_empty() && !new_sha.is_empty() && old_sha != new_sha {
1078        format!(
1079            "\n  upstream: {} \u{2192} {}",
1080            short_sha(old_sha),
1081            short_sha(new_sha)
1082        )
1083    } else {
1084        String::new()
1085    }
1086}
1087
1088struct LockMaps<'a> {
1089    locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
1090    old_locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
1091}
1092
1093struct DeployCtx<'a> {
1094    repo_root: &'a Path,
1095    opts: &'a InstallOptions,
1096    maps: LockMaps<'a>,
1097}
1098
1099fn handle_patch_conflict(
1100    entry: &Entry,
1101    entry_name: &str,
1102    ctx: &DeployCtx<'_>,
1103) -> Result<(), SkillfileError> {
1104    let key = lock_key(entry);
1105    let old_sha = ctx
1106        .maps
1107        .old_locked
1108        .get(&key)
1109        .map(|l| l.sha.clone())
1110        .unwrap_or_default();
1111    let new_sha = ctx
1112        .maps
1113        .locked
1114        .get(&key)
1115        .map_or_else(|| old_sha.clone(), |l| l.sha.clone());
1116
1117    write_conflict(
1118        ctx.repo_root,
1119        &ConflictState {
1120            entry: entry_name.to_string(),
1121            entity_type: entry.entity_type,
1122            old_sha: old_sha.clone(),
1123            new_sha: new_sha.clone(),
1124        },
1125    )?;
1126
1127    let sha_info = sha_transition_hint(&old_sha, &new_sha);
1128    Err(SkillfileError::Install(format!(
1129        "upstream changes to '{entry_name}' conflict with your customisations.{sha_info}\n\
1130         Your pinned edits could not be applied to the new upstream version.\n\
1131         Run `skillfile diff {entry_name}` to review what changed upstream.\n\
1132         Run `skillfile resolve {entry_name}` when ready to merge.\n\
1133         Run `skillfile resolve --abort` to discard the conflict and keep the old version."
1134    )))
1135}
1136
1137fn append_rollback_detail(error: SkillfileError, patch_message: &str) -> SkillfileError {
1138    if !patch_message.contains("rollback failed") {
1139        return error;
1140    }
1141    match error {
1142        SkillfileError::Install(message) => {
1143            SkillfileError::Install(format!("{message}\nRollback warning: {patch_message}"))
1144        }
1145        other => other,
1146    }
1147}
1148
1149fn install_entry_or_conflict(
1150    entry: &Entry,
1151    target: &InstallTarget,
1152    ctx: &DeployCtx<'_>,
1153) -> Result<(), SkillfileError> {
1154    let install_ctx = InstallCtx {
1155        repo_root: ctx.repo_root,
1156        opts: Some(ctx.opts),
1157    };
1158    match install_entry(entry, target, &install_ctx) {
1159        Ok(()) => Ok(()),
1160        Err(SkillfileError::PatchConflict {
1161            entry_name,
1162            message,
1163        }) => handle_patch_conflict(entry, &entry_name, ctx)
1164            .map_err(|error| append_rollback_detail(error, &message)),
1165        Err(e) => Err(e),
1166    }
1167}
1168
1169fn deploy_all(manifest: &Manifest, ctx: &DeployCtx<'_>) -> Result<(), SkillfileError> {
1170    let mode = if ctx.opts.dry_run { " [dry-run]" } else { "" };
1171    let all_adapters = adapters();
1172
1173    for target in &manifest.install_targets {
1174        if !all_adapters.contains(&target.adapter) {
1175            eprintln!("warning: unknown platform '{}', skipping", target.adapter);
1176            continue;
1177        }
1178        progress!(
1179            "Installing for {} ({}){mode}...",
1180            target.adapter,
1181            target.scope
1182        );
1183        for entry in &manifest.entries {
1184            install_entry_or_conflict(entry, target, ctx)?;
1185        }
1186    }
1187
1188    Ok(())
1189}
1190
1191// ---------------------------------------------------------------------------
1192// cmd_install
1193// ---------------------------------------------------------------------------
1194
1195fn apply_extra_targets(manifest: &mut Manifest, extra_targets: Option<&[InstallTarget]>) {
1196    let Some(targets) = extra_targets else {
1197        return;
1198    };
1199    if !targets.is_empty() {
1200        progress!("Using platform targets from personal config (Skillfile has no install lines).");
1201    }
1202    manifest.install_targets = targets.to_vec();
1203}
1204
1205fn load_manifest(
1206    repo_root: &Path,
1207    extra_targets: Option<&[InstallTarget]>,
1208) -> Result<Manifest, SkillfileError> {
1209    let manifest_path = repo_root.join(MANIFEST_NAME);
1210    if !manifest_path.exists() {
1211        return Err(SkillfileError::Manifest(format!(
1212            "{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
1213            repo_root.display()
1214        )));
1215    }
1216
1217    let result = parse_manifest(&manifest_path)?;
1218    for w in &result.warnings {
1219        eprintln!("{w}");
1220    }
1221    let mut manifest = result.manifest;
1222
1223    // If the Skillfile has no install targets, fall back to caller-provided targets
1224    // (e.g. from user-global config).
1225    if manifest.install_targets.is_empty() {
1226        apply_extra_targets(&mut manifest, extra_targets);
1227    }
1228
1229    Ok(manifest)
1230}
1231
1232fn auto_pin_all(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
1233    for entry in &manifest.entries {
1234        auto_pin_entry(entry, manifest, repo_root)?;
1235    }
1236    Ok(())
1237}
1238
1239fn print_first_install_hint(manifest: &Manifest) {
1240    let platforms: Vec<String> = manifest
1241        .install_targets
1242        .iter()
1243        .map(|t| format!("{} ({})", t.adapter, t.scope))
1244        .collect();
1245    progress!("  Configured platforms: {}", platforms.join(", "));
1246    progress!("  Run `skillfile init` to add or change platforms.");
1247}
1248
1249pub struct CmdInstallOpts<'a> {
1250    pub dry_run: bool,
1251    pub update: bool,
1252    pub extra_targets: Option<&'a [InstallTarget]>,
1253}
1254
1255pub fn cmd_install(repo_root: &Path, opts: &CmdInstallOpts<'_>) -> Result<(), SkillfileError> {
1256    let manifest = load_manifest(repo_root, opts.extra_targets)?;
1257
1258    check_preconditions(&manifest, repo_root)?;
1259
1260    // Detect first install (cache dir absent → fresh clone or first run).
1261    let cache_dir = repo_root.join(".skillfile").join("cache");
1262    let first_install = !cache_dir.exists();
1263
1264    // Read old locked state before sync (used for SHA context in conflict messages).
1265    let old_locked = read_lock(repo_root).unwrap_or_default();
1266
1267    // Auto-pin local edits before re-fetching upstream (--update only).
1268    if opts.update && !opts.dry_run {
1269        auto_pin_all(&manifest, repo_root)?;
1270    }
1271
1272    // Ensure cache dir exists (used as first-install marker and by sync).
1273    if !opts.dry_run {
1274        std::fs::create_dir_all(&cache_dir)?;
1275    }
1276
1277    // Fetch any missing or stale entries.
1278    cmd_sync(&skillfile_sources::sync::SyncCmdOpts {
1279        repo_root,
1280        dry_run: opts.dry_run,
1281        entry_filter: None,
1282        update: opts.update,
1283    })?;
1284
1285    // Read new locked state (written by sync).
1286    let locked = read_lock(repo_root).unwrap_or_default();
1287
1288    // Deploy to all configured platform targets.
1289    let install_opts = InstallOptions {
1290        dry_run: opts.dry_run,
1291        overwrite: opts.update,
1292    };
1293    let deploy_ctx = DeployCtx {
1294        repo_root,
1295        opts: &install_opts,
1296        maps: LockMaps {
1297            locked: &locked,
1298            old_locked: &old_locked,
1299        },
1300    };
1301    deploy_all(&manifest, &deploy_ctx)?;
1302
1303    if !opts.dry_run {
1304        progress!("Done.");
1305
1306        // On first install, show configured platforms and hint about `init`.
1307        // Helps the clone scenario: user clones a repo with a Skillfile targeting
1308        // platforms they may not use, and needs to know how to add theirs.
1309        if first_install {
1310            print_first_install_hint(&manifest);
1311        }
1312    }
1313
1314    Ok(())
1315}
1316
1317// ---------------------------------------------------------------------------
1318// Tests
1319// ---------------------------------------------------------------------------
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324    use skillfile_core::models::{
1325        EntityType, Entry, InstallTarget, LockEntry, Scope, SourceFields,
1326    };
1327    use std::collections::BTreeMap;
1328    use std::path::{Path, PathBuf};
1329
1330    // -----------------------------------------------------------------------
1331    // Fixture helpers — filesystem-only, no cross-crate function calls
1332    // -----------------------------------------------------------------------
1333
1334    /// Return the path for a single-file entry patch.
1335    /// `.skillfile/patches/<type>s/<name>.patch`
1336    fn patch_fixture_path(dir: &Path, entry: &Entry) -> PathBuf {
1337        dir.join(".skillfile/patches")
1338            .join(entry.entity_type.dir_name())
1339            .join(format!("{}.patch", entry.name))
1340    }
1341
1342    /// Return the path for a per-file patch within a directory entry.
1343    /// `.skillfile/patches/<type>s/<name>/<rel>.patch`
1344    fn dir_patch_fixture_path(dir: &Path, entry: &Entry, rel: &str) -> PathBuf {
1345        dir.join(".skillfile/patches")
1346            .join(entry.entity_type.dir_name())
1347            .join(&entry.name)
1348            .join(format!("{rel}.patch"))
1349    }
1350
1351    /// Write a single-file patch fixture to the correct path.
1352    fn write_patch_fixture(dir: &Path, entry: &Entry, text: &str) {
1353        let p = patch_fixture_path(dir, entry);
1354        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1355        std::fs::write(p, text).unwrap();
1356    }
1357
1358    /// Write `Skillfile.lock` as JSON. Uses `serde_json` — no cross-crate call.
1359    fn write_lock_fixture(dir: &Path, locked: &BTreeMap<String, LockEntry>) {
1360        let json = serde_json::to_string_pretty(locked).unwrap();
1361        std::fs::write(dir.join("Skillfile.lock"), format!("{json}\n")).unwrap();
1362    }
1363
1364    /// Write `.skillfile/conflict` JSON from a `ConflictState`.
1365    fn write_conflict_fixture(dir: &Path, state: &ConflictState) {
1366        let p = dir.join(".skillfile/conflict");
1367        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1368        let json = serde_json::to_string_pretty(state).unwrap();
1369        std::fs::write(p, format!("{json}\n")).unwrap();
1370    }
1371
1372    /// Return `true` if any `.patch` file exists under the directory-entry patch dir.
1373    fn has_dir_patch_fixture(dir: &Path, entry: &Entry) -> bool {
1374        let d = dir
1375            .join(".skillfile/patches")
1376            .join(entry.entity_type.dir_name())
1377            .join(&entry.name);
1378        if !d.is_dir() {
1379            return false;
1380        }
1381        std::fs::read_dir(&d).is_ok_and(|rd| {
1382            rd.filter_map(std::result::Result::ok)
1383                .any(|e| e.path().extension().is_some_and(|x| x == "patch"))
1384        })
1385    }
1386
1387    // -----------------------------------------------------------------------
1388    // Entry and target builders
1389    // -----------------------------------------------------------------------
1390
1391    fn make_agent_entry(name: &str) -> Entry {
1392        Entry {
1393            entity_type: EntityType::Agent,
1394            name: name.into(),
1395            source: SourceFields::Github {
1396                owner_repo: "owner/repo".into(),
1397                path_in_repo: "agents/agent.md".into(),
1398                ref_: "main".into(),
1399            },
1400        }
1401    }
1402
1403    fn make_local_entry(name: &str, path: &str) -> Entry {
1404        Entry {
1405            entity_type: EntityType::Skill,
1406            name: name.into(),
1407            source: SourceFields::Local { path: path.into() },
1408        }
1409    }
1410
1411    fn make_target(adapter: &str, scope: Scope) -> InstallTarget {
1412        InstallTarget {
1413            adapter: adapter.into(),
1414            scope,
1415        }
1416    }
1417
1418    // -- install_entry: local source --
1419
1420    #[test]
1421    fn install_local_entry_copy() {
1422        let dir = tempfile::tempdir().unwrap();
1423        let source_file = dir.path().join("skills/my-skill.md");
1424        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1425        std::fs::write(&source_file, "# My Skill").unwrap();
1426
1427        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1428        let target = make_target("claude-code", Scope::Local);
1429        let outcome = install_entry_with_outcome(
1430            &entry,
1431            &target,
1432            &InstallCtx {
1433                repo_root: dir.path(),
1434                opts: None,
1435            },
1436        )
1437        .unwrap();
1438        assert_eq!(outcome, InstallOutcome::Installed);
1439
1440        let dest = dir.path().join(".claude/skills/my-skill/SKILL.md");
1441        assert!(dest.exists());
1442        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
1443    }
1444
1445    #[test]
1446    fn install_local_dir_entry_copy() {
1447        let dir = tempfile::tempdir().unwrap();
1448        // Local source is a directory (not a .md file)
1449        let source_dir = dir.path().join("skills/python-testing");
1450        std::fs::create_dir_all(&source_dir).unwrap();
1451        std::fs::write(source_dir.join("SKILL.md"), "# Python Testing").unwrap();
1452        std::fs::write(source_dir.join("examples.md"), "# Examples").unwrap();
1453
1454        let entry = make_local_entry("python-testing", "skills/python-testing");
1455        let target = make_target("claude-code", Scope::Local);
1456        install_entry(
1457            &entry,
1458            &target,
1459            &InstallCtx {
1460                repo_root: dir.path(),
1461                opts: None,
1462            },
1463        )
1464        .unwrap();
1465
1466        // Must be deployed as a directory (nested mode), not as a single .md file
1467        let dest = dir.path().join(".claude/skills/python-testing");
1468        assert!(dest.is_dir(), "local dir entry must deploy as directory");
1469        assert_eq!(
1470            std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
1471            "# Python Testing"
1472        );
1473        assert_eq!(
1474            std::fs::read_to_string(dest.join("examples.md")).unwrap(),
1475            "# Examples"
1476        );
1477        // Must NOT create a .md file at the target
1478        assert!(
1479            !dir.path().join(".claude/skills/python-testing.md").exists(),
1480            "should not create python-testing.md for a dir source"
1481        );
1482    }
1483
1484    #[test]
1485    fn install_entry_dry_run_no_write() {
1486        let dir = tempfile::tempdir().unwrap();
1487        let source_file = dir.path().join("skills/my-skill.md");
1488        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1489        std::fs::write(&source_file, "# My Skill").unwrap();
1490
1491        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1492        let target = make_target("claude-code", Scope::Local);
1493        let opts = InstallOptions {
1494            dry_run: true,
1495            ..Default::default()
1496        };
1497        let outcome = install_entry_with_outcome(
1498            &entry,
1499            &target,
1500            &InstallCtx {
1501                repo_root: dir.path(),
1502                opts: Some(&opts),
1503            },
1504        )
1505        .unwrap();
1506        assert_eq!(outcome, InstallOutcome::Skipped(InstallSkipReason::DryRun));
1507
1508        let dest = dir.path().join(".claude/skills/my-skill/SKILL.md");
1509        assert!(!dest.exists());
1510    }
1511
1512    #[test]
1513    fn install_entry_overwrites_existing() {
1514        let dir = tempfile::tempdir().unwrap();
1515        let source_file = dir.path().join("skills/my-skill.md");
1516        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1517        std::fs::write(&source_file, "# New content").unwrap();
1518
1519        let dest_dir = dir.path().join(".claude/skills/my-skill");
1520        std::fs::create_dir_all(&dest_dir).unwrap();
1521        let dest = dest_dir.join("SKILL.md");
1522        std::fs::write(&dest, "# Old content").unwrap();
1523
1524        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1525        let target = make_target("claude-code", Scope::Local);
1526        install_entry(
1527            &entry,
1528            &target,
1529            &InstallCtx {
1530                repo_root: dir.path(),
1531                opts: None,
1532            },
1533        )
1534        .unwrap();
1535
1536        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
1537    }
1538
1539    // -- install_entry: github (vendored) source --
1540
1541    #[test]
1542    fn install_github_entry_copy() {
1543        let dir = tempfile::tempdir().unwrap();
1544        let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
1545        std::fs::create_dir_all(&vdir).unwrap();
1546        std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
1547
1548        let entry = make_agent_entry("my-agent");
1549        let target = make_target("claude-code", Scope::Local);
1550        install_entry(
1551            &entry,
1552            &target,
1553            &InstallCtx {
1554                repo_root: dir.path(),
1555                opts: None,
1556            },
1557        )
1558        .unwrap();
1559
1560        let dest = dir.path().join(".claude/agents/my-agent.md");
1561        assert!(dest.exists());
1562        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
1563    }
1564
1565    #[test]
1566    fn install_github_dir_entry_copy() {
1567        let dir = tempfile::tempdir().unwrap();
1568        let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
1569        std::fs::create_dir_all(&vdir).unwrap();
1570        std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
1571        std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
1572
1573        let entry = Entry {
1574            entity_type: EntityType::Skill,
1575            name: "python-pro".into(),
1576            source: SourceFields::Github {
1577                owner_repo: "owner/repo".into(),
1578                path_in_repo: "skills/python-pro".into(),
1579                ref_: "main".into(),
1580            },
1581        };
1582        let target = make_target("claude-code", Scope::Local);
1583        install_entry(
1584            &entry,
1585            &target,
1586            &InstallCtx {
1587                repo_root: dir.path(),
1588                opts: None,
1589            },
1590        )
1591        .unwrap();
1592
1593        let dest = dir.path().join(".claude/skills/python-pro");
1594        assert!(dest.is_dir());
1595        assert_eq!(
1596            std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
1597            "# Python Pro"
1598        );
1599    }
1600
1601    #[test]
1602    fn install_agent_dir_entry_explodes_to_individual_files() {
1603        let dir = tempfile::tempdir().unwrap();
1604        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1605        std::fs::create_dir_all(&vdir).unwrap();
1606        std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
1607        std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
1608        std::fs::write(vdir.join(".meta"), "{}").unwrap();
1609
1610        let entry = Entry {
1611            entity_type: EntityType::Agent,
1612            name: "core-dev".into(),
1613            source: SourceFields::Github {
1614                owner_repo: "owner/repo".into(),
1615                path_in_repo: "categories/core-dev".into(),
1616                ref_: "main".into(),
1617            },
1618        };
1619        let target = make_target("claude-code", Scope::Local);
1620        install_entry(
1621            &entry,
1622            &target,
1623            &InstallCtx {
1624                repo_root: dir.path(),
1625                opts: None,
1626            },
1627        )
1628        .unwrap();
1629
1630        let agents_dir = dir.path().join(".claude/agents");
1631        assert_eq!(
1632            std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
1633            "# Backend"
1634        );
1635        assert_eq!(
1636            std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
1637            "# Frontend"
1638        );
1639        // No "core-dev" directory should exist — flat mode
1640        assert!(!agents_dir.join("core-dev").exists());
1641    }
1642
1643    #[test]
1644    fn install_entry_missing_source_warns() {
1645        let dir = tempfile::tempdir().unwrap();
1646        let entry = make_agent_entry("my-agent");
1647        let target = make_target("claude-code", Scope::Local);
1648
1649        let outcome = install_entry_with_outcome(
1650            &entry,
1651            &target,
1652            &InstallCtx {
1653                repo_root: dir.path(),
1654                opts: None,
1655            },
1656        )
1657        .unwrap();
1658        assert_eq!(
1659            outcome,
1660            InstallOutcome::Skipped(InstallSkipReason::MissingSource)
1661        );
1662    }
1663
1664    #[test]
1665    fn install_entry_unknown_adapter_is_skipped() {
1666        let dir = tempfile::tempdir().unwrap();
1667        let source_file = dir.path().join("skills/my-skill.md");
1668        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1669        std::fs::write(&source_file, "# My Skill").unwrap();
1670
1671        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1672        let target = make_target("unknown-adapter", Scope::Local);
1673        let outcome = install_entry_with_outcome(
1674            &entry,
1675            &target,
1676            &InstallCtx {
1677                repo_root: dir.path(),
1678                opts: None,
1679            },
1680        )
1681        .unwrap();
1682        assert_eq!(
1683            outcome,
1684            InstallOutcome::Skipped(InstallSkipReason::UnknownAdapter)
1685        );
1686    }
1687
1688    #[test]
1689    fn install_entry_errors_when_target_path_is_blocked() {
1690        let dir = tempfile::tempdir().unwrap();
1691        let source_file = dir.path().join("skills/my-skill.md");
1692        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1693        std::fs::write(&source_file, "# My Skill").unwrap();
1694        std::fs::write(dir.path().join(".claude"), "not a directory").unwrap();
1695
1696        let entry = make_local_entry("my-skill", "skills/my-skill.md");
1697        let target = make_target("claude-code", Scope::Local);
1698        let result = install_entry(
1699            &entry,
1700            &target,
1701            &InstallCtx {
1702                repo_root: dir.path(),
1703                opts: None,
1704            },
1705        );
1706        assert!(matches!(
1707            result,
1708            Err(SkillfileError::Install(message))
1709                if message.contains("failed to install 'my-skill' to claude-code (local)")
1710        ));
1711    }
1712
1713    #[test]
1714    fn install_snapshot_restores_previous_directory_contents() {
1715        let dir = tempfile::tempdir().unwrap();
1716        let source_dir = dir.path().join("skills/foo");
1717        let dest_dir = dir.path().join(".claude/skills/foo");
1718        std::fs::create_dir_all(&source_dir).unwrap();
1719        std::fs::create_dir_all(&dest_dir).unwrap();
1720        std::fs::write(source_dir.join("SKILL.md"), "# Source\n").unwrap();
1721        std::fs::write(dest_dir.join("SKILL.md"), "# Old\n").unwrap();
1722
1723        let entry = make_local_entry("foo", "skills/foo");
1724        let target = make_target("claude-code", Scope::Local);
1725        let snapshot =
1726            capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
1727
1728        std::fs::remove_dir_all(&dest_dir).unwrap();
1729        std::fs::create_dir_all(&dest_dir).unwrap();
1730        std::fs::write(dest_dir.join("SKILL.md"), "# New\n").unwrap();
1731
1732        snapshot.restore().unwrap();
1733        assert_eq!(
1734            std::fs::read_to_string(dest_dir.join("SKILL.md")).unwrap(),
1735            "# Old\n"
1736        );
1737    }
1738
1739    #[test]
1740    fn install_snapshot_restores_legacy_flat_file_side_effect() {
1741        let dir = tempfile::tempdir().unwrap();
1742        let source_file = dir.path().join("skills/foo.md");
1743        let legacy_file = dir.path().join(".claude/skills/foo.md");
1744        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1745        std::fs::create_dir_all(legacy_file.parent().unwrap()).unwrap();
1746        std::fs::write(&source_file, "# Source\n").unwrap();
1747        std::fs::write(&legacy_file, "# Legacy\n").unwrap();
1748
1749        let entry = make_local_entry("foo", "skills/foo.md");
1750        let target = make_target("claude-code", Scope::Local);
1751        let snapshot =
1752            capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
1753
1754        std::fs::remove_file(&legacy_file).unwrap();
1755        snapshot.restore().unwrap();
1756        assert_eq!(std::fs::read_to_string(&legacy_file).unwrap(), "# Legacy\n");
1757    }
1758
1759    #[test]
1760    fn install_snapshot_restores_patch_file() {
1761        let dir = tempfile::tempdir().unwrap();
1762        let source_file = dir.path().join("skills/foo.md");
1763        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1764        std::fs::write(&source_file, "# Source\n").unwrap();
1765
1766        let entry = make_local_entry("foo", "skills/foo.md");
1767        let patch = patch_fixture_path(dir.path(), &entry);
1768        std::fs::create_dir_all(patch.parent().unwrap()).unwrap();
1769        std::fs::write(&patch, "old patch").unwrap();
1770
1771        let target = make_target("claude-code", Scope::Local);
1772        let snapshot =
1773            capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
1774        std::fs::write(&patch, "new patch").unwrap();
1775
1776        snapshot.restore().unwrap();
1777        assert_eq!(std::fs::read_to_string(&patch).unwrap(), "old patch");
1778    }
1779
1780    #[cfg(unix)]
1781    #[test]
1782    fn install_entry_restores_existing_directory_when_copy_fails() {
1783        use std::os::unix::fs::symlink;
1784
1785        let dir = tempfile::tempdir().unwrap();
1786        let source_dir = dir.path().join("skills/foo");
1787        let dest_dir = dir.path().join(".claude/skills/foo");
1788        std::fs::create_dir_all(&source_dir).unwrap();
1789        std::fs::create_dir_all(&dest_dir).unwrap();
1790        std::fs::write(source_dir.join("SKILL.md"), "# New\n").unwrap();
1791        symlink("missing-target.md", source_dir.join("dangling.md")).unwrap();
1792        std::fs::write(dest_dir.join("SKILL.md"), "# Old\n").unwrap();
1793        std::fs::write(dest_dir.join("keep.md"), "# Keep\n").unwrap();
1794
1795        let entry = make_local_entry("foo", "skills/foo");
1796        let target = make_target("claude-code", Scope::Local);
1797        let result = install_entry(
1798            &entry,
1799            &target,
1800            &InstallCtx {
1801                repo_root: dir.path(),
1802                opts: None,
1803            },
1804        );
1805
1806        assert!(matches!(
1807            result,
1808            Err(SkillfileError::Install(message))
1809                if message.contains("failed to install 'foo' to claude-code (local)")
1810        ));
1811        assert_eq!(
1812            std::fs::read_to_string(dest_dir.join("SKILL.md")).unwrap(),
1813            "# Old\n"
1814        );
1815        assert_eq!(
1816            std::fs::read_to_string(dest_dir.join("keep.md")).unwrap(),
1817            "# Keep\n"
1818        );
1819    }
1820
1821    #[cfg(unix)]
1822    #[test]
1823    fn install_snapshot_restores_symlink_as_symlink() {
1824        use std::os::unix::fs::symlink;
1825
1826        let dir = tempfile::tempdir().unwrap();
1827        let link = dir.path().join(".claude/skills/foo.md");
1828        std::fs::create_dir_all(link.parent().unwrap()).unwrap();
1829        symlink("target.md", &link).unwrap();
1830        let snapshot = InstallSnapshot::capture(dir.path(), vec![link.clone()]).unwrap();
1831
1832        std::fs::remove_file(&link).unwrap();
1833        std::fs::write(&link, "# regular file\n").unwrap();
1834
1835        snapshot.restore().unwrap();
1836        assert!(std::fs::symlink_metadata(&link)
1837            .unwrap()
1838            .file_type()
1839            .is_symlink());
1840        assert_eq!(
1841            std::fs::read_link(&link).unwrap(),
1842            PathBuf::from("target.md")
1843        );
1844    }
1845
1846    #[cfg(windows)]
1847    #[test]
1848    fn install_snapshot_restores_dangling_directory_symlink_kind() {
1849        use std::os::windows::fs::{symlink_dir, FileTypeExt as _};
1850
1851        let dir = tempfile::tempdir().unwrap();
1852        let link = dir.path().join(".claude/skills/foo");
1853        std::fs::create_dir_all(link.parent().unwrap()).unwrap();
1854        if symlink_dir("missing-dir", &link).is_err() {
1855            return;
1856        }
1857        let snapshot = InstallSnapshot::capture(dir.path(), vec![link.clone()]).unwrap();
1858
1859        remove_path(&link).unwrap();
1860        std::fs::write(&link, "# regular file\n").unwrap();
1861
1862        snapshot.restore().unwrap();
1863        let file_type = std::fs::symlink_metadata(&link).unwrap().file_type();
1864        assert!(file_type.is_symlink_dir());
1865        assert_eq!(
1866            std::fs::read_link(&link).unwrap(),
1867            PathBuf::from("missing-dir")
1868        );
1869    }
1870
1871    #[test]
1872    fn install_snapshot_keeps_scratch_dir_when_restore_fails() {
1873        let dir = tempfile::tempdir().unwrap();
1874        let live_path = dir.path().join("target/foo.md");
1875        std::fs::create_dir_all(live_path.parent().unwrap()).unwrap();
1876        std::fs::write(&live_path, "# old\n").unwrap();
1877        let snapshot = InstallSnapshot::capture(dir.path(), vec![live_path.clone()]).unwrap();
1878        let scratch_dir = snapshot.scratch_dir().unwrap().to_path_buf();
1879
1880        std::fs::remove_dir_all(live_path.parent().unwrap()).unwrap();
1881        std::fs::write(live_path.parent().unwrap(), "parent is a file").unwrap();
1882
1883        let result = snapshot.restore();
1884        assert!(result.is_err());
1885        drop(snapshot);
1886        assert!(scratch_dir.exists());
1887
1888        std::fs::remove_dir_all(&scratch_dir).unwrap();
1889        remove_empty_dir(scratch_dir.parent());
1890    }
1891
1892    #[test]
1893    fn patch_conflict_type_survives_rollback_failure() {
1894        let dir = tempfile::tempdir().unwrap();
1895        let live_path = dir.path().join("target/foo.md");
1896        std::fs::create_dir_all(live_path.parent().unwrap()).unwrap();
1897        std::fs::write(&live_path, "# old\n").unwrap();
1898        let snapshot = InstallSnapshot::capture(dir.path(), vec![live_path.clone()]).unwrap();
1899        let scratch_dir = snapshot.scratch_dir().unwrap().to_path_buf();
1900
1901        std::fs::remove_dir_all(live_path.parent().unwrap()).unwrap();
1902        std::fs::write(live_path.parent().unwrap(), "parent is a file").unwrap();
1903
1904        let error = restore_on_install_error(
1905            &snapshot,
1906            SkillfileError::PatchConflict {
1907                message: "patch failed".to_string(),
1908                entry_name: "foo".to_string(),
1909            },
1910        );
1911        assert!(matches!(
1912            error,
1913            SkillfileError::PatchConflict { ref message, ref entry_name }
1914                if entry_name == "foo"
1915                    && message.contains("patch failed")
1916                    && message.contains("rollback failed")
1917        ));
1918        drop(snapshot);
1919
1920        std::fs::remove_dir_all(&scratch_dir).unwrap();
1921        remove_empty_dir(scratch_dir.parent());
1922    }
1923
1924    // -- Patch application during install --
1925
1926    #[test]
1927    fn install_applies_existing_patch() {
1928        let dir = tempfile::tempdir().unwrap();
1929
1930        // Set up cache
1931        let vdir = dir.path().join(".skillfile/cache/skills/test");
1932        std::fs::create_dir_all(&vdir).unwrap();
1933        std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
1934
1935        // Write a patch using filesystem fixture helper.
1936        let entry = Entry {
1937            entity_type: EntityType::Skill,
1938            name: "test".into(),
1939            source: SourceFields::Github {
1940                owner_repo: "owner/repo".into(),
1941                path_in_repo: "skills/test.md".into(),
1942                ref_: "main".into(),
1943            },
1944        };
1945        // Hand-written unified diff: "Original." → "Modified."
1946        let patch_text =
1947            "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Test\n \n-Original.\n+Modified.\n";
1948        write_patch_fixture(dir.path(), &entry, patch_text);
1949
1950        let target = make_target("claude-code", Scope::Local);
1951        install_entry(
1952            &entry,
1953            &target,
1954            &InstallCtx {
1955                repo_root: dir.path(),
1956                opts: None,
1957            },
1958        )
1959        .unwrap();
1960
1961        let dest = dir.path().join(".claude/skills/test/SKILL.md");
1962        assert_eq!(
1963            std::fs::read_to_string(&dest).unwrap(),
1964            "# Test\n\nModified.\n"
1965        );
1966    }
1967
1968    #[test]
1969    fn install_patch_conflict_returns_error() {
1970        let dir = tempfile::tempdir().unwrap();
1971
1972        let vdir = dir.path().join(".skillfile/cache/skills/test");
1973        std::fs::create_dir_all(&vdir).unwrap();
1974        // Cache has completely different content from what the patch expects
1975        std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
1976
1977        let entry = Entry {
1978            entity_type: EntityType::Skill,
1979            name: "test".into(),
1980            source: SourceFields::Github {
1981                owner_repo: "owner/repo".into(),
1982                path_in_repo: "skills/test.md".into(),
1983                ref_: "main".into(),
1984            },
1985        };
1986        // Write a patch that expects a line that doesn't exist
1987        let bad_patch =
1988            "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
1989        write_patch_fixture(dir.path(), &entry, bad_patch);
1990
1991        // Deploy the entry
1992        let installed_dir = dir.path().join(".claude/skills/test");
1993        std::fs::create_dir_all(&installed_dir).unwrap();
1994        std::fs::write(
1995            installed_dir.join("SKILL.md"),
1996            "totally different\ncontent\n",
1997        )
1998        .unwrap();
1999
2000        let target = make_target("claude-code", Scope::Local);
2001        let result = install_entry(
2002            &entry,
2003            &target,
2004            &InstallCtx {
2005                repo_root: dir.path(),
2006                opts: None,
2007            },
2008        );
2009        assert!(result.is_err());
2010        // Should be a PatchConflict error
2011        matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
2012    }
2013
2014    #[test]
2015    fn install_patch_conflict_restores_previous_installed_file() {
2016        let dir = tempfile::tempdir().unwrap();
2017
2018        let vdir = dir.path().join(".skillfile/cache/skills/test");
2019        std::fs::create_dir_all(&vdir).unwrap();
2020        std::fs::write(vdir.join("test.md"), "# New upstream\n").unwrap();
2021
2022        let entry = Entry {
2023            entity_type: EntityType::Skill,
2024            name: "test".into(),
2025            source: SourceFields::Github {
2026                owner_repo: "owner/repo".into(),
2027                path_in_repo: "skills/test.md".into(),
2028                ref_: "main".into(),
2029            },
2030        };
2031        let bad_patch =
2032            "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
2033        write_patch_fixture(dir.path(), &entry, bad_patch);
2034
2035        let dest = dir.path().join(".claude/skills/test/SKILL.md");
2036        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2037        std::fs::write(&dest, "# Old installed\n").unwrap();
2038
2039        let target = make_target("claude-code", Scope::Local);
2040        let result = install_entry(
2041            &entry,
2042            &target,
2043            &InstallCtx {
2044                repo_root: dir.path(),
2045                opts: None,
2046            },
2047        );
2048
2049        assert!(matches!(result, Err(SkillfileError::PatchConflict { .. })));
2050        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old installed\n");
2051    }
2052
2053    // -- Multi-adapter --
2054
2055    #[test]
2056    fn install_local_skill_gemini_cli() {
2057        let dir = tempfile::tempdir().unwrap();
2058        let source_file = dir.path().join("skills/my-skill.md");
2059        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2060        std::fs::write(&source_file, "# My Skill").unwrap();
2061
2062        let entry = make_local_entry("my-skill", "skills/my-skill.md");
2063        let target = make_target("gemini-cli", Scope::Local);
2064        install_entry(
2065            &entry,
2066            &target,
2067            &InstallCtx {
2068                repo_root: dir.path(),
2069                opts: None,
2070            },
2071        )
2072        .unwrap();
2073
2074        let dest = dir.path().join(".gemini/skills/my-skill/SKILL.md");
2075        assert!(dest.exists());
2076        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
2077    }
2078
2079    #[test]
2080    fn install_local_skill_codex() {
2081        let dir = tempfile::tempdir().unwrap();
2082        let source_file = dir.path().join("skills/my-skill.md");
2083        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2084        std::fs::write(&source_file, "# My Skill").unwrap();
2085
2086        let entry = make_local_entry("my-skill", "skills/my-skill.md");
2087        let target = make_target("codex", Scope::Local);
2088        install_entry(
2089            &entry,
2090            &target,
2091            &InstallCtx {
2092                repo_root: dir.path(),
2093                opts: None,
2094            },
2095        )
2096        .unwrap();
2097
2098        let dest = dir.path().join(".codex/skills/my-skill/SKILL.md");
2099        assert!(dest.exists());
2100        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
2101    }
2102
2103    #[test]
2104    fn codex_skips_agent_entries() {
2105        let dir = tempfile::tempdir().unwrap();
2106        let entry = make_agent_entry("my-agent");
2107        let target = make_target("codex", Scope::Local);
2108        let outcome = install_entry_with_outcome(
2109            &entry,
2110            &target,
2111            &InstallCtx {
2112                repo_root: dir.path(),
2113                opts: None,
2114            },
2115        )
2116        .unwrap();
2117        assert_eq!(
2118            outcome,
2119            InstallOutcome::Skipped(InstallSkipReason::UnsupportedEntity)
2120        );
2121
2122        assert!(!dir.path().join(".codex").exists());
2123    }
2124
2125    #[test]
2126    fn install_github_agent_gemini_cli() {
2127        let dir = tempfile::tempdir().unwrap();
2128        let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
2129        std::fs::create_dir_all(&vdir).unwrap();
2130        std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
2131
2132        let entry = make_agent_entry("my-agent");
2133        let target = make_target("gemini-cli", Scope::Local);
2134        install_entry(
2135            &entry,
2136            &target,
2137            &InstallCtx {
2138                repo_root: dir.path(),
2139                opts: Some(&InstallOptions::default()),
2140            },
2141        )
2142        .unwrap();
2143
2144        let dest = dir.path().join(".gemini/agents/my-agent.md");
2145        assert!(dest.exists());
2146        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
2147    }
2148
2149    #[test]
2150    fn install_skill_multi_adapter() {
2151        for adapter in &["claude-code", "gemini-cli", "codex"] {
2152            let dir = tempfile::tempdir().unwrap();
2153            let source_file = dir.path().join("skills/my-skill.md");
2154            std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2155            std::fs::write(&source_file, "# Multi Skill").unwrap();
2156
2157            let entry = make_local_entry("my-skill", "skills/my-skill.md");
2158            let target = make_target(adapter, Scope::Local);
2159            install_entry(
2160                &entry,
2161                &target,
2162                &InstallCtx {
2163                    repo_root: dir.path(),
2164                    opts: None,
2165                },
2166            )
2167            .unwrap();
2168
2169            let prefix = match *adapter {
2170                "claude-code" => ".claude",
2171                "gemini-cli" => ".gemini",
2172                "codex" => ".codex",
2173                _ => unreachable!(),
2174            };
2175            let dest = dir
2176                .path()
2177                .join(format!("{prefix}/skills/my-skill/SKILL.md"));
2178            assert!(dest.exists(), "Failed for adapter {adapter}");
2179            assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
2180        }
2181    }
2182
2183    // -- cmd_install --
2184
2185    #[test]
2186    fn cmd_install_no_manifest() {
2187        let dir = tempfile::tempdir().unwrap();
2188        let result = cmd_install(
2189            dir.path(),
2190            &CmdInstallOpts {
2191                dry_run: false,
2192                update: false,
2193                extra_targets: None,
2194            },
2195        );
2196        assert!(result.is_err());
2197        assert!(result.unwrap_err().to_string().contains("not found"));
2198    }
2199
2200    #[test]
2201    fn cmd_install_no_install_targets() {
2202        let dir = tempfile::tempdir().unwrap();
2203        std::fs::write(
2204            dir.path().join("Skillfile"),
2205            "local  skill  foo  skills/foo.md\n",
2206        )
2207        .unwrap();
2208
2209        let result = cmd_install(
2210            dir.path(),
2211            &CmdInstallOpts {
2212                dry_run: false,
2213                update: false,
2214                extra_targets: None,
2215            },
2216        );
2217        assert!(result.is_err());
2218        assert!(result
2219            .unwrap_err()
2220            .to_string()
2221            .contains("No install targets"));
2222    }
2223
2224    #[test]
2225    fn cmd_install_extra_targets_fallback() {
2226        let dir = tempfile::tempdir().unwrap();
2227        // Skillfile with entries but NO install lines.
2228        std::fs::write(
2229            dir.path().join("Skillfile"),
2230            "local  skill  foo  skills/foo.md\n",
2231        )
2232        .unwrap();
2233        let source_file = dir.path().join("skills/foo.md");
2234        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2235        std::fs::write(&source_file, "# Foo").unwrap();
2236
2237        // Pass extra targets — should be used as fallback.
2238        let targets = vec![make_target("claude-code", Scope::Local)];
2239        cmd_install(
2240            dir.path(),
2241            &CmdInstallOpts {
2242                dry_run: false,
2243                update: false,
2244                extra_targets: Some(&targets),
2245            },
2246        )
2247        .unwrap();
2248
2249        let dest = dir.path().join(".claude/skills/foo/SKILL.md");
2250        assert!(
2251            dest.exists(),
2252            "extra_targets must be used when Skillfile has none"
2253        );
2254        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
2255    }
2256
2257    #[test]
2258    fn cmd_install_skillfile_targets_win_over_extra() {
2259        let dir = tempfile::tempdir().unwrap();
2260        // Skillfile WITH install lines.
2261        std::fs::write(
2262            dir.path().join("Skillfile"),
2263            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
2264        )
2265        .unwrap();
2266        let source_file = dir.path().join("skills/foo.md");
2267        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2268        std::fs::write(&source_file, "# Foo").unwrap();
2269
2270        // Pass extra targets for gemini-cli — should be IGNORED (Skillfile wins).
2271        let targets = vec![make_target("gemini-cli", Scope::Local)];
2272        cmd_install(
2273            dir.path(),
2274            &CmdInstallOpts {
2275                dry_run: false,
2276                update: false,
2277                extra_targets: Some(&targets),
2278            },
2279        )
2280        .unwrap();
2281
2282        // claude-code (from Skillfile) should be deployed.
2283        assert!(dir.path().join(".claude/skills/foo/SKILL.md").exists());
2284        // gemini-cli (from extra_targets) should NOT be deployed.
2285        assert!(!dir.path().join(".gemini").exists());
2286    }
2287
2288    #[test]
2289    fn cmd_install_dry_run_no_files() {
2290        let dir = tempfile::tempdir().unwrap();
2291        std::fs::write(
2292            dir.path().join("Skillfile"),
2293            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
2294        )
2295        .unwrap();
2296        let source_file = dir.path().join("skills/foo.md");
2297        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2298        std::fs::write(&source_file, "# Foo").unwrap();
2299
2300        cmd_install(
2301            dir.path(),
2302            &CmdInstallOpts {
2303                dry_run: true,
2304                update: false,
2305                extra_targets: None,
2306            },
2307        )
2308        .unwrap();
2309
2310        assert!(!dir.path().join(".claude").exists());
2311    }
2312
2313    #[test]
2314    fn cmd_install_deploys_to_multiple_adapters() {
2315        let dir = tempfile::tempdir().unwrap();
2316        std::fs::write(
2317            dir.path().join("Skillfile"),
2318            "install  claude-code  local\n\
2319             install  gemini-cli  local\n\
2320             install  codex  local\n\
2321             local  skill  foo  skills/foo.md\n\
2322             local  agent  bar  agents/bar.md\n",
2323        )
2324        .unwrap();
2325        std::fs::create_dir_all(dir.path().join("skills")).unwrap();
2326        std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
2327        std::fs::create_dir_all(dir.path().join("agents")).unwrap();
2328        std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
2329
2330        cmd_install(
2331            dir.path(),
2332            &CmdInstallOpts {
2333                dry_run: false,
2334                update: false,
2335                extra_targets: None,
2336            },
2337        )
2338        .unwrap();
2339
2340        // skill deployed to all three adapters
2341        assert!(dir.path().join(".claude/skills/foo/SKILL.md").exists());
2342        assert!(dir.path().join(".gemini/skills/foo/SKILL.md").exists());
2343        assert!(dir.path().join(".codex/skills/foo/SKILL.md").exists());
2344
2345        // agent deployed to claude-code and gemini-cli but NOT codex
2346        assert!(dir.path().join(".claude/agents/bar.md").exists());
2347        assert!(dir.path().join(".gemini/agents/bar.md").exists());
2348        assert!(!dir.path().join(".codex/agents").exists());
2349    }
2350
2351    #[test]
2352    fn cmd_install_pending_conflict_blocks() {
2353        let dir = tempfile::tempdir().unwrap();
2354        std::fs::write(
2355            dir.path().join("Skillfile"),
2356            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
2357        )
2358        .unwrap();
2359
2360        write_conflict_fixture(
2361            dir.path(),
2362            &ConflictState {
2363                entry: "foo".into(),
2364                entity_type: EntityType::Skill,
2365                old_sha: "aaa".into(),
2366                new_sha: "bbb".into(),
2367            },
2368        );
2369
2370        let result = cmd_install(
2371            dir.path(),
2372            &CmdInstallOpts {
2373                dry_run: false,
2374                update: false,
2375                extra_targets: None,
2376            },
2377        );
2378        assert!(result.is_err());
2379        assert!(result.unwrap_err().to_string().contains("pending conflict"));
2380    }
2381
2382    // -----------------------------------------------------------------------
2383    // Helpers shared by the new tests below
2384    // -----------------------------------------------------------------------
2385
2386    /// Build a single-file github skill Entry.
2387    fn make_skill_entry(name: &str) -> Entry {
2388        Entry {
2389            entity_type: EntityType::Skill,
2390            name: name.into(),
2391            source: SourceFields::Github {
2392                owner_repo: "owner/repo".into(),
2393                path_in_repo: format!("skills/{name}.md"),
2394                ref_: "main".into(),
2395            },
2396        }
2397    }
2398
2399    /// Build a directory github skill Entry (path_in_repo has no `.md` suffix).
2400    fn make_dir_skill_entry(name: &str) -> Entry {
2401        Entry {
2402            entity_type: EntityType::Skill,
2403            name: name.into(),
2404            source: SourceFields::Github {
2405                owner_repo: "owner/repo".into(),
2406                path_in_repo: format!("skills/{name}"),
2407                ref_: "main".into(),
2408            },
2409        }
2410    }
2411
2412    /// Write a minimal Skillfile + Skillfile.lock for a single single-file github skill.
2413    fn setup_github_skill_repo(dir: &Path, name: &str, cache_content: &str) {
2414        // Manifest
2415        std::fs::write(
2416            dir.join("Skillfile"),
2417            format!(
2418                "install  claude-code  local\ngithub  skill  {name}  owner/repo  skills/{name}.md\n"
2419            ),
2420        )
2421        .unwrap();
2422
2423        // Lock file via filesystem fixture (no cross-crate call).
2424        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2425        locked.insert(
2426            format!("github/skill/{name}"),
2427            LockEntry {
2428                sha: "abc123def456abc123def456abc123def456abc123".into(),
2429                raw_url: format!(
2430                    "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
2431                ),
2432            },
2433        );
2434        write_lock_fixture(dir, &locked);
2435
2436        // Vendor cache
2437        let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
2438        std::fs::create_dir_all(&vdir).unwrap();
2439        std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
2440    }
2441
2442    // -----------------------------------------------------------------------
2443    // auto_pin_entry — single-file entry
2444    // -----------------------------------------------------------------------
2445
2446    #[test]
2447    fn auto_pin_entry_local_is_skipped() {
2448        let dir = tempfile::tempdir().unwrap();
2449
2450        // Local entry: auto_pin should be a no-op.
2451        let entry = make_local_entry("my-skill", "skills/my-skill.md");
2452        let manifest = Manifest {
2453            entries: vec![entry.clone()],
2454            install_targets: vec![make_target("claude-code", Scope::Local)],
2455        };
2456
2457        // Provide installed file that differs from source — pin should NOT fire.
2458        let skills_dir = dir.path().join("skills");
2459        std::fs::create_dir_all(&skills_dir).unwrap();
2460        std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
2461
2462        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2463
2464        // No patch must have been written.
2465        assert!(
2466            !patch_fixture_path(dir.path(), &entry).exists(),
2467            "local entry must never be pinned"
2468        );
2469    }
2470
2471    #[test]
2472    fn auto_pin_entry_missing_lock_is_skipped() {
2473        let dir = tempfile::tempdir().unwrap();
2474
2475        let entry = make_skill_entry("test");
2476        let manifest = Manifest {
2477            entries: vec![entry.clone()],
2478            install_targets: vec![make_target("claude-code", Scope::Local)],
2479        };
2480
2481        // No Skillfile.lock — should silently return without panicking.
2482        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2483
2484        assert!(!patch_fixture_path(dir.path(), &entry).exists());
2485    }
2486
2487    #[test]
2488    fn auto_pin_entry_missing_lock_key_is_skipped() {
2489        let dir = tempfile::tempdir().unwrap();
2490
2491        // Lock exists but for a different entry.
2492        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2493        locked.insert(
2494            "github/skill/other".into(),
2495            LockEntry {
2496                sha: "aabbcc".into(),
2497                raw_url: "https://example.com/other.md".into(),
2498            },
2499        );
2500        write_lock_fixture(dir.path(), &locked);
2501
2502        let entry = make_skill_entry("test");
2503        let manifest = Manifest {
2504            entries: vec![entry.clone()],
2505            install_targets: vec![make_target("claude-code", Scope::Local)],
2506        };
2507
2508        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2509
2510        assert!(!patch_fixture_path(dir.path(), &entry).exists());
2511    }
2512
2513    #[test]
2514    fn auto_pin_entry_writes_patch_when_installed_differs() {
2515        let dir = tempfile::tempdir().unwrap();
2516        let name = "my-skill";
2517
2518        let cache_content = "# My Skill\n\nOriginal content.\n";
2519        let installed_content = "# My Skill\n\nUser-modified content.\n";
2520
2521        setup_github_skill_repo(dir.path(), name, cache_content);
2522
2523        // Place a modified installed file.
2524        let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2525        std::fs::create_dir_all(&installed_dir).unwrap();
2526        std::fs::write(installed_dir.join("SKILL.md"), installed_content).unwrap();
2527
2528        let entry = make_skill_entry(name);
2529        let manifest = Manifest {
2530            entries: vec![entry.clone()],
2531            install_targets: vec![make_target("claude-code", Scope::Local)],
2532        };
2533
2534        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2535
2536        assert!(
2537            patch_fixture_path(dir.path(), &entry).exists(),
2538            "patch should be written when installed differs from cache"
2539        );
2540
2541        // Verify the patch round-trips: reset the installed file to cache_content and
2542        // reinstall — the patch must produce installed_content.
2543        std::fs::write(installed_dir.join("SKILL.md"), cache_content).unwrap();
2544        let target = make_target("claude-code", Scope::Local);
2545        install_entry(
2546            &entry,
2547            &target,
2548            &InstallCtx {
2549                repo_root: dir.path(),
2550                opts: None,
2551            },
2552        )
2553        .unwrap();
2554        assert_eq!(
2555            std::fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
2556            installed_content,
2557        );
2558    }
2559
2560    #[test]
2561    fn auto_pin_entry_uses_second_target_when_first_is_clean() {
2562        let dir = tempfile::tempdir().unwrap();
2563        let name = "my-skill";
2564
2565        let cache_content = "# My Skill\n\nOriginal content.\n";
2566        let installed_content = "# My Skill\n\nUser-modified content.\n";
2567
2568        setup_github_skill_repo(dir.path(), name, cache_content);
2569
2570        let first_installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2571        std::fs::create_dir_all(&first_installed_dir).unwrap();
2572        std::fs::write(first_installed_dir.join("SKILL.md"), cache_content).unwrap();
2573
2574        let second_installed_dir = dir.path().join(format!(".cursor/skills/{name}"));
2575        std::fs::create_dir_all(&second_installed_dir).unwrap();
2576        std::fs::write(second_installed_dir.join("SKILL.md"), installed_content).unwrap();
2577
2578        let entry = make_skill_entry(name);
2579        let manifest = Manifest {
2580            entries: vec![entry.clone()],
2581            install_targets: vec![
2582                make_target("claude-code", Scope::Local),
2583                make_target("cursor", Scope::Local),
2584            ],
2585        };
2586
2587        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2588
2589        std::fs::write(first_installed_dir.join("SKILL.md"), cache_content).unwrap();
2590        install_entry(
2591            &entry,
2592            &make_target("claude-code", Scope::Local),
2593            &InstallCtx {
2594                repo_root: dir.path(),
2595                opts: None,
2596            },
2597        )
2598        .unwrap();
2599        assert_eq!(
2600            std::fs::read_to_string(first_installed_dir.join("SKILL.md")).unwrap(),
2601            installed_content,
2602            "auto-pin must preserve edits from a modified secondary target"
2603        );
2604    }
2605
2606    #[test]
2607    fn auto_pin_entry_errors_on_divergent_multi_target_edits() {
2608        let dir = tempfile::tempdir().unwrap();
2609        let name = "my-skill";
2610
2611        let cache_content = "# My Skill\n\nOriginal content.\n";
2612        setup_github_skill_repo(dir.path(), name, cache_content);
2613
2614        let first_installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2615        std::fs::create_dir_all(&first_installed_dir).unwrap();
2616        std::fs::write(
2617            first_installed_dir.join("SKILL.md"),
2618            "# My Skill\n\nClaude edit.\n",
2619        )
2620        .unwrap();
2621
2622        let second_installed_dir = dir.path().join(format!(".cursor/skills/{name}"));
2623        std::fs::create_dir_all(&second_installed_dir).unwrap();
2624        std::fs::write(
2625            second_installed_dir.join("SKILL.md"),
2626            "# My Skill\n\nCursor edit.\n",
2627        )
2628        .unwrap();
2629
2630        let entry = make_skill_entry(name);
2631        let manifest = Manifest {
2632            entries: vec![entry.clone()],
2633            install_targets: vec![
2634                make_target("claude-code", Scope::Local),
2635                make_target("cursor", Scope::Local),
2636            ],
2637        };
2638
2639        let error = auto_pin_entry(&entry, &manifest, dir.path()).unwrap_err();
2640        assert!(
2641            error
2642                .to_string()
2643                .contains("divergent edits across install targets"),
2644            "unexpected error: {error}"
2645        );
2646    }
2647
2648    #[test]
2649    fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
2650        let dir = tempfile::tempdir().unwrap();
2651        let name = "my-skill";
2652
2653        let cache_content = "# My Skill\n\nOriginal.\n";
2654        let installed_content = "# My Skill\n\nModified.\n";
2655
2656        setup_github_skill_repo(dir.path(), name, cache_content);
2657
2658        let entry = make_skill_entry(name);
2659        let manifest = Manifest {
2660            entries: vec![entry.clone()],
2661            install_targets: vec![make_target("claude-code", Scope::Local)],
2662        };
2663
2664        // Pre-write the correct patch (cache → installed) using the fixture helper.
2665        // Hand-written unified diff: "Original." → "Modified."
2666        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";
2667        write_patch_fixture(dir.path(), &entry, patch_text);
2668
2669        // Write installed file that matches what the patch produces.
2670        let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2671        std::fs::create_dir_all(&installed_dir).unwrap();
2672        std::fs::write(installed_dir.join("SKILL.md"), installed_content).unwrap();
2673
2674        // Record mtime of patch so we can detect if it changed.
2675        let patch_path = patch_fixture_path(dir.path(), &entry);
2676        let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
2677
2678        // Small sleep so that any write would produce a different mtime.
2679        std::thread::sleep(std::time::Duration::from_millis(20));
2680
2681        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2682
2683        let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
2684
2685        assert_eq!(
2686            mtime_before, mtime_after,
2687            "patch must not be rewritten when already up to date"
2688        );
2689    }
2690
2691    #[test]
2692    fn auto_pin_entry_repins_when_installed_has_additional_edits() {
2693        let dir = tempfile::tempdir().unwrap();
2694        let name = "my-skill";
2695
2696        let cache_content = "# My Skill\n\nOriginal.\n";
2697        let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
2698
2699        setup_github_skill_repo(dir.path(), name, cache_content);
2700
2701        let entry = make_skill_entry(name);
2702        let manifest = Manifest {
2703            entries: vec![entry.clone()],
2704            install_targets: vec![make_target("claude-code", Scope::Local)],
2705        };
2706
2707        // Stored patch reflects the old installed state: "Original." → "First edit."
2708        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";
2709        write_patch_fixture(dir.path(), &entry, old_patch);
2710
2711        // But the actual installed file has further edits.
2712        let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2713        std::fs::create_dir_all(&installed_dir).unwrap();
2714        std::fs::write(installed_dir.join("SKILL.md"), new_installed).unwrap();
2715
2716        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2717
2718        // The patch was re-written to reflect new_installed. Verify by resetting the
2719        // installed file to cache_content and reinstalling — must yield new_installed.
2720        std::fs::write(installed_dir.join("SKILL.md"), cache_content).unwrap();
2721        let target = make_target("claude-code", Scope::Local);
2722        install_entry(
2723            &entry,
2724            &target,
2725            &InstallCtx {
2726                repo_root: dir.path(),
2727                opts: None,
2728            },
2729        )
2730        .unwrap();
2731        assert_eq!(
2732            std::fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
2733            new_installed,
2734            "updated patch must describe the latest installed content"
2735        );
2736    }
2737
2738    // -----------------------------------------------------------------------
2739    // auto_pin_dir_entry
2740    // -----------------------------------------------------------------------
2741
2742    #[test]
2743    fn auto_pin_dir_entry_writes_per_file_patches() {
2744        let dir = tempfile::tempdir().unwrap();
2745        let name = "lang-pro";
2746
2747        // Manifest + lock (dir entry)
2748        std::fs::write(
2749            dir.path().join("Skillfile"),
2750            format!(
2751                "install  claude-code  local\ngithub  skill  {name}  owner/repo  skills/{name}\n"
2752            ),
2753        )
2754        .unwrap();
2755        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2756        locked.insert(
2757            format!("github/skill/{name}"),
2758            LockEntry {
2759                sha: "deadbeefdeadbeefdeadbeef".into(),
2760                raw_url: format!("https://example.com/{name}"),
2761            },
2762        );
2763        write_lock_fixture(dir.path(), &locked);
2764
2765        // Vendor cache with two files.
2766        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
2767        std::fs::create_dir_all(&vdir).unwrap();
2768        std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
2769        std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
2770
2771        // Installed dir (nested mode for skills).
2772        let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
2773        std::fs::create_dir_all(&inst_dir).unwrap();
2774        std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
2775        std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
2776
2777        let entry = make_dir_skill_entry(name);
2778        let manifest = Manifest {
2779            entries: vec![entry.clone()],
2780            install_targets: vec![make_target("claude-code", Scope::Local)],
2781        };
2782
2783        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2784
2785        // Patch for the modified file should exist.
2786        let skill_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
2787        assert!(skill_patch.exists(), "patch for SKILL.md must be written");
2788
2789        // Patch for the unmodified file should NOT exist.
2790        let examples_patch = dir_patch_fixture_path(dir.path(), &entry, "examples.md");
2791        assert!(
2792            !examples_patch.exists(),
2793            "patch for examples.md must not be written (content unchanged)"
2794        );
2795    }
2796
2797    #[test]
2798    fn auto_pin_dir_entry_uses_second_target_when_first_is_clean() {
2799        let dir = tempfile::tempdir().unwrap();
2800        let name = "lang-pro";
2801
2802        std::fs::write(
2803            dir.path().join("Skillfile"),
2804            format!(
2805                "install  claude-code  local\ninstall  cursor  local\ngithub  skill  {name}  owner/repo  skills/{name}\n"
2806            ),
2807        )
2808        .unwrap();
2809        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2810        locked.insert(
2811            format!("github/skill/{name}"),
2812            LockEntry {
2813                sha: "deadbeefdeadbeefdeadbeef".into(),
2814                raw_url: format!("https://example.com/{name}"),
2815            },
2816        );
2817        write_lock_fixture(dir.path(), &locked);
2818
2819        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
2820        std::fs::create_dir_all(&vdir).unwrap();
2821        std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
2822
2823        let first_inst_dir = dir.path().join(format!(".claude/skills/{name}"));
2824        std::fs::create_dir_all(&first_inst_dir).unwrap();
2825        std::fs::write(first_inst_dir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
2826
2827        let second_inst_dir = dir.path().join(format!(".cursor/skills/{name}"));
2828        std::fs::create_dir_all(&second_inst_dir).unwrap();
2829        std::fs::write(
2830            second_inst_dir.join("SKILL.md"),
2831            "# Lang Pro\n\nModified.\n",
2832        )
2833        .unwrap();
2834
2835        let entry = make_dir_skill_entry(name);
2836        let manifest = Manifest {
2837            entries: vec![entry.clone()],
2838            install_targets: vec![
2839                make_target("claude-code", Scope::Local),
2840                make_target("cursor", Scope::Local),
2841            ],
2842        };
2843
2844        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2845
2846        std::fs::write(first_inst_dir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
2847        install_entry(
2848            &entry,
2849            &make_target("claude-code", Scope::Local),
2850            &InstallCtx {
2851                repo_root: dir.path(),
2852                opts: None,
2853            },
2854        )
2855        .unwrap();
2856        assert_eq!(
2857            std::fs::read_to_string(first_inst_dir.join("SKILL.md")).unwrap(),
2858            "# Lang Pro\n\nModified.\n",
2859            "auto-pin must preserve dir-entry edits from a modified secondary target"
2860        );
2861    }
2862
2863    #[test]
2864    fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
2865        let dir = tempfile::tempdir().unwrap();
2866        let name = "lang-pro";
2867
2868        // Write lock so we don't bail out there.
2869        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2870        locked.insert(
2871            format!("github/skill/{name}"),
2872            LockEntry {
2873                sha: "abc".into(),
2874                raw_url: "https://example.com".into(),
2875            },
2876        );
2877        write_lock_fixture(dir.path(), &locked);
2878
2879        let entry = make_dir_skill_entry(name);
2880        let manifest = Manifest {
2881            entries: vec![entry.clone()],
2882            install_targets: vec![make_target("claude-code", Scope::Local)],
2883        };
2884
2885        // No vendor dir — must silently return without panicking.
2886        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2887
2888        assert!(!has_dir_patch_fixture(dir.path(), &entry));
2889    }
2890
2891    #[test]
2892    fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
2893        let dir = tempfile::tempdir().unwrap();
2894        let name = "lang-pro";
2895
2896        let cache_content = "# Lang Pro\n\nOriginal.\n";
2897        let modified = "# Lang Pro\n\nModified.\n";
2898
2899        // Write lock.
2900        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2901        locked.insert(
2902            format!("github/skill/{name}"),
2903            LockEntry {
2904                sha: "abc".into(),
2905                raw_url: "https://example.com".into(),
2906            },
2907        );
2908        write_lock_fixture(dir.path(), &locked);
2909
2910        // Vendor cache.
2911        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
2912        std::fs::create_dir_all(&vdir).unwrap();
2913        std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
2914
2915        // Installed dir.
2916        let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
2917        std::fs::create_dir_all(&inst_dir).unwrap();
2918        std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
2919
2920        let entry = make_dir_skill_entry(name);
2921        let manifest = Manifest {
2922            entries: vec![entry.clone()],
2923            install_targets: vec![make_target("claude-code", Scope::Local)],
2924        };
2925
2926        // Pre-write the correct patch: "Original." → "Modified." for SKILL.md
2927        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Lang Pro\n \n-Original.\n+Modified.\n";
2928        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
2929        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
2930        std::fs::write(&dp, patch_text).unwrap();
2931
2932        let patch_path = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
2933        let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
2934
2935        std::thread::sleep(std::time::Duration::from_millis(20));
2936
2937        auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2938
2939        let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
2940
2941        assert_eq!(
2942            mtime_before, mtime_after,
2943            "dir patch must not be rewritten when already up to date"
2944        );
2945    }
2946
2947    // -----------------------------------------------------------------------
2948    // apply_dir_patches
2949    // -----------------------------------------------------------------------
2950
2951    #[test]
2952    fn apply_dir_patches_applies_patch_and_rebases() {
2953        let dir = tempfile::tempdir().unwrap();
2954
2955        // Old upstream → user's installed version (what the stored patch records).
2956        let cache_content = "# Skill\n\nOriginal.\n";
2957        let installed_content = "# Skill\n\nModified.\n";
2958        // New upstream has a different body line but same structure.
2959        let new_cache_content = "# Skill\n\nOriginal v2.\n";
2960        // After rebase, the rebased patch encodes the diff from new_cache to installed.
2961        // Applying that rebased patch to new_cache must yield installed_content.
2962        let expected_rebased_to_new_cache = installed_content;
2963
2964        let entry = make_dir_skill_entry("lang-pro");
2965
2966        // Create patch dir with a valid patch (old cache → installed): "Original." → "Modified."
2967        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
2968        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
2969        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
2970        std::fs::write(&dp, patch_text).unwrap();
2971
2972        // Installed file starts at cache content (patch not yet applied).
2973        let inst_dir = dir.path().join(".claude/skills/lang-pro");
2974        std::fs::create_dir_all(&inst_dir).unwrap();
2975        std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
2976
2977        // New cache (simulates upstream update).
2978        let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
2979        std::fs::create_dir_all(&new_cache_dir).unwrap();
2980        std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
2981
2982        // Build the installed_files map as deploy_all would.
2983        let mut installed_files = std::collections::HashMap::new();
2984        installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
2985
2986        apply_dir_patches(
2987            &PatchCtx {
2988                entry: &entry,
2989                repo_root: dir.path(),
2990            },
2991            &installed_files,
2992            &new_cache_dir,
2993        )
2994        .unwrap();
2995
2996        // The installed file should have the original patch applied.
2997        let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
2998        assert_eq!(installed_after, installed_content);
2999
3000        // The stored patch must now describe the diff from new_cache to installed_content.
3001        // Verify by resetting the installed file to new_cache and reinstalling — must
3002        // yield installed_content (== expected_rebased_to_new_cache).
3003        std::fs::write(inst_dir.join("SKILL.md"), new_cache_content).unwrap();
3004        let mut reinstall_files = std::collections::HashMap::new();
3005        reinstall_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
3006        apply_dir_patches(
3007            &PatchCtx {
3008                entry: &entry,
3009                repo_root: dir.path(),
3010            },
3011            &reinstall_files,
3012            &new_cache_dir,
3013        )
3014        .unwrap();
3015        assert_eq!(
3016            std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap(),
3017            expected_rebased_to_new_cache,
3018            "rebased patch applied to new_cache must reproduce installed_content"
3019        );
3020    }
3021
3022    #[test]
3023    fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
3024        let dir = tempfile::tempdir().unwrap();
3025
3026        // The "new" cache content IS the patched content — patch becomes a no-op.
3027        let original = "# Skill\n\nOriginal.\n";
3028        let modified = "# Skill\n\nModified.\n";
3029        // New upstream == modified, so after applying patch the result equals new cache.
3030        let new_cache = modified; // upstream caught up
3031
3032        let entry = make_dir_skill_entry("lang-pro");
3033
3034        // Hand-written patch: "Original." → "Modified."
3035        let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
3036        let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
3037        std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
3038        std::fs::write(&dp, patch_text).unwrap();
3039
3040        // Installed file starts at original (patch not yet applied).
3041        let inst_dir = dir.path().join(".claude/skills/lang-pro");
3042        std::fs::create_dir_all(&inst_dir).unwrap();
3043        std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
3044
3045        let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
3046        std::fs::create_dir_all(&new_cache_dir).unwrap();
3047        std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
3048
3049        let mut installed_files = std::collections::HashMap::new();
3050        installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
3051
3052        apply_dir_patches(
3053            &PatchCtx {
3054                entry: &entry,
3055                repo_root: dir.path(),
3056            },
3057            &installed_files,
3058            &new_cache_dir,
3059        )
3060        .unwrap();
3061
3062        // Patch file must be removed (rebase produced empty diff).
3063        let removed_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
3064        assert!(
3065            !removed_patch.exists(),
3066            "patch file must be removed when rebase yields empty diff"
3067        );
3068    }
3069
3070    #[test]
3071    fn apply_dir_patches_no_op_when_no_patches_dir() {
3072        let dir = tempfile::tempdir().unwrap();
3073
3074        // No patches directory at all.
3075        let entry = make_dir_skill_entry("lang-pro");
3076        let installed_files = std::collections::HashMap::new();
3077        let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
3078        std::fs::create_dir_all(&source_dir).unwrap();
3079
3080        // Must succeed without error.
3081        apply_dir_patches(
3082            &PatchCtx {
3083                entry: &entry,
3084                repo_root: dir.path(),
3085            },
3086            &installed_files,
3087            &source_dir,
3088        )
3089        .unwrap();
3090    }
3091
3092    // -----------------------------------------------------------------------
3093    // apply_single_file_patch — rebase removes patch when result equals cache
3094    // -----------------------------------------------------------------------
3095
3096    #[test]
3097    fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
3098        let dir = tempfile::tempdir().unwrap();
3099
3100        let original = "# Skill\n\nOriginal.\n";
3101        let modified = "# Skill\n\nModified.\n";
3102        // New cache == modified: after rebase, new_patch is empty → patch removed.
3103        let new_cache = modified;
3104
3105        let entry = make_skill_entry("test");
3106
3107        // Write patch using filesystem fixture: "Original." → "Modified."
3108        let patch_text =
3109            "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
3110        write_patch_fixture(dir.path(), &entry, patch_text);
3111
3112        // Set up vendor cache (the "new" version).
3113        let vdir = dir.path().join(".skillfile/cache/skills/test");
3114        std::fs::create_dir_all(&vdir).unwrap();
3115        let source = vdir.join("test.md");
3116        std::fs::write(&source, new_cache).unwrap();
3117
3118        // Installed file is the original (patch not yet applied).
3119        let installed_dir = dir.path().join(".claude/skills");
3120        std::fs::create_dir_all(&installed_dir).unwrap();
3121        let dest = installed_dir.join("test.md");
3122        std::fs::write(&dest, original).unwrap();
3123
3124        apply_single_file_patch(
3125            &PatchCtx {
3126                entry: &entry,
3127                repo_root: dir.path(),
3128            },
3129            &dest,
3130            &source,
3131        )
3132        .unwrap();
3133
3134        // The installed file must be the patched (== new cache) result.
3135        assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
3136
3137        // Patch file must have been removed.
3138        assert!(
3139            !patch_fixture_path(dir.path(), &entry).exists(),
3140            "patch must be removed when new cache already matches patched content"
3141        );
3142    }
3143
3144    #[test]
3145    fn apply_single_file_patch_rewrites_patch_after_rebase() {
3146        let dir = tempfile::tempdir().unwrap();
3147
3148        // Old upstream, user edit, new upstream (different body — no overlap with user edit).
3149        let original = "# Skill\n\nOriginal.\n";
3150        let modified = "# Skill\n\nModified.\n";
3151        let new_cache = "# Skill\n\nOriginal v2.\n";
3152        // The rebase stores generate_patch(new_cache, modified).
3153        // Applying that to new_cache must reproduce `modified`.
3154        let expected_rebased_result = modified;
3155
3156        let entry = make_skill_entry("test");
3157
3158        // Hand-written patch: "Original." → "Modified."
3159        let patch_text =
3160            "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
3161        write_patch_fixture(dir.path(), &entry, patch_text);
3162
3163        // New vendor cache (upstream updated).
3164        let vdir = dir.path().join(".skillfile/cache/skills/test");
3165        std::fs::create_dir_all(&vdir).unwrap();
3166        let source = vdir.join("test.md");
3167        std::fs::write(&source, new_cache).unwrap();
3168
3169        // Installed still at original content (patch not applied yet).
3170        let installed_dir = dir.path().join(".claude/skills");
3171        std::fs::create_dir_all(&installed_dir).unwrap();
3172        let dest = installed_dir.join("test.md");
3173        std::fs::write(&dest, original).unwrap();
3174
3175        apply_single_file_patch(
3176            &PatchCtx {
3177                entry: &entry,
3178                repo_root: dir.path(),
3179            },
3180            &dest,
3181            &source,
3182        )
3183        .unwrap();
3184
3185        // Installed must now be the patched content.
3186        assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
3187
3188        // The rebased patch must still exist (new_cache != modified).
3189        assert!(
3190            patch_fixture_path(dir.path(), &entry).exists(),
3191            "rebased patch must still exist (new_cache != modified)"
3192        );
3193        // Verify the rebased patch yields expected_rebased_result when applied to new_cache.
3194        // Reset dest to new_cache and call apply_single_file_patch again.
3195        std::fs::write(&dest, new_cache).unwrap();
3196        std::fs::write(&source, new_cache).unwrap();
3197        apply_single_file_patch(
3198            &PatchCtx {
3199                entry: &entry,
3200                repo_root: dir.path(),
3201            },
3202            &dest,
3203            &source,
3204        )
3205        .unwrap();
3206        assert_eq!(
3207            std::fs::read_to_string(&dest).unwrap(),
3208            expected_rebased_result,
3209            "rebased patch applied to new_cache must reproduce installed content"
3210        );
3211    }
3212
3213    // -----------------------------------------------------------------------
3214    // check_preconditions
3215    // -----------------------------------------------------------------------
3216
3217    #[test]
3218    fn check_preconditions_no_targets_returns_error() {
3219        let dir = tempfile::tempdir().unwrap();
3220        let manifest = Manifest {
3221            entries: vec![],
3222            install_targets: vec![],
3223        };
3224        let result = check_preconditions(&manifest, dir.path());
3225        assert!(result.is_err());
3226        assert!(result
3227            .unwrap_err()
3228            .to_string()
3229            .contains("No install targets"));
3230    }
3231
3232    #[test]
3233    fn check_preconditions_pending_conflict_returns_error() {
3234        let dir = tempfile::tempdir().unwrap();
3235        let manifest = Manifest {
3236            entries: vec![],
3237            install_targets: vec![make_target("claude-code", Scope::Local)],
3238        };
3239
3240        write_conflict_fixture(
3241            dir.path(),
3242            &ConflictState {
3243                entry: "my-skill".into(),
3244                entity_type: EntityType::Skill,
3245                old_sha: "aaa".into(),
3246                new_sha: "bbb".into(),
3247            },
3248        );
3249
3250        let result = check_preconditions(&manifest, dir.path());
3251        assert!(result.is_err());
3252        assert!(result.unwrap_err().to_string().contains("pending conflict"));
3253    }
3254
3255    #[test]
3256    fn check_preconditions_ok_with_target_and_no_conflict() {
3257        let dir = tempfile::tempdir().unwrap();
3258        let manifest = Manifest {
3259            entries: vec![],
3260            install_targets: vec![make_target("claude-code", Scope::Local)],
3261        };
3262        check_preconditions(&manifest, dir.path()).unwrap();
3263    }
3264}