Skip to main content

skillfile_deploy/
install.rs

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