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