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);
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_entry_dry_run_no_write() {
516        let dir = tempfile::tempdir().unwrap();
517        let source_file = dir.path().join("skills/my-skill.md");
518        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
519        std::fs::write(&source_file, "# My Skill").unwrap();
520
521        let entry = make_local_entry("my-skill", "skills/my-skill.md");
522        let target = make_target("claude-code", Scope::Local);
523        let opts = InstallOptions {
524            dry_run: true,
525            ..Default::default()
526        };
527        install_entry(&entry, &target, dir.path(), Some(&opts)).unwrap();
528
529        let dest = dir.path().join(".claude/skills/my-skill.md");
530        assert!(!dest.exists());
531    }
532
533    #[test]
534    fn install_entry_overwrites_existing() {
535        let dir = tempfile::tempdir().unwrap();
536        let source_file = dir.path().join("skills/my-skill.md");
537        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
538        std::fs::write(&source_file, "# New content").unwrap();
539
540        let dest_dir = dir.path().join(".claude/skills");
541        std::fs::create_dir_all(&dest_dir).unwrap();
542        let dest = dest_dir.join("my-skill.md");
543        std::fs::write(&dest, "# Old content").unwrap();
544
545        let entry = make_local_entry("my-skill", "skills/my-skill.md");
546        let target = make_target("claude-code", Scope::Local);
547        install_entry(&entry, &target, dir.path(), None).unwrap();
548
549        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
550    }
551
552    // -- install_entry: github (vendored) source --
553
554    #[test]
555    fn install_github_entry_copy() {
556        let dir = tempfile::tempdir().unwrap();
557        let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
558        std::fs::create_dir_all(&vdir).unwrap();
559        std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
560
561        let entry = make_agent_entry("my-agent");
562        let target = make_target("claude-code", Scope::Local);
563        install_entry(&entry, &target, dir.path(), None).unwrap();
564
565        let dest = dir.path().join(".claude/agents/my-agent.md");
566        assert!(dest.exists());
567        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
568    }
569
570    #[test]
571    fn install_github_dir_entry_copy() {
572        let dir = tempfile::tempdir().unwrap();
573        let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
574        std::fs::create_dir_all(&vdir).unwrap();
575        std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
576        std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
577
578        let entry = Entry {
579            entity_type: EntityType::Skill,
580            name: "python-pro".into(),
581            source: SourceFields::Github {
582                owner_repo: "owner/repo".into(),
583                path_in_repo: "skills/python-pro".into(),
584                ref_: "main".into(),
585            },
586        };
587        let target = make_target("claude-code", Scope::Local);
588        install_entry(&entry, &target, dir.path(), None).unwrap();
589
590        let dest = dir.path().join(".claude/skills/python-pro");
591        assert!(dest.is_dir());
592        assert_eq!(
593            std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
594            "# Python Pro"
595        );
596    }
597
598    #[test]
599    fn install_agent_dir_entry_explodes_to_individual_files() {
600        let dir = tempfile::tempdir().unwrap();
601        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
602        std::fs::create_dir_all(&vdir).unwrap();
603        std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
604        std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
605        std::fs::write(vdir.join(".meta"), "{}").unwrap();
606
607        let entry = Entry {
608            entity_type: EntityType::Agent,
609            name: "core-dev".into(),
610            source: SourceFields::Github {
611                owner_repo: "owner/repo".into(),
612                path_in_repo: "categories/core-dev".into(),
613                ref_: "main".into(),
614            },
615        };
616        let target = make_target("claude-code", Scope::Local);
617        install_entry(&entry, &target, dir.path(), None).unwrap();
618
619        let agents_dir = dir.path().join(".claude/agents");
620        assert_eq!(
621            std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
622            "# Backend"
623        );
624        assert_eq!(
625            std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
626            "# Frontend"
627        );
628        // No "core-dev" directory should exist — flat mode
629        assert!(!agents_dir.join("core-dev").exists());
630    }
631
632    #[test]
633    fn install_entry_missing_source_warns() {
634        let dir = tempfile::tempdir().unwrap();
635        let entry = make_agent_entry("my-agent");
636        let target = make_target("claude-code", Scope::Local);
637
638        // Should return Ok without error — just a warning
639        install_entry(&entry, &target, dir.path(), None).unwrap();
640    }
641
642    // -- Patch application during install --
643
644    #[test]
645    fn install_applies_existing_patch() {
646        let dir = tempfile::tempdir().unwrap();
647
648        // Set up cache
649        let vdir = dir.path().join(".skillfile/cache/skills/test");
650        std::fs::create_dir_all(&vdir).unwrap();
651        std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
652
653        // Write a patch
654        let entry = Entry {
655            entity_type: EntityType::Skill,
656            name: "test".into(),
657            source: SourceFields::Github {
658                owner_repo: "owner/repo".into(),
659                path_in_repo: "skills/test.md".into(),
660                ref_: "main".into(),
661            },
662        };
663        let patch_text = skillfile_core::patch::generate_patch(
664            "# Test\n\nOriginal.\n",
665            "# Test\n\nModified.\n",
666            "test.md",
667        );
668        skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
669
670        let target = make_target("claude-code", Scope::Local);
671        install_entry(&entry, &target, dir.path(), None).unwrap();
672
673        let dest = dir.path().join(".claude/skills/test.md");
674        assert_eq!(
675            std::fs::read_to_string(&dest).unwrap(),
676            "# Test\n\nModified.\n"
677        );
678    }
679
680    #[test]
681    fn install_patch_conflict_returns_error() {
682        let dir = tempfile::tempdir().unwrap();
683
684        let vdir = dir.path().join(".skillfile/cache/skills/test");
685        std::fs::create_dir_all(&vdir).unwrap();
686        // Cache has completely different content from what the patch expects
687        std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
688
689        let entry = Entry {
690            entity_type: EntityType::Skill,
691            name: "test".into(),
692            source: SourceFields::Github {
693                owner_repo: "owner/repo".into(),
694                path_in_repo: "skills/test.md".into(),
695                ref_: "main".into(),
696            },
697        };
698        // Write a patch that expects a line that doesn't exist
699        let bad_patch =
700            "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
701        skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
702
703        // Deploy the entry
704        let installed_dir = dir.path().join(".claude/skills");
705        std::fs::create_dir_all(&installed_dir).unwrap();
706        std::fs::write(
707            installed_dir.join("test.md"),
708            "totally different\ncontent\n",
709        )
710        .unwrap();
711
712        let target = make_target("claude-code", Scope::Local);
713        let result = install_entry(&entry, &target, dir.path(), None);
714        assert!(result.is_err());
715        // Should be a PatchConflict error
716        matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
717    }
718
719    // -- Multi-adapter --
720
721    #[test]
722    fn install_local_skill_gemini_cli() {
723        let dir = tempfile::tempdir().unwrap();
724        let source_file = dir.path().join("skills/my-skill.md");
725        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
726        std::fs::write(&source_file, "# My Skill").unwrap();
727
728        let entry = make_local_entry("my-skill", "skills/my-skill.md");
729        let target = make_target("gemini-cli", Scope::Local);
730        install_entry(&entry, &target, dir.path(), None).unwrap();
731
732        let dest = dir.path().join(".gemini/skills/my-skill.md");
733        assert!(dest.exists());
734        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
735    }
736
737    #[test]
738    fn install_local_skill_codex() {
739        let dir = tempfile::tempdir().unwrap();
740        let source_file = dir.path().join("skills/my-skill.md");
741        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
742        std::fs::write(&source_file, "# My Skill").unwrap();
743
744        let entry = make_local_entry("my-skill", "skills/my-skill.md");
745        let target = make_target("codex", Scope::Local);
746        install_entry(&entry, &target, dir.path(), None).unwrap();
747
748        let dest = dir.path().join(".codex/skills/my-skill.md");
749        assert!(dest.exists());
750        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
751    }
752
753    #[test]
754    fn codex_skips_agent_entries() {
755        let dir = tempfile::tempdir().unwrap();
756        let entry = make_agent_entry("my-agent");
757        let target = make_target("codex", Scope::Local);
758        install_entry(&entry, &target, dir.path(), None).unwrap();
759
760        assert!(!dir.path().join(".codex").exists());
761    }
762
763    #[test]
764    fn install_github_agent_gemini_cli() {
765        let dir = tempfile::tempdir().unwrap();
766        let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
767        std::fs::create_dir_all(&vdir).unwrap();
768        std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
769
770        let entry = make_agent_entry("my-agent");
771        let target = make_target("gemini-cli", Scope::Local);
772        install_entry(
773            &entry,
774            &target,
775            dir.path(),
776            Some(&InstallOptions::default()),
777        )
778        .unwrap();
779
780        let dest = dir.path().join(".gemini/agents/my-agent.md");
781        assert!(dest.exists());
782        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
783    }
784
785    #[test]
786    fn install_skill_multi_adapter() {
787        for adapter in &["claude-code", "gemini-cli", "codex"] {
788            let dir = tempfile::tempdir().unwrap();
789            let source_file = dir.path().join("skills/my-skill.md");
790            std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
791            std::fs::write(&source_file, "# Multi Skill").unwrap();
792
793            let entry = make_local_entry("my-skill", "skills/my-skill.md");
794            let target = make_target(adapter, Scope::Local);
795            install_entry(&entry, &target, dir.path(), None).unwrap();
796
797            let prefix = match *adapter {
798                "claude-code" => ".claude",
799                "gemini-cli" => ".gemini",
800                "codex" => ".codex",
801                _ => unreachable!(),
802            };
803            let dest = dir.path().join(format!("{prefix}/skills/my-skill.md"));
804            assert!(dest.exists(), "Failed for adapter {adapter}");
805            assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
806        }
807    }
808
809    // -- cmd_install --
810
811    #[test]
812    fn cmd_install_no_manifest() {
813        let dir = tempfile::tempdir().unwrap();
814        let result = cmd_install(dir.path(), false, false);
815        assert!(result.is_err());
816        assert!(result.unwrap_err().to_string().contains("not found"));
817    }
818
819    #[test]
820    fn cmd_install_no_install_targets() {
821        let dir = tempfile::tempdir().unwrap();
822        std::fs::write(
823            dir.path().join("Skillfile"),
824            "local  skill  foo  skills/foo.md\n",
825        )
826        .unwrap();
827
828        let result = cmd_install(dir.path(), false, false);
829        assert!(result.is_err());
830        assert!(result
831            .unwrap_err()
832            .to_string()
833            .contains("No install targets"));
834    }
835
836    #[test]
837    fn cmd_install_dry_run_no_files() {
838        let dir = tempfile::tempdir().unwrap();
839        std::fs::write(
840            dir.path().join("Skillfile"),
841            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
842        )
843        .unwrap();
844        let source_file = dir.path().join("skills/foo.md");
845        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
846        std::fs::write(&source_file, "# Foo").unwrap();
847
848        cmd_install(dir.path(), true, false).unwrap();
849
850        assert!(!dir.path().join(".claude").exists());
851    }
852
853    #[test]
854    fn cmd_install_deploys_to_multiple_adapters() {
855        let dir = tempfile::tempdir().unwrap();
856        std::fs::write(
857            dir.path().join("Skillfile"),
858            "install  claude-code  local\n\
859             install  gemini-cli  local\n\
860             install  codex  local\n\
861             local  skill  foo  skills/foo.md\n\
862             local  agent  bar  agents/bar.md\n",
863        )
864        .unwrap();
865        std::fs::create_dir_all(dir.path().join("skills")).unwrap();
866        std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
867        std::fs::create_dir_all(dir.path().join("agents")).unwrap();
868        std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
869
870        cmd_install(dir.path(), false, false).unwrap();
871
872        // skill deployed to all three adapters
873        assert!(dir.path().join(".claude/skills/foo.md").exists());
874        assert!(dir.path().join(".gemini/skills/foo.md").exists());
875        assert!(dir.path().join(".codex/skills/foo.md").exists());
876
877        // agent deployed to claude-code and gemini-cli but NOT codex
878        assert!(dir.path().join(".claude/agents/bar.md").exists());
879        assert!(dir.path().join(".gemini/agents/bar.md").exists());
880        assert!(!dir.path().join(".codex/agents").exists());
881    }
882
883    #[test]
884    fn cmd_install_pending_conflict_blocks() {
885        use skillfile_core::conflict::write_conflict;
886        use skillfile_core::models::ConflictState;
887
888        let dir = tempfile::tempdir().unwrap();
889        std::fs::write(
890            dir.path().join("Skillfile"),
891            "install  claude-code  local\nlocal  skill  foo  skills/foo.md\n",
892        )
893        .unwrap();
894
895        write_conflict(
896            dir.path(),
897            &ConflictState {
898                entry: "foo".into(),
899                entity_type: "skill".into(),
900                old_sha: "aaa".into(),
901                new_sha: "bbb".into(),
902            },
903        )
904        .unwrap();
905
906        let result = cmd_install(dir.path(), false, false);
907        assert!(result.is_err());
908        assert!(result.unwrap_err().to_string().contains("pending conflict"));
909    }
910
911    // -----------------------------------------------------------------------
912    // Helpers shared by the new tests below
913    // -----------------------------------------------------------------------
914
915    /// Build a single-file github skill Entry.
916    fn make_skill_entry(name: &str) -> Entry {
917        Entry {
918            entity_type: EntityType::Skill,
919            name: name.into(),
920            source: SourceFields::Github {
921                owner_repo: "owner/repo".into(),
922                path_in_repo: format!("skills/{name}.md"),
923                ref_: "main".into(),
924            },
925        }
926    }
927
928    /// Build a directory github skill Entry (path_in_repo has no `.md` suffix).
929    fn make_dir_skill_entry(name: &str) -> Entry {
930        Entry {
931            entity_type: EntityType::Skill,
932            name: name.into(),
933            source: SourceFields::Github {
934                owner_repo: "owner/repo".into(),
935                path_in_repo: format!("skills/{name}"),
936                ref_: "main".into(),
937            },
938        }
939    }
940
941    /// Write a minimal Skillfile + Skillfile.lock for a single single-file github skill.
942    fn setup_github_skill_repo(dir: &std::path::Path, name: &str, cache_content: &str) {
943        use skillfile_core::lock::write_lock;
944        use skillfile_core::models::LockEntry;
945        use std::collections::BTreeMap;
946
947        // Manifest
948        std::fs::write(
949            dir.join("Skillfile"),
950            format!("install  claude-code  local\ngithub  skill  {name}  owner/repo  skills/{name}.md\n"),
951        )
952        .unwrap();
953
954        // Lock file — use write_lock so we don't need serde_json directly.
955        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
956        locked.insert(
957            format!("github/skill/{name}"),
958            LockEntry {
959                sha: "abc123def456abc123def456abc123def456abc123".into(),
960                raw_url: format!(
961                    "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
962                ),
963            },
964        );
965        write_lock(dir, &locked).unwrap();
966
967        // Vendor cache
968        let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
969        std::fs::create_dir_all(&vdir).unwrap();
970        std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
971    }
972
973    // -----------------------------------------------------------------------
974    // auto_pin_entry — single-file entry
975    // -----------------------------------------------------------------------
976
977    #[test]
978    fn auto_pin_entry_local_is_skipped() {
979        let dir = tempfile::tempdir().unwrap();
980
981        // Local entry: auto_pin should be a no-op.
982        let entry = make_local_entry("my-skill", "skills/my-skill.md");
983        let manifest = Manifest {
984            entries: vec![entry.clone()],
985            install_targets: vec![make_target("claude-code", Scope::Local)],
986        };
987
988        // Provide installed file that differs from source — pin should NOT fire.
989        let skills_dir = dir.path().join("skills");
990        std::fs::create_dir_all(&skills_dir).unwrap();
991        std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
992
993        auto_pin_entry(&entry, &manifest, dir.path());
994
995        // No patch must have been written.
996        assert!(
997            !skillfile_core::patch::has_patch(&entry, dir.path()),
998            "local entry must never be pinned"
999        );
1000    }
1001
1002    #[test]
1003    fn auto_pin_entry_missing_lock_is_skipped() {
1004        let dir = tempfile::tempdir().unwrap();
1005
1006        let entry = make_skill_entry("test");
1007        let manifest = Manifest {
1008            entries: vec![entry.clone()],
1009            install_targets: vec![make_target("claude-code", Scope::Local)],
1010        };
1011
1012        // No Skillfile.lock — should silently return without panicking.
1013        auto_pin_entry(&entry, &manifest, dir.path());
1014
1015        assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1016    }
1017
1018    #[test]
1019    fn auto_pin_entry_missing_lock_key_is_skipped() {
1020        use skillfile_core::lock::write_lock;
1021        use skillfile_core::models::LockEntry;
1022        use std::collections::BTreeMap;
1023
1024        let dir = tempfile::tempdir().unwrap();
1025
1026        // Lock exists but for a different entry.
1027        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1028        locked.insert(
1029            "github/skill/other".into(),
1030            LockEntry {
1031                sha: "aabbcc".into(),
1032                raw_url: "https://example.com/other.md".into(),
1033            },
1034        );
1035        write_lock(dir.path(), &locked).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        auto_pin_entry(&entry, &manifest, dir.path());
1044
1045        assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1046    }
1047
1048    #[test]
1049    fn auto_pin_entry_writes_patch_when_installed_differs() {
1050        let dir = tempfile::tempdir().unwrap();
1051        let name = "my-skill";
1052
1053        let cache_content = "# My Skill\n\nOriginal content.\n";
1054        let installed_content = "# My Skill\n\nUser-modified content.\n";
1055
1056        setup_github_skill_repo(dir.path(), name, cache_content);
1057
1058        // Place a modified installed file.
1059        let installed_dir = dir.path().join(".claude/skills");
1060        std::fs::create_dir_all(&installed_dir).unwrap();
1061        std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1062
1063        let entry = make_skill_entry(name);
1064        let manifest = Manifest {
1065            entries: vec![entry.clone()],
1066            install_targets: vec![make_target("claude-code", Scope::Local)],
1067        };
1068
1069        auto_pin_entry(&entry, &manifest, dir.path());
1070
1071        assert!(
1072            skillfile_core::patch::has_patch(&entry, dir.path()),
1073            "patch should be written when installed differs from cache"
1074        );
1075
1076        // The stored patch should round-trip: applying it to cache gives installed.
1077        let patch_text = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1078        let result = skillfile_core::patch::apply_patch_pure(cache_content, &patch_text).unwrap();
1079        assert_eq!(result, installed_content);
1080    }
1081
1082    #[test]
1083    fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1084        let dir = tempfile::tempdir().unwrap();
1085        let name = "my-skill";
1086
1087        let cache_content = "# My Skill\n\nOriginal.\n";
1088        let installed_content = "# My Skill\n\nModified.\n";
1089
1090        setup_github_skill_repo(dir.path(), name, cache_content);
1091
1092        let entry = make_skill_entry(name);
1093        let manifest = Manifest {
1094            entries: vec![entry.clone()],
1095            install_targets: vec![make_target("claude-code", Scope::Local)],
1096        };
1097
1098        // Pre-write the correct patch (cache → installed).
1099        let patch_text = skillfile_core::patch::generate_patch(
1100            cache_content,
1101            installed_content,
1102            &format!("{name}.md"),
1103        );
1104        skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1105
1106        // Write installed file that matches what the patch produces.
1107        let installed_dir = dir.path().join(".claude/skills");
1108        std::fs::create_dir_all(&installed_dir).unwrap();
1109        std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1110
1111        // Record mtime of patch so we can detect if it changed.
1112        let patch_path = skillfile_core::patch::patch_path(&entry, dir.path());
1113        let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1114
1115        // Small sleep so that any write would produce a different mtime.
1116        std::thread::sleep(std::time::Duration::from_millis(20));
1117
1118        auto_pin_entry(&entry, &manifest, dir.path());
1119
1120        let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1121
1122        assert_eq!(
1123            mtime_before, mtime_after,
1124            "patch must not be rewritten when already up to date"
1125        );
1126    }
1127
1128    #[test]
1129    fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1130        let dir = tempfile::tempdir().unwrap();
1131        let name = "my-skill";
1132
1133        let cache_content = "# My Skill\n\nOriginal.\n";
1134        let old_installed = "# My Skill\n\nFirst edit.\n";
1135        let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1136
1137        setup_github_skill_repo(dir.path(), name, cache_content);
1138
1139        let entry = make_skill_entry(name);
1140        let manifest = Manifest {
1141            entries: vec![entry.clone()],
1142            install_targets: vec![make_target("claude-code", Scope::Local)],
1143        };
1144
1145        // Stored patch reflects the old installed state.
1146        let old_patch = skillfile_core::patch::generate_patch(
1147            cache_content,
1148            old_installed,
1149            &format!("{name}.md"),
1150        );
1151        skillfile_core::patch::write_patch(&entry, &old_patch, dir.path()).unwrap();
1152
1153        // But the actual installed file has further edits.
1154        let installed_dir = dir.path().join(".claude/skills");
1155        std::fs::create_dir_all(&installed_dir).unwrap();
1156        std::fs::write(installed_dir.join(format!("{name}.md")), new_installed).unwrap();
1157
1158        auto_pin_entry(&entry, &manifest, dir.path());
1159
1160        // The patch should now reflect the new installed content.
1161        let new_patch = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1162        let result = skillfile_core::patch::apply_patch_pure(cache_content, &new_patch).unwrap();
1163        assert_eq!(
1164            result, new_installed,
1165            "updated patch must describe the latest installed content"
1166        );
1167    }
1168
1169    // -----------------------------------------------------------------------
1170    // auto_pin_dir_entry
1171    // -----------------------------------------------------------------------
1172
1173    #[test]
1174    fn auto_pin_dir_entry_writes_per_file_patches() {
1175        use skillfile_core::lock::write_lock;
1176        use skillfile_core::models::LockEntry;
1177        use std::collections::BTreeMap;
1178
1179        let dir = tempfile::tempdir().unwrap();
1180        let name = "lang-pro";
1181
1182        // Manifest + lock (dir entry)
1183        std::fs::write(
1184            dir.path().join("Skillfile"),
1185            format!(
1186                "install  claude-code  local\ngithub  skill  {name}  owner/repo  skills/{name}\n"
1187            ),
1188        )
1189        .unwrap();
1190        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1191        locked.insert(
1192            format!("github/skill/{name}"),
1193            LockEntry {
1194                sha: "deadbeefdeadbeefdeadbeef".into(),
1195                raw_url: format!("https://example.com/{name}"),
1196            },
1197        );
1198        write_lock(dir.path(), &locked).unwrap();
1199
1200        // Vendor cache with two files.
1201        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1202        std::fs::create_dir_all(&vdir).unwrap();
1203        std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1204        std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1205
1206        // Installed dir (nested mode for skills).
1207        let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1208        std::fs::create_dir_all(&inst_dir).unwrap();
1209        std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1210        std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1211
1212        let entry = make_dir_skill_entry(name);
1213        let manifest = Manifest {
1214            entries: vec![entry.clone()],
1215            install_targets: vec![make_target("claude-code", Scope::Local)],
1216        };
1217
1218        auto_pin_entry(&entry, &manifest, dir.path());
1219
1220        // Patch for the modified file should exist.
1221        let skill_patch = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1222        assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1223
1224        // Patch for the unmodified file should NOT exist.
1225        let examples_patch =
1226            skillfile_core::patch::dir_patch_path(&entry, "examples.md", dir.path());
1227        assert!(
1228            !examples_patch.exists(),
1229            "patch for examples.md must not be written (content unchanged)"
1230        );
1231    }
1232
1233    #[test]
1234    fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1235        use skillfile_core::lock::write_lock;
1236        use skillfile_core::models::LockEntry;
1237        use std::collections::BTreeMap;
1238
1239        let dir = tempfile::tempdir().unwrap();
1240        let name = "lang-pro";
1241
1242        // Write lock so we don't bail out there.
1243        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1244        locked.insert(
1245            format!("github/skill/{name}"),
1246            LockEntry {
1247                sha: "abc".into(),
1248                raw_url: "https://example.com".into(),
1249            },
1250        );
1251        write_lock(dir.path(), &locked).unwrap();
1252
1253        let entry = make_dir_skill_entry(name);
1254        let manifest = Manifest {
1255            entries: vec![entry.clone()],
1256            install_targets: vec![make_target("claude-code", Scope::Local)],
1257        };
1258
1259        // No vendor dir — must silently return without panicking.
1260        auto_pin_entry(&entry, &manifest, dir.path());
1261
1262        assert!(!skillfile_core::patch::has_dir_patch(&entry, dir.path()));
1263    }
1264
1265    #[test]
1266    fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1267        use skillfile_core::lock::write_lock;
1268        use skillfile_core::models::LockEntry;
1269        use std::collections::BTreeMap;
1270
1271        let dir = tempfile::tempdir().unwrap();
1272        let name = "lang-pro";
1273
1274        let cache_content = "# Lang Pro\n\nOriginal.\n";
1275        let modified = "# Lang Pro\n\nModified.\n";
1276
1277        // Write lock.
1278        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1279        locked.insert(
1280            format!("github/skill/{name}"),
1281            LockEntry {
1282                sha: "abc".into(),
1283                raw_url: "https://example.com".into(),
1284            },
1285        );
1286        write_lock(dir.path(), &locked).unwrap();
1287
1288        // Vendor cache.
1289        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1290        std::fs::create_dir_all(&vdir).unwrap();
1291        std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1292
1293        // Installed dir.
1294        let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1295        std::fs::create_dir_all(&inst_dir).unwrap();
1296        std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1297
1298        let entry = make_dir_skill_entry(name);
1299        let manifest = Manifest {
1300            entries: vec![entry.clone()],
1301            install_targets: vec![make_target("claude-code", Scope::Local)],
1302        };
1303
1304        // Pre-write the correct patch.
1305        let patch_text = skillfile_core::patch::generate_patch(cache_content, modified, "SKILL.md");
1306        skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1307            .unwrap();
1308
1309        let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1310        let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1311
1312        std::thread::sleep(std::time::Duration::from_millis(20));
1313
1314        auto_pin_entry(&entry, &manifest, dir.path());
1315
1316        let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1317
1318        assert_eq!(
1319            mtime_before, mtime_after,
1320            "dir patch must not be rewritten when already up to date"
1321        );
1322    }
1323
1324    // -----------------------------------------------------------------------
1325    // apply_dir_patches
1326    // -----------------------------------------------------------------------
1327
1328    #[test]
1329    fn apply_dir_patches_applies_patch_and_rebases() {
1330        let dir = tempfile::tempdir().unwrap();
1331
1332        // Old upstream → user's installed version (what the stored patch records).
1333        let cache_content = "# Skill\n\nOriginal.\n";
1334        let installed_content = "# Skill\n\nModified.\n";
1335        // New upstream has a different body line but same structure.
1336        let new_cache_content = "# Skill\n\nOriginal v2.\n";
1337        // After rebase, the rebased patch encodes the diff from new_cache to installed.
1338        // Applying that rebased patch to new_cache must yield installed_content.
1339        let expected_rebased_to_new_cache = installed_content;
1340
1341        let entry = make_dir_skill_entry("lang-pro");
1342
1343        // Create patch dir with a valid patch (old cache → installed).
1344        let patch_text =
1345            skillfile_core::patch::generate_patch(cache_content, installed_content, "SKILL.md");
1346        skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1347            .unwrap();
1348
1349        // Installed file starts at cache content (patch not yet applied).
1350        let inst_dir = dir.path().join(".claude/skills/lang-pro");
1351        std::fs::create_dir_all(&inst_dir).unwrap();
1352        std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1353
1354        // New cache (simulates upstream update).
1355        let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1356        std::fs::create_dir_all(&new_cache_dir).unwrap();
1357        std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1358
1359        // Build the installed_files map as deploy_all would.
1360        let mut installed_files = std::collections::HashMap::new();
1361        installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1362
1363        apply_dir_patches(&entry, &installed_files, &new_cache_dir, dir.path()).unwrap();
1364
1365        // The installed file should have the original patch applied.
1366        let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1367        assert_eq!(installed_after, installed_content);
1368
1369        // The stored patch must now describe the diff from new_cache to installed_content.
1370        // Applying the rebased patch to new_cache must reproduce installed_content.
1371        let rebased_patch = std::fs::read_to_string(skillfile_core::patch::dir_patch_path(
1372            &entry,
1373            "SKILL.md",
1374            dir.path(),
1375        ))
1376        .unwrap();
1377        let rebase_result =
1378            skillfile_core::patch::apply_patch_pure(new_cache_content, &rebased_patch).unwrap();
1379        assert_eq!(
1380            rebase_result, expected_rebased_to_new_cache,
1381            "rebased patch applied to new_cache must reproduce installed_content"
1382        );
1383    }
1384
1385    #[test]
1386    fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1387        let dir = tempfile::tempdir().unwrap();
1388
1389        // The "new" cache content IS the patched content — patch becomes a no-op.
1390        let original = "# Skill\n\nOriginal.\n";
1391        let modified = "# Skill\n\nModified.\n";
1392        // New upstream == modified, so after applying patch the result equals new cache.
1393        let new_cache = modified; // upstream caught up
1394
1395        let entry = make_dir_skill_entry("lang-pro");
1396
1397        let patch_text = skillfile_core::patch::generate_patch(original, modified, "SKILL.md");
1398        skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1399            .unwrap();
1400
1401        // Installed file starts at original (patch not yet applied).
1402        let inst_dir = dir.path().join(".claude/skills/lang-pro");
1403        std::fs::create_dir_all(&inst_dir).unwrap();
1404        std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1405
1406        let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1407        std::fs::create_dir_all(&new_cache_dir).unwrap();
1408        std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1409
1410        let mut installed_files = std::collections::HashMap::new();
1411        installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1412
1413        apply_dir_patches(&entry, &installed_files, &new_cache_dir, dir.path()).unwrap();
1414
1415        // Patch file must be removed (rebase produced empty diff).
1416        let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1417        assert!(
1418            !patch_path.exists(),
1419            "patch file must be removed when rebase yields empty diff"
1420        );
1421    }
1422
1423    #[test]
1424    fn apply_dir_patches_no_op_when_no_patches_dir() {
1425        let dir = tempfile::tempdir().unwrap();
1426
1427        // No patches directory at all.
1428        let entry = make_dir_skill_entry("lang-pro");
1429        let installed_files = std::collections::HashMap::new();
1430        let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1431        std::fs::create_dir_all(&source_dir).unwrap();
1432
1433        // Must succeed without error.
1434        apply_dir_patches(&entry, &installed_files, &source_dir, dir.path()).unwrap();
1435    }
1436
1437    // -----------------------------------------------------------------------
1438    // apply_single_file_patch — rebase removes patch when result equals cache
1439    // -----------------------------------------------------------------------
1440
1441    #[test]
1442    fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1443        let dir = tempfile::tempdir().unwrap();
1444
1445        let original = "# Skill\n\nOriginal.\n";
1446        let modified = "# Skill\n\nModified.\n";
1447        // New cache == modified: after rebase, new_patch is empty → patch removed.
1448        let new_cache = modified;
1449
1450        let entry = make_skill_entry("test");
1451
1452        // Write patch.
1453        let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1454        skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1455
1456        // Set up vendor cache (the "new" version).
1457        let vdir = dir.path().join(".skillfile/cache/skills/test");
1458        std::fs::create_dir_all(&vdir).unwrap();
1459        let source = vdir.join("test.md");
1460        std::fs::write(&source, new_cache).unwrap();
1461
1462        // Installed file is the original (patch not yet applied).
1463        let installed_dir = dir.path().join(".claude/skills");
1464        std::fs::create_dir_all(&installed_dir).unwrap();
1465        let dest = installed_dir.join("test.md");
1466        std::fs::write(&dest, original).unwrap();
1467
1468        apply_single_file_patch(&entry, &dest, &source, dir.path()).unwrap();
1469
1470        // The installed file must be the patched (== new cache) result.
1471        assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1472
1473        // Patch file must have been removed.
1474        assert!(
1475            !skillfile_core::patch::has_patch(&entry, dir.path()),
1476            "patch must be removed when new cache already matches patched content"
1477        );
1478    }
1479
1480    #[test]
1481    fn apply_single_file_patch_rewrites_patch_after_rebase() {
1482        let dir = tempfile::tempdir().unwrap();
1483
1484        // Old upstream, user edit, new upstream (different body — no overlap with user edit).
1485        let original = "# Skill\n\nOriginal.\n";
1486        let modified = "# Skill\n\nModified.\n";
1487        let new_cache = "# Skill\n\nOriginal v2.\n";
1488        // The rebase stores generate_patch(new_cache, modified).
1489        // Applying that to new_cache must reproduce `modified`.
1490        let expected_rebased_result = modified;
1491
1492        let entry = make_skill_entry("test");
1493
1494        let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1495        skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1496
1497        // New vendor cache (upstream updated).
1498        let vdir = dir.path().join(".skillfile/cache/skills/test");
1499        std::fs::create_dir_all(&vdir).unwrap();
1500        let source = vdir.join("test.md");
1501        std::fs::write(&source, new_cache).unwrap();
1502
1503        // Installed still at original content (patch not applied yet).
1504        let installed_dir = dir.path().join(".claude/skills");
1505        std::fs::create_dir_all(&installed_dir).unwrap();
1506        let dest = installed_dir.join("test.md");
1507        std::fs::write(&dest, original).unwrap();
1508
1509        apply_single_file_patch(&entry, &dest, &source, dir.path()).unwrap();
1510
1511        // Installed must now be the patched content.
1512        assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1513
1514        // The patch is rebased: generate_patch(new_cache, modified).
1515        // Applying the rebased patch to new_cache must reproduce modified.
1516        assert!(
1517            skillfile_core::patch::has_patch(&entry, dir.path()),
1518            "rebased patch must still exist (new_cache != modified)"
1519        );
1520        let rebased = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1521        let result = skillfile_core::patch::apply_patch_pure(new_cache, &rebased).unwrap();
1522        assert_eq!(
1523            result, expected_rebased_result,
1524            "rebased patch applied to new_cache must reproduce installed content"
1525        );
1526    }
1527
1528    // -----------------------------------------------------------------------
1529    // check_preconditions
1530    // -----------------------------------------------------------------------
1531
1532    #[test]
1533    fn check_preconditions_no_targets_returns_error() {
1534        let dir = tempfile::tempdir().unwrap();
1535        let manifest = Manifest {
1536            entries: vec![],
1537            install_targets: vec![],
1538        };
1539        let result = check_preconditions(&manifest, dir.path());
1540        assert!(result.is_err());
1541        assert!(result
1542            .unwrap_err()
1543            .to_string()
1544            .contains("No install targets"));
1545    }
1546
1547    #[test]
1548    fn check_preconditions_pending_conflict_returns_error() {
1549        use skillfile_core::conflict::write_conflict;
1550        use skillfile_core::models::ConflictState;
1551
1552        let dir = tempfile::tempdir().unwrap();
1553        let manifest = Manifest {
1554            entries: vec![],
1555            install_targets: vec![make_target("claude-code", Scope::Local)],
1556        };
1557
1558        write_conflict(
1559            dir.path(),
1560            &ConflictState {
1561                entry: "my-skill".into(),
1562                entity_type: "skill".into(),
1563                old_sha: "aaa".into(),
1564                new_sha: "bbb".into(),
1565            },
1566        )
1567        .unwrap();
1568
1569        let result = check_preconditions(&manifest, dir.path());
1570        assert!(result.is_err());
1571        assert!(result.unwrap_err().to_string().contains("pending conflict"));
1572    }
1573
1574    #[test]
1575    fn check_preconditions_ok_with_target_and_no_conflict() {
1576        let dir = tempfile::tempdir().unwrap();
1577        let manifest = Manifest {
1578            entries: vec![],
1579            install_targets: vec![make_target("claude-code", Scope::Local)],
1580        };
1581        check_preconditions(&manifest, dir.path()).unwrap();
1582    }
1583
1584    // -----------------------------------------------------------------------
1585    // deploy_all — PatchConflict writes conflict state and returns Install error
1586    // -----------------------------------------------------------------------
1587
1588    #[test]
1589    fn deploy_all_patch_conflict_writes_conflict_state() {
1590        use skillfile_core::conflict::{has_conflict, read_conflict};
1591        use skillfile_core::lock::write_lock;
1592        use skillfile_core::models::LockEntry;
1593        use std::collections::BTreeMap;
1594
1595        let dir = tempfile::tempdir().unwrap();
1596        let name = "test";
1597
1598        // Vendor cache: content that cannot match the stored patch.
1599        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1600        std::fs::create_dir_all(&vdir).unwrap();
1601        std::fs::write(
1602            vdir.join(format!("{name}.md")),
1603            "totally different content\n",
1604        )
1605        .unwrap();
1606
1607        // Write a patch that expects lines which don't exist.
1608        let entry = make_skill_entry(name);
1609        let bad_patch =
1610            "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-expected_original_line\n+modified\n";
1611        skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1612
1613        // Pre-create installed file.
1614        let inst_dir = dir.path().join(".claude/skills");
1615        std::fs::create_dir_all(&inst_dir).unwrap();
1616        std::fs::write(
1617            inst_dir.join(format!("{name}.md")),
1618            "totally different content\n",
1619        )
1620        .unwrap();
1621
1622        // Manifest.
1623        let manifest = Manifest {
1624            entries: vec![entry.clone()],
1625            install_targets: vec![make_target("claude-code", Scope::Local)],
1626        };
1627
1628        // Lock maps — old and new have different SHAs for SHA context in error.
1629        let lock_key_str = format!("github/skill/{name}");
1630        let old_sha = "a".repeat(40);
1631        let new_sha = "b".repeat(40);
1632
1633        let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1634        old_locked.insert(
1635            lock_key_str.clone(),
1636            LockEntry {
1637                sha: old_sha.clone(),
1638                raw_url: "https://example.com/old.md".into(),
1639            },
1640        );
1641
1642        let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1643        new_locked.insert(
1644            lock_key_str,
1645            LockEntry {
1646                sha: new_sha.clone(),
1647                raw_url: "https://example.com/new.md".into(),
1648            },
1649        );
1650
1651        write_lock(dir.path(), &new_locked).unwrap();
1652
1653        let opts = InstallOptions {
1654            dry_run: false,
1655            overwrite: true,
1656        };
1657
1658        let result = deploy_all(&manifest, dir.path(), &opts, &new_locked, &old_locked);
1659
1660        // Must return an error.
1661        assert!(
1662            result.is_err(),
1663            "deploy_all must return Err on PatchConflict"
1664        );
1665        let err_msg = result.unwrap_err().to_string();
1666        assert!(
1667            err_msg.contains("conflict"),
1668            "error message must mention conflict: {err_msg}"
1669        );
1670
1671        // Conflict state file must have been written.
1672        assert!(
1673            has_conflict(dir.path()),
1674            "conflict state file must be written after PatchConflict"
1675        );
1676
1677        let conflict = read_conflict(dir.path()).unwrap().unwrap();
1678        assert_eq!(conflict.entry, name);
1679        assert_eq!(conflict.old_sha, old_sha);
1680        assert_eq!(conflict.new_sha, new_sha);
1681    }
1682
1683    #[test]
1684    fn deploy_all_patch_conflict_error_message_contains_sha_context() {
1685        use skillfile_core::lock::write_lock;
1686        use skillfile_core::models::LockEntry;
1687        use std::collections::BTreeMap;
1688
1689        let dir = tempfile::tempdir().unwrap();
1690        let name = "test";
1691
1692        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1693        std::fs::create_dir_all(&vdir).unwrap();
1694        std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
1695
1696        let entry = make_skill_entry(name);
1697        let bad_patch =
1698            "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
1699        skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1700
1701        let inst_dir = dir.path().join(".claude/skills");
1702        std::fs::create_dir_all(&inst_dir).unwrap();
1703        std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
1704
1705        let manifest = Manifest {
1706            entries: vec![entry.clone()],
1707            install_targets: vec![make_target("claude-code", Scope::Local)],
1708        };
1709
1710        let lock_key_str = format!("github/skill/{name}");
1711        let old_sha = "aabbccddeeff001122334455aabbccddeeff0011".to_string();
1712        let new_sha = "99887766554433221100ffeeddccbbaa99887766".to_string();
1713
1714        let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1715        old_locked.insert(
1716            lock_key_str.clone(),
1717            LockEntry {
1718                sha: old_sha.clone(),
1719                raw_url: "https://example.com/old.md".into(),
1720            },
1721        );
1722
1723        let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1724        new_locked.insert(
1725            lock_key_str,
1726            LockEntry {
1727                sha: new_sha.clone(),
1728                raw_url: "https://example.com/new.md".into(),
1729            },
1730        );
1731
1732        write_lock(dir.path(), &new_locked).unwrap();
1733
1734        let opts = InstallOptions {
1735            dry_run: false,
1736            overwrite: true,
1737        };
1738
1739        let result = deploy_all(&manifest, dir.path(), &opts, &new_locked, &old_locked);
1740        assert!(result.is_err());
1741
1742        let err_msg = result.unwrap_err().to_string();
1743
1744        // The error must include the short-SHA arrow notation.
1745        assert!(
1746            err_msg.contains('\u{2192}'),
1747            "error message must contain the SHA arrow (→): {err_msg}"
1748        );
1749        // Must contain truncated SHAs.
1750        assert!(
1751            err_msg.contains(&old_sha[..12]),
1752            "error must contain old SHA prefix: {err_msg}"
1753        );
1754        assert!(
1755            err_msg.contains(&new_sha[..12]),
1756            "error must contain new SHA prefix: {err_msg}"
1757        );
1758    }
1759
1760    #[test]
1761    fn deploy_all_patch_conflict_error_message_has_resolve_hints() {
1762        use skillfile_core::lock::write_lock;
1763        use skillfile_core::models::LockEntry;
1764        use std::collections::BTreeMap;
1765
1766        let dir = tempfile::tempdir().unwrap();
1767        let name = "test";
1768
1769        let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1770        std::fs::create_dir_all(&vdir).unwrap();
1771        std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
1772
1773        let entry = make_skill_entry(name);
1774        let bad_patch =
1775            "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
1776        skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1777
1778        let inst_dir = dir.path().join(".claude/skills");
1779        std::fs::create_dir_all(&inst_dir).unwrap();
1780        std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
1781
1782        let manifest = Manifest {
1783            entries: vec![entry.clone()],
1784            install_targets: vec![make_target("claude-code", Scope::Local)],
1785        };
1786
1787        let lock_key_str = format!("github/skill/{name}");
1788        let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1789        locked.insert(
1790            lock_key_str,
1791            LockEntry {
1792                sha: "abc123".into(),
1793                raw_url: "https://example.com/test.md".into(),
1794            },
1795        );
1796        write_lock(dir.path(), &locked).unwrap();
1797
1798        let opts = InstallOptions {
1799            dry_run: false,
1800            overwrite: true,
1801        };
1802
1803        let result = deploy_all(
1804            &manifest,
1805            dir.path(),
1806            &opts,
1807            &locked,
1808            &BTreeMap::new(), // no old lock
1809        );
1810        assert!(result.is_err());
1811
1812        let err_msg = result.unwrap_err().to_string();
1813        assert!(
1814            err_msg.contains("skillfile resolve"),
1815            "error must mention resolve command: {err_msg}"
1816        );
1817        assert!(
1818            err_msg.contains("skillfile diff"),
1819            "error must mention diff command: {err_msg}"
1820        );
1821        assert!(
1822            err_msg.contains("--abort"),
1823            "error must mention --abort: {err_msg}"
1824        );
1825    }
1826
1827    #[test]
1828    fn deploy_all_unknown_platform_skips_gracefully() {
1829        use std::collections::BTreeMap;
1830
1831        let dir = tempfile::tempdir().unwrap();
1832
1833        // Manifest with an unknown adapter.
1834        let manifest = Manifest {
1835            entries: vec![],
1836            install_targets: vec![InstallTarget {
1837                adapter: "unknown-tool".into(),
1838                scope: Scope::Local,
1839            }],
1840        };
1841
1842        let opts = InstallOptions {
1843            dry_run: false,
1844            overwrite: true,
1845        };
1846
1847        // Must succeed even with unknown adapter (just warns).
1848        deploy_all(
1849            &manifest,
1850            dir.path(),
1851            &opts,
1852            &BTreeMap::new(),
1853            &BTreeMap::new(),
1854        )
1855        .unwrap();
1856    }
1857}