Skip to main content

skillfile_deploy/
install.rs

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