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, DeployRequest};
20use crate::paths::{installed_dir_files, installed_path, source_path};
21
22fn 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
34struct PatchCtx<'a> {
36 entry: &'a Entry,
37 repo_root: &'a Path,
38}
39
40fn rebase_single_patch(
43 ctx: &PatchCtx<'_>,
44 source: &Path,
45 patched: &str,
46) -> Result<(), SkillfileError> {
47 let cache_text = std::fs::read_to_string(source)?;
48 let new_patch = generate_patch(&cache_text, patched, &format!("{}.md", ctx.entry.name));
49 if new_patch.is_empty() {
50 remove_patch(ctx.entry, ctx.repo_root)?;
51 } else {
52 write_patch(ctx.entry, &new_patch, ctx.repo_root)?;
53 }
54 Ok(())
55}
56
57fn apply_single_file_patch(
60 ctx: &PatchCtx<'_>,
61 dest: &Path,
62 source: &Path,
63) -> Result<(), SkillfileError> {
64 if !has_patch(ctx.entry, ctx.repo_root) {
65 return Ok(());
66 }
67 let patch_text = read_patch(ctx.entry, ctx.repo_root)?;
68 let original = std::fs::read_to_string(dest)?;
69 let patched = apply_patch_pure(&original, &patch_text)
70 .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
71 std::fs::write(dest, &patched)?;
72
73 rebase_single_patch(ctx, source, &patched)
75}
76
77fn apply_dir_patches(
80 ctx: &PatchCtx<'_>,
81 installed_files: &HashMap<String, PathBuf>,
82 source_dir: &Path,
83) -> Result<(), SkillfileError> {
84 let patches_dir = patches_root(ctx.repo_root)
85 .join(ctx.entry.entity_type.dir_name())
86 .join(&ctx.entry.name);
87 if !patches_dir.is_dir() {
88 return Ok(());
89 }
90
91 let patch_files: Vec<PathBuf> = walkdir(&patches_dir)
92 .into_iter()
93 .filter(|p| p.extension().is_some_and(|e| e == "patch"))
94 .collect();
95
96 for patch_file in patch_files {
97 let Some(rel) = patch_file
98 .strip_prefix(&patches_dir)
99 .ok()
100 .and_then(|p| p.to_str())
101 .and_then(|s| s.strip_suffix(".patch"))
102 .map(str::to_string)
103 else {
104 continue;
105 };
106
107 let Some(target) = installed_files.get(&rel).filter(|p| p.exists()) else {
108 continue;
109 };
110
111 let patch_text = std::fs::read_to_string(&patch_file)?;
112 let original = std::fs::read_to_string(target)?;
113 let patched = apply_patch_pure(&original, &patch_text)
114 .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
115 std::fs::write(target, &patched)?;
116
117 let cache_file = source_dir.join(&rel);
119 if !cache_file.exists() {
120 continue;
121 }
122 let cache_text = std::fs::read_to_string(&cache_file)?;
123 let new_patch = generate_patch(&cache_text, &patched, &rel);
124 if new_patch.is_empty() {
125 std::fs::remove_file(&patch_file)?;
126 } else {
127 write_dir_patch(&dir_patch_path(ctx.entry, &rel, ctx.repo_root), &new_patch)?;
128 }
129 }
130 Ok(())
131}
132
133fn patch_already_covers(patch_text: &str, cache_text: &str, installed_text: &str) -> bool {
144 match apply_patch_pure(cache_text, patch_text) {
145 Ok(expected) if installed_text == expected => true, Err(_) => true, Ok(_) => false, }
149}
150
151fn should_skip_pin(ctx: &PatchCtx<'_>, cache_text: &str, installed_text: &str) -> bool {
154 if !has_patch(ctx.entry, ctx.repo_root) {
155 return false;
156 }
157 let Ok(pt) = read_patch(ctx.entry, ctx.repo_root) else {
158 return false;
159 };
160 patch_already_covers(&pt, cache_text, installed_text)
161}
162
163fn auto_pin_entry(entry: &Entry, manifest: &Manifest, repo_root: &Path) {
165 if entry.source_type() == "local" {
166 return;
167 }
168
169 let Ok(locked) = read_lock(repo_root) else {
170 return;
171 };
172 let key = lock_key(entry);
173 if !locked.contains_key(&key) {
174 return;
175 }
176
177 let vdir = vendor_dir_for(entry, repo_root);
178
179 if is_dir_entry(entry) {
180 auto_pin_dir_entry(entry, manifest, repo_root);
181 return;
182 }
183
184 let cf = content_file(entry);
185 if cf.is_empty() {
186 return;
187 }
188 let cache_file = vdir.join(&cf);
189 if !cache_file.exists() {
190 return;
191 }
192
193 let Ok(dest) = installed_path(entry, manifest, repo_root) else {
194 return;
195 };
196 if !dest.exists() {
197 return;
198 }
199
200 let Ok(cache_text) = std::fs::read_to_string(&cache_file) else {
201 return;
202 };
203 let Ok(installed_text) = std::fs::read_to_string(&dest) else {
204 return;
205 };
206
207 let ctx = PatchCtx { entry, repo_root };
209 if should_skip_pin(&ctx, &cache_text, &installed_text) {
210 return;
211 }
212
213 let patch_text = generate_patch(&cache_text, &installed_text, &format!("{}.md", entry.name));
214 if !patch_text.is_empty() && write_patch(entry, &patch_text, repo_root).is_ok() {
215 progress!(
216 " {}: local changes auto-saved to .skillfile/patches/",
217 entry.name
218 );
219 }
220}
221
222struct AutoPinCtx<'a> {
226 vdir: &'a Path,
227 entry: &'a Entry,
228 installed: &'a HashMap<String, PathBuf>,
229 repo_root: &'a Path,
230}
231
232fn dir_patch_already_matches(patch_path: &Path, cache_text: &str, installed_text: &str) -> bool {
235 if !patch_path.exists() {
236 return false;
237 }
238 let Ok(pt) = std::fs::read_to_string(patch_path) else {
239 return false;
240 };
241 patch_already_covers(&pt, cache_text, installed_text)
242}
243
244fn try_auto_pin_file(cache_file: &Path, ctx: &AutoPinCtx<'_>) -> Option<String> {
245 if cache_file.file_name().is_some_and(|n| n == ".meta") {
246 return None;
247 }
248 let filename = cache_file
249 .strip_prefix(ctx.vdir)
250 .ok()?
251 .to_str()?
252 .to_string();
253 let inst_path = match ctx.installed.get(&filename) {
254 Some(p) if p.exists() => p,
255 _ => return None,
256 };
257
258 let cache_text = std::fs::read_to_string(cache_file).ok()?;
259 let installed_text = std::fs::read_to_string(inst_path).ok()?;
260
261 let p = dir_patch_path(ctx.entry, &filename, ctx.repo_root);
263 if dir_patch_already_matches(&p, &cache_text, &installed_text) {
264 return None;
265 }
266
267 let patch_text = generate_patch(&cache_text, &installed_text, &filename);
268 if !patch_text.is_empty()
269 && write_dir_patch(
270 &dir_patch_path(ctx.entry, &filename, ctx.repo_root),
271 &patch_text,
272 )
273 .is_ok()
274 {
275 Some(filename)
276 } else {
277 None
278 }
279}
280
281fn auto_pin_dir_entry(entry: &Entry, manifest: &Manifest, repo_root: &Path) {
283 let vdir = &vendor_dir_for(entry, repo_root);
284 if !vdir.is_dir() {
285 return;
286 }
287 let Ok(installed) = installed_dir_files(entry, manifest, repo_root) else {
288 return;
289 };
290 if installed.is_empty() {
291 return;
292 }
293
294 let ctx = AutoPinCtx {
295 vdir,
296 entry,
297 installed: &installed,
298 repo_root,
299 };
300 let pinned: Vec<String> = walkdir(vdir)
301 .into_iter()
302 .filter_map(|f| try_auto_pin_file(&f, &ctx))
303 .collect();
304
305 if !pinned.is_empty() {
306 progress!(
307 " {}: local changes auto-saved to .skillfile/patches/ ({})",
308 entry.name,
309 pinned.join(", ")
310 );
311 }
312}
313
314pub struct InstallCtx<'a> {
320 pub repo_root: &'a Path,
321 pub opts: Option<&'a InstallOptions>,
322}
323
324pub fn install_entry(
332 entry: &Entry,
333 target: &InstallTarget,
334 ctx: &InstallCtx<'_>,
335) -> Result<(), SkillfileError> {
336 let default_opts = InstallOptions::default();
337 let opts = ctx.opts.unwrap_or(&default_opts);
338
339 let all_adapters = adapters();
340 let Some(adapter) = all_adapters.get(&target.adapter) else {
341 return Ok(());
342 };
343
344 if !adapter.supports(entry.entity_type) {
345 return Ok(());
346 }
347
348 let source = match source_path(entry, ctx.repo_root) {
349 Some(p) if p.exists() => p,
350 _ => {
351 eprintln!(" warning: source missing for {}, skipping", entry.name);
352 return Ok(());
353 }
354 };
355
356 let is_dir = is_dir_entry(entry) || source.is_dir();
357 let installed = adapter.deploy_entry(&DeployRequest {
358 entry,
359 source: &source,
360 scope: target.scope,
361 repo_root: ctx.repo_root,
362 opts,
363 });
364
365 if installed.is_empty() || opts.dry_run {
366 return Ok(());
367 }
368
369 let patch_ctx = PatchCtx {
370 entry,
371 repo_root: ctx.repo_root,
372 };
373 if is_dir {
374 apply_dir_patches(&patch_ctx, &installed, &source)?;
375 } else {
376 let key = format!("{}.md", entry.name);
377 if let Some(dest) = installed.get(&key) {
378 apply_single_file_patch(&patch_ctx, dest, &source)?;
379 }
380 }
381
382 Ok(())
383}
384
385fn check_preconditions(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
390 if manifest.install_targets.is_empty() {
391 return Err(SkillfileError::Manifest(
392 "No install targets configured. Run `skillfile init` first.".into(),
393 ));
394 }
395
396 if let Some(conflict) = read_conflict(repo_root)? {
397 return Err(SkillfileError::Install(format!(
398 "pending conflict for '{}' — \
399 run `skillfile diff {}` to review, \
400 or `skillfile resolve {}` to merge",
401 conflict.entry, conflict.entry, conflict.entry
402 )));
403 }
404
405 Ok(())
406}
407
408fn sha_transition_hint(old_sha: &str, new_sha: &str) -> String {
414 if !old_sha.is_empty() && !new_sha.is_empty() && old_sha != new_sha {
415 format!(
416 "\n upstream: {} \u{2192} {}",
417 short_sha(old_sha),
418 short_sha(new_sha)
419 )
420 } else {
421 String::new()
422 }
423}
424
425struct LockMaps<'a> {
427 locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
428 old_locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
429}
430
431struct DeployCtx<'a> {
433 repo_root: &'a Path,
434 opts: &'a InstallOptions,
435 maps: LockMaps<'a>,
436}
437
438fn handle_patch_conflict(
440 entry: &Entry,
441 entry_name: &str,
442 ctx: &DeployCtx<'_>,
443) -> Result<(), SkillfileError> {
444 let key = lock_key(entry);
445 let old_sha = ctx
446 .maps
447 .old_locked
448 .get(&key)
449 .map(|l| l.sha.clone())
450 .unwrap_or_default();
451 let new_sha = ctx
452 .maps
453 .locked
454 .get(&key)
455 .map_or_else(|| old_sha.clone(), |l| l.sha.clone());
456
457 write_conflict(
458 ctx.repo_root,
459 &ConflictState {
460 entry: entry_name.to_string(),
461 entity_type: entry.entity_type,
462 old_sha: old_sha.clone(),
463 new_sha: new_sha.clone(),
464 },
465 )?;
466
467 let sha_info = sha_transition_hint(&old_sha, &new_sha);
468 Err(SkillfileError::Install(format!(
469 "upstream changes to '{entry_name}' conflict with your customisations.{sha_info}\n\
470 Your pinned edits could not be applied to the new upstream version.\n\
471 Run `skillfile diff {entry_name}` to review what changed upstream.\n\
472 Run `skillfile resolve {entry_name}` when ready to merge.\n\
473 Run `skillfile resolve --abort` to discard the conflict and keep the old version."
474 )))
475}
476
477fn install_entry_or_conflict(
479 entry: &Entry,
480 target: &InstallTarget,
481 ctx: &DeployCtx<'_>,
482) -> Result<(), SkillfileError> {
483 let install_ctx = InstallCtx {
484 repo_root: ctx.repo_root,
485 opts: Some(ctx.opts),
486 };
487 match install_entry(entry, target, &install_ctx) {
488 Ok(()) => Ok(()),
489 Err(SkillfileError::PatchConflict { entry_name, .. }) => {
490 handle_patch_conflict(entry, &entry_name, ctx)
491 }
492 Err(e) => Err(e),
493 }
494}
495
496fn deploy_all(manifest: &Manifest, ctx: &DeployCtx<'_>) -> Result<(), SkillfileError> {
497 let mode = if ctx.opts.dry_run { " [dry-run]" } else { "" };
498 let all_adapters = adapters();
499
500 for target in &manifest.install_targets {
501 if !all_adapters.contains(&target.adapter) {
502 eprintln!("warning: unknown platform '{}', skipping", target.adapter);
503 continue;
504 }
505 progress!(
506 "Installing for {} ({}){mode}...",
507 target.adapter,
508 target.scope
509 );
510 for entry in &manifest.entries {
511 install_entry_or_conflict(entry, target, ctx)?;
512 }
513 }
514
515 Ok(())
516}
517
518fn apply_extra_targets(manifest: &mut Manifest, extra_targets: Option<&[InstallTarget]>) {
525 let Some(targets) = extra_targets else {
526 return;
527 };
528 if !targets.is_empty() {
529 progress!("Using platform targets from personal config (Skillfile has no install lines).");
530 }
531 manifest.install_targets = targets.to_vec();
532}
533
534fn load_manifest(
538 repo_root: &Path,
539 extra_targets: Option<&[InstallTarget]>,
540) -> Result<Manifest, SkillfileError> {
541 let manifest_path = repo_root.join(MANIFEST_NAME);
542 if !manifest_path.exists() {
543 return Err(SkillfileError::Manifest(format!(
544 "{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
545 repo_root.display()
546 )));
547 }
548
549 let result = parse_manifest(&manifest_path)?;
550 for w in &result.warnings {
551 eprintln!("{w}");
552 }
553 let mut manifest = result.manifest;
554
555 if manifest.install_targets.is_empty() {
558 apply_extra_targets(&mut manifest, extra_targets);
559 }
560
561 Ok(manifest)
562}
563
564fn auto_pin_all(manifest: &Manifest, repo_root: &Path) {
566 for entry in &manifest.entries {
567 auto_pin_entry(entry, manifest, repo_root);
568 }
569}
570
571fn print_first_install_hint(manifest: &Manifest) {
573 let platforms: Vec<String> = manifest
574 .install_targets
575 .iter()
576 .map(|t| format!("{} ({})", t.adapter, t.scope))
577 .collect();
578 progress!(" Configured platforms: {}", platforms.join(", "));
579 progress!(" Run `skillfile init` to add or change platforms.");
580}
581
582pub struct CmdInstallOpts<'a> {
584 pub dry_run: bool,
585 pub update: bool,
586 pub extra_targets: Option<&'a [InstallTarget]>,
587}
588
589pub fn cmd_install(repo_root: &Path, opts: &CmdInstallOpts<'_>) -> Result<(), SkillfileError> {
590 let manifest = load_manifest(repo_root, opts.extra_targets)?;
591
592 check_preconditions(&manifest, repo_root)?;
593
594 let cache_dir = repo_root.join(".skillfile").join("cache");
596 let first_install = !cache_dir.exists();
597
598 let old_locked = read_lock(repo_root).unwrap_or_default();
600
601 if opts.update && !opts.dry_run {
603 auto_pin_all(&manifest, repo_root);
604 }
605
606 if !opts.dry_run {
608 std::fs::create_dir_all(&cache_dir)?;
609 }
610
611 cmd_sync(&skillfile_sources::sync::SyncCmdOpts {
613 repo_root,
614 dry_run: opts.dry_run,
615 entry_filter: None,
616 update: opts.update,
617 })?;
618
619 let locked = read_lock(repo_root).unwrap_or_default();
621
622 let install_opts = InstallOptions {
624 dry_run: opts.dry_run,
625 overwrite: opts.update,
626 };
627 let deploy_ctx = DeployCtx {
628 repo_root,
629 opts: &install_opts,
630 maps: LockMaps {
631 locked: &locked,
632 old_locked: &old_locked,
633 },
634 };
635 deploy_all(&manifest, &deploy_ctx)?;
636
637 if !opts.dry_run {
638 progress!("Done.");
639
640 if first_install {
644 print_first_install_hint(&manifest);
645 }
646 }
647
648 Ok(())
649}
650
651#[cfg(test)]
656mod tests {
657 use super::*;
658 use skillfile_core::models::{
659 EntityType, Entry, InstallTarget, LockEntry, Scope, SourceFields,
660 };
661 use std::collections::BTreeMap;
662
663 fn make_agent_entry(name: &str) -> Entry {
664 Entry {
665 entity_type: EntityType::Agent,
666 name: name.into(),
667 source: SourceFields::Github {
668 owner_repo: "owner/repo".into(),
669 path_in_repo: "agents/agent.md".into(),
670 ref_: "main".into(),
671 },
672 }
673 }
674
675 fn make_local_entry(name: &str, path: &str) -> Entry {
676 Entry {
677 entity_type: EntityType::Skill,
678 name: name.into(),
679 source: SourceFields::Local { path: path.into() },
680 }
681 }
682
683 fn make_target(adapter: &str, scope: Scope) -> InstallTarget {
684 InstallTarget {
685 adapter: adapter.into(),
686 scope,
687 }
688 }
689
690 #[test]
693 fn install_local_entry_copy() {
694 let dir = tempfile::tempdir().unwrap();
695 let source_file = dir.path().join("skills/my-skill.md");
696 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
697 std::fs::write(&source_file, "# My Skill").unwrap();
698
699 let entry = make_local_entry("my-skill", "skills/my-skill.md");
700 let target = make_target("claude-code", Scope::Local);
701 install_entry(
702 &entry,
703 &target,
704 &InstallCtx {
705 repo_root: dir.path(),
706 opts: None,
707 },
708 )
709 .unwrap();
710
711 let dest = dir.path().join(".claude/skills/my-skill.md");
712 assert!(dest.exists());
713 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
714 }
715
716 #[test]
717 fn install_local_dir_entry_copy() {
718 let dir = tempfile::tempdir().unwrap();
719 let source_dir = dir.path().join("skills/python-testing");
721 std::fs::create_dir_all(&source_dir).unwrap();
722 std::fs::write(source_dir.join("SKILL.md"), "# Python Testing").unwrap();
723 std::fs::write(source_dir.join("examples.md"), "# Examples").unwrap();
724
725 let entry = make_local_entry("python-testing", "skills/python-testing");
726 let target = make_target("claude-code", Scope::Local);
727 install_entry(
728 &entry,
729 &target,
730 &InstallCtx {
731 repo_root: dir.path(),
732 opts: None,
733 },
734 )
735 .unwrap();
736
737 let dest = dir.path().join(".claude/skills/python-testing");
739 assert!(dest.is_dir(), "local dir entry must deploy as directory");
740 assert_eq!(
741 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
742 "# Python Testing"
743 );
744 assert_eq!(
745 std::fs::read_to_string(dest.join("examples.md")).unwrap(),
746 "# Examples"
747 );
748 assert!(
750 !dir.path().join(".claude/skills/python-testing.md").exists(),
751 "should not create python-testing.md for a dir source"
752 );
753 }
754
755 #[test]
756 fn install_entry_dry_run_no_write() {
757 let dir = tempfile::tempdir().unwrap();
758 let source_file = dir.path().join("skills/my-skill.md");
759 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
760 std::fs::write(&source_file, "# My Skill").unwrap();
761
762 let entry = make_local_entry("my-skill", "skills/my-skill.md");
763 let target = make_target("claude-code", Scope::Local);
764 let opts = InstallOptions {
765 dry_run: true,
766 ..Default::default()
767 };
768 install_entry(
769 &entry,
770 &target,
771 &InstallCtx {
772 repo_root: dir.path(),
773 opts: Some(&opts),
774 },
775 )
776 .unwrap();
777
778 let dest = dir.path().join(".claude/skills/my-skill.md");
779 assert!(!dest.exists());
780 }
781
782 #[test]
783 fn install_entry_overwrites_existing() {
784 let dir = tempfile::tempdir().unwrap();
785 let source_file = dir.path().join("skills/my-skill.md");
786 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
787 std::fs::write(&source_file, "# New content").unwrap();
788
789 let dest_dir = dir.path().join(".claude/skills");
790 std::fs::create_dir_all(&dest_dir).unwrap();
791 let dest = dest_dir.join("my-skill.md");
792 std::fs::write(&dest, "# Old content").unwrap();
793
794 let entry = make_local_entry("my-skill", "skills/my-skill.md");
795 let target = make_target("claude-code", Scope::Local);
796 install_entry(
797 &entry,
798 &target,
799 &InstallCtx {
800 repo_root: dir.path(),
801 opts: None,
802 },
803 )
804 .unwrap();
805
806 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
807 }
808
809 #[test]
812 fn install_github_entry_copy() {
813 let dir = tempfile::tempdir().unwrap();
814 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
815 std::fs::create_dir_all(&vdir).unwrap();
816 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
817
818 let entry = make_agent_entry("my-agent");
819 let target = make_target("claude-code", Scope::Local);
820 install_entry(
821 &entry,
822 &target,
823 &InstallCtx {
824 repo_root: dir.path(),
825 opts: None,
826 },
827 )
828 .unwrap();
829
830 let dest = dir.path().join(".claude/agents/my-agent.md");
831 assert!(dest.exists());
832 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
833 }
834
835 #[test]
836 fn install_github_dir_entry_copy() {
837 let dir = tempfile::tempdir().unwrap();
838 let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
839 std::fs::create_dir_all(&vdir).unwrap();
840 std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
841 std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
842
843 let entry = Entry {
844 entity_type: EntityType::Skill,
845 name: "python-pro".into(),
846 source: SourceFields::Github {
847 owner_repo: "owner/repo".into(),
848 path_in_repo: "skills/python-pro".into(),
849 ref_: "main".into(),
850 },
851 };
852 let target = make_target("claude-code", Scope::Local);
853 install_entry(
854 &entry,
855 &target,
856 &InstallCtx {
857 repo_root: dir.path(),
858 opts: None,
859 },
860 )
861 .unwrap();
862
863 let dest = dir.path().join(".claude/skills/python-pro");
864 assert!(dest.is_dir());
865 assert_eq!(
866 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
867 "# Python Pro"
868 );
869 }
870
871 #[test]
872 fn install_agent_dir_entry_explodes_to_individual_files() {
873 let dir = tempfile::tempdir().unwrap();
874 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
875 std::fs::create_dir_all(&vdir).unwrap();
876 std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
877 std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
878 std::fs::write(vdir.join(".meta"), "{}").unwrap();
879
880 let entry = Entry {
881 entity_type: EntityType::Agent,
882 name: "core-dev".into(),
883 source: SourceFields::Github {
884 owner_repo: "owner/repo".into(),
885 path_in_repo: "categories/core-dev".into(),
886 ref_: "main".into(),
887 },
888 };
889 let target = make_target("claude-code", Scope::Local);
890 install_entry(
891 &entry,
892 &target,
893 &InstallCtx {
894 repo_root: dir.path(),
895 opts: None,
896 },
897 )
898 .unwrap();
899
900 let agents_dir = dir.path().join(".claude/agents");
901 assert_eq!(
902 std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
903 "# Backend"
904 );
905 assert_eq!(
906 std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
907 "# Frontend"
908 );
909 assert!(!agents_dir.join("core-dev").exists());
911 }
912
913 #[test]
914 fn install_entry_missing_source_warns() {
915 let dir = tempfile::tempdir().unwrap();
916 let entry = make_agent_entry("my-agent");
917 let target = make_target("claude-code", Scope::Local);
918
919 install_entry(
921 &entry,
922 &target,
923 &InstallCtx {
924 repo_root: dir.path(),
925 opts: None,
926 },
927 )
928 .unwrap();
929 }
930
931 #[test]
934 fn install_applies_existing_patch() {
935 let dir = tempfile::tempdir().unwrap();
936
937 let vdir = dir.path().join(".skillfile/cache/skills/test");
939 std::fs::create_dir_all(&vdir).unwrap();
940 std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
941
942 let entry = Entry {
944 entity_type: EntityType::Skill,
945 name: "test".into(),
946 source: SourceFields::Github {
947 owner_repo: "owner/repo".into(),
948 path_in_repo: "skills/test.md".into(),
949 ref_: "main".into(),
950 },
951 };
952 let patch_text = skillfile_core::patch::generate_patch(
953 "# Test\n\nOriginal.\n",
954 "# Test\n\nModified.\n",
955 "test.md",
956 );
957 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
958
959 let target = make_target("claude-code", Scope::Local);
960 install_entry(
961 &entry,
962 &target,
963 &InstallCtx {
964 repo_root: dir.path(),
965 opts: None,
966 },
967 )
968 .unwrap();
969
970 let dest = dir.path().join(".claude/skills/test.md");
971 assert_eq!(
972 std::fs::read_to_string(&dest).unwrap(),
973 "# Test\n\nModified.\n"
974 );
975 }
976
977 #[test]
978 fn install_patch_conflict_returns_error() {
979 let dir = tempfile::tempdir().unwrap();
980
981 let vdir = dir.path().join(".skillfile/cache/skills/test");
982 std::fs::create_dir_all(&vdir).unwrap();
983 std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
985
986 let entry = Entry {
987 entity_type: EntityType::Skill,
988 name: "test".into(),
989 source: SourceFields::Github {
990 owner_repo: "owner/repo".into(),
991 path_in_repo: "skills/test.md".into(),
992 ref_: "main".into(),
993 },
994 };
995 let bad_patch =
997 "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
998 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
999
1000 let installed_dir = dir.path().join(".claude/skills");
1002 std::fs::create_dir_all(&installed_dir).unwrap();
1003 std::fs::write(
1004 installed_dir.join("test.md"),
1005 "totally different\ncontent\n",
1006 )
1007 .unwrap();
1008
1009 let target = make_target("claude-code", Scope::Local);
1010 let result = install_entry(
1011 &entry,
1012 &target,
1013 &InstallCtx {
1014 repo_root: dir.path(),
1015 opts: None,
1016 },
1017 );
1018 assert!(result.is_err());
1019 matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
1021 }
1022
1023 #[test]
1026 fn install_local_skill_gemini_cli() {
1027 let dir = tempfile::tempdir().unwrap();
1028 let source_file = dir.path().join("skills/my-skill.md");
1029 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1030 std::fs::write(&source_file, "# My Skill").unwrap();
1031
1032 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1033 let target = make_target("gemini-cli", Scope::Local);
1034 install_entry(
1035 &entry,
1036 &target,
1037 &InstallCtx {
1038 repo_root: dir.path(),
1039 opts: None,
1040 },
1041 )
1042 .unwrap();
1043
1044 let dest = dir.path().join(".gemini/skills/my-skill.md");
1045 assert!(dest.exists());
1046 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
1047 }
1048
1049 #[test]
1050 fn install_local_skill_codex() {
1051 let dir = tempfile::tempdir().unwrap();
1052 let source_file = dir.path().join("skills/my-skill.md");
1053 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1054 std::fs::write(&source_file, "# My Skill").unwrap();
1055
1056 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1057 let target = make_target("codex", Scope::Local);
1058 install_entry(
1059 &entry,
1060 &target,
1061 &InstallCtx {
1062 repo_root: dir.path(),
1063 opts: None,
1064 },
1065 )
1066 .unwrap();
1067
1068 let dest = dir.path().join(".codex/skills/my-skill.md");
1069 assert!(dest.exists());
1070 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
1071 }
1072
1073 #[test]
1074 fn codex_skips_agent_entries() {
1075 let dir = tempfile::tempdir().unwrap();
1076 let entry = make_agent_entry("my-agent");
1077 let target = make_target("codex", Scope::Local);
1078 install_entry(
1079 &entry,
1080 &target,
1081 &InstallCtx {
1082 repo_root: dir.path(),
1083 opts: None,
1084 },
1085 )
1086 .unwrap();
1087
1088 assert!(!dir.path().join(".codex").exists());
1089 }
1090
1091 #[test]
1092 fn install_github_agent_gemini_cli() {
1093 let dir = tempfile::tempdir().unwrap();
1094 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
1095 std::fs::create_dir_all(&vdir).unwrap();
1096 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
1097
1098 let entry = make_agent_entry("my-agent");
1099 let target = make_target("gemini-cli", Scope::Local);
1100 install_entry(
1101 &entry,
1102 &target,
1103 &InstallCtx {
1104 repo_root: dir.path(),
1105 opts: Some(&InstallOptions::default()),
1106 },
1107 )
1108 .unwrap();
1109
1110 let dest = dir.path().join(".gemini/agents/my-agent.md");
1111 assert!(dest.exists());
1112 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
1113 }
1114
1115 #[test]
1116 fn install_skill_multi_adapter() {
1117 for adapter in &["claude-code", "gemini-cli", "codex"] {
1118 let dir = tempfile::tempdir().unwrap();
1119 let source_file = dir.path().join("skills/my-skill.md");
1120 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1121 std::fs::write(&source_file, "# Multi Skill").unwrap();
1122
1123 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1124 let target = make_target(adapter, Scope::Local);
1125 install_entry(
1126 &entry,
1127 &target,
1128 &InstallCtx {
1129 repo_root: dir.path(),
1130 opts: None,
1131 },
1132 )
1133 .unwrap();
1134
1135 let prefix = match *adapter {
1136 "claude-code" => ".claude",
1137 "gemini-cli" => ".gemini",
1138 "codex" => ".codex",
1139 _ => unreachable!(),
1140 };
1141 let dest = dir.path().join(format!("{prefix}/skills/my-skill.md"));
1142 assert!(dest.exists(), "Failed for adapter {adapter}");
1143 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
1144 }
1145 }
1146
1147 #[test]
1150 fn cmd_install_no_manifest() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let result = cmd_install(
1153 dir.path(),
1154 &CmdInstallOpts {
1155 dry_run: false,
1156 update: false,
1157 extra_targets: None,
1158 },
1159 );
1160 assert!(result.is_err());
1161 assert!(result.unwrap_err().to_string().contains("not found"));
1162 }
1163
1164 #[test]
1165 fn cmd_install_no_install_targets() {
1166 let dir = tempfile::tempdir().unwrap();
1167 std::fs::write(
1168 dir.path().join("Skillfile"),
1169 "local skill foo skills/foo.md\n",
1170 )
1171 .unwrap();
1172
1173 let result = cmd_install(
1174 dir.path(),
1175 &CmdInstallOpts {
1176 dry_run: false,
1177 update: false,
1178 extra_targets: None,
1179 },
1180 );
1181 assert!(result.is_err());
1182 assert!(result
1183 .unwrap_err()
1184 .to_string()
1185 .contains("No install targets"));
1186 }
1187
1188 #[test]
1189 fn cmd_install_extra_targets_fallback() {
1190 let dir = tempfile::tempdir().unwrap();
1191 std::fs::write(
1193 dir.path().join("Skillfile"),
1194 "local skill foo skills/foo.md\n",
1195 )
1196 .unwrap();
1197 let source_file = dir.path().join("skills/foo.md");
1198 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1199 std::fs::write(&source_file, "# Foo").unwrap();
1200
1201 let targets = vec![make_target("claude-code", Scope::Local)];
1203 cmd_install(
1204 dir.path(),
1205 &CmdInstallOpts {
1206 dry_run: false,
1207 update: false,
1208 extra_targets: Some(&targets),
1209 },
1210 )
1211 .unwrap();
1212
1213 let dest = dir.path().join(".claude/skills/foo.md");
1214 assert!(
1215 dest.exists(),
1216 "extra_targets must be used when Skillfile has none"
1217 );
1218 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
1219 }
1220
1221 #[test]
1222 fn cmd_install_skillfile_targets_win_over_extra() {
1223 let dir = tempfile::tempdir().unwrap();
1224 std::fs::write(
1226 dir.path().join("Skillfile"),
1227 "install claude-code local\nlocal skill foo skills/foo.md\n",
1228 )
1229 .unwrap();
1230 let source_file = dir.path().join("skills/foo.md");
1231 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1232 std::fs::write(&source_file, "# Foo").unwrap();
1233
1234 let targets = vec![make_target("gemini-cli", Scope::Local)];
1236 cmd_install(
1237 dir.path(),
1238 &CmdInstallOpts {
1239 dry_run: false,
1240 update: false,
1241 extra_targets: Some(&targets),
1242 },
1243 )
1244 .unwrap();
1245
1246 assert!(dir.path().join(".claude/skills/foo.md").exists());
1248 assert!(!dir.path().join(".gemini").exists());
1250 }
1251
1252 #[test]
1253 fn cmd_install_dry_run_no_files() {
1254 let dir = tempfile::tempdir().unwrap();
1255 std::fs::write(
1256 dir.path().join("Skillfile"),
1257 "install claude-code local\nlocal skill foo skills/foo.md\n",
1258 )
1259 .unwrap();
1260 let source_file = dir.path().join("skills/foo.md");
1261 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1262 std::fs::write(&source_file, "# Foo").unwrap();
1263
1264 cmd_install(
1265 dir.path(),
1266 &CmdInstallOpts {
1267 dry_run: true,
1268 update: false,
1269 extra_targets: None,
1270 },
1271 )
1272 .unwrap();
1273
1274 assert!(!dir.path().join(".claude").exists());
1275 }
1276
1277 #[test]
1278 fn cmd_install_deploys_to_multiple_adapters() {
1279 let dir = tempfile::tempdir().unwrap();
1280 std::fs::write(
1281 dir.path().join("Skillfile"),
1282 "install claude-code local\n\
1283 install gemini-cli local\n\
1284 install codex local\n\
1285 local skill foo skills/foo.md\n\
1286 local agent bar agents/bar.md\n",
1287 )
1288 .unwrap();
1289 std::fs::create_dir_all(dir.path().join("skills")).unwrap();
1290 std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
1291 std::fs::create_dir_all(dir.path().join("agents")).unwrap();
1292 std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
1293
1294 cmd_install(
1295 dir.path(),
1296 &CmdInstallOpts {
1297 dry_run: false,
1298 update: false,
1299 extra_targets: None,
1300 },
1301 )
1302 .unwrap();
1303
1304 assert!(dir.path().join(".claude/skills/foo.md").exists());
1306 assert!(dir.path().join(".gemini/skills/foo.md").exists());
1307 assert!(dir.path().join(".codex/skills/foo.md").exists());
1308
1309 assert!(dir.path().join(".claude/agents/bar.md").exists());
1311 assert!(dir.path().join(".gemini/agents/bar.md").exists());
1312 assert!(!dir.path().join(".codex/agents").exists());
1313 }
1314
1315 #[test]
1316 fn cmd_install_pending_conflict_blocks() {
1317 use skillfile_core::conflict::write_conflict;
1318 use skillfile_core::models::ConflictState;
1319
1320 let dir = tempfile::tempdir().unwrap();
1321 std::fs::write(
1322 dir.path().join("Skillfile"),
1323 "install claude-code local\nlocal skill foo skills/foo.md\n",
1324 )
1325 .unwrap();
1326
1327 write_conflict(
1328 dir.path(),
1329 &ConflictState {
1330 entry: "foo".into(),
1331 entity_type: EntityType::Skill,
1332 old_sha: "aaa".into(),
1333 new_sha: "bbb".into(),
1334 },
1335 )
1336 .unwrap();
1337
1338 let result = cmd_install(
1339 dir.path(),
1340 &CmdInstallOpts {
1341 dry_run: false,
1342 update: false,
1343 extra_targets: None,
1344 },
1345 );
1346 assert!(result.is_err());
1347 assert!(result.unwrap_err().to_string().contains("pending conflict"));
1348 }
1349
1350 fn make_skill_entry(name: &str) -> Entry {
1356 Entry {
1357 entity_type: EntityType::Skill,
1358 name: name.into(),
1359 source: SourceFields::Github {
1360 owner_repo: "owner/repo".into(),
1361 path_in_repo: format!("skills/{name}.md"),
1362 ref_: "main".into(),
1363 },
1364 }
1365 }
1366
1367 fn make_dir_skill_entry(name: &str) -> Entry {
1369 Entry {
1370 entity_type: EntityType::Skill,
1371 name: name.into(),
1372 source: SourceFields::Github {
1373 owner_repo: "owner/repo".into(),
1374 path_in_repo: format!("skills/{name}"),
1375 ref_: "main".into(),
1376 },
1377 }
1378 }
1379
1380 fn setup_github_skill_repo(dir: &std::path::Path, name: &str, cache_content: &str) {
1382 use skillfile_core::lock::write_lock;
1383
1384 std::fs::write(
1386 dir.join("Skillfile"),
1387 format!("install claude-code local\ngithub skill {name} owner/repo skills/{name}.md\n"),
1388 )
1389 .unwrap();
1390
1391 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1393 locked.insert(
1394 format!("github/skill/{name}"),
1395 LockEntry {
1396 sha: "abc123def456abc123def456abc123def456abc123".into(),
1397 raw_url: format!(
1398 "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
1399 ),
1400 },
1401 );
1402 write_lock(dir, &locked).unwrap();
1403
1404 let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
1406 std::fs::create_dir_all(&vdir).unwrap();
1407 std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
1408 }
1409
1410 #[test]
1415 fn auto_pin_entry_local_is_skipped() {
1416 let dir = tempfile::tempdir().unwrap();
1417
1418 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1420 let manifest = Manifest {
1421 entries: vec![entry.clone()],
1422 install_targets: vec![make_target("claude-code", Scope::Local)],
1423 };
1424
1425 let skills_dir = dir.path().join("skills");
1427 std::fs::create_dir_all(&skills_dir).unwrap();
1428 std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
1429
1430 auto_pin_entry(&entry, &manifest, dir.path());
1431
1432 assert!(
1434 !skillfile_core::patch::has_patch(&entry, dir.path()),
1435 "local entry must never be pinned"
1436 );
1437 }
1438
1439 #[test]
1440 fn auto_pin_entry_missing_lock_is_skipped() {
1441 let dir = tempfile::tempdir().unwrap();
1442
1443 let entry = make_skill_entry("test");
1444 let manifest = Manifest {
1445 entries: vec![entry.clone()],
1446 install_targets: vec![make_target("claude-code", Scope::Local)],
1447 };
1448
1449 auto_pin_entry(&entry, &manifest, dir.path());
1451
1452 assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1453 }
1454
1455 #[test]
1456 fn auto_pin_entry_missing_lock_key_is_skipped() {
1457 use skillfile_core::lock::write_lock;
1458
1459 let dir = tempfile::tempdir().unwrap();
1460
1461 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1463 locked.insert(
1464 "github/skill/other".into(),
1465 LockEntry {
1466 sha: "aabbcc".into(),
1467 raw_url: "https://example.com/other.md".into(),
1468 },
1469 );
1470 write_lock(dir.path(), &locked).unwrap();
1471
1472 let entry = make_skill_entry("test");
1473 let manifest = Manifest {
1474 entries: vec![entry.clone()],
1475 install_targets: vec![make_target("claude-code", Scope::Local)],
1476 };
1477
1478 auto_pin_entry(&entry, &manifest, dir.path());
1479
1480 assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1481 }
1482
1483 #[test]
1484 fn auto_pin_entry_writes_patch_when_installed_differs() {
1485 let dir = tempfile::tempdir().unwrap();
1486 let name = "my-skill";
1487
1488 let cache_content = "# My Skill\n\nOriginal content.\n";
1489 let installed_content = "# My Skill\n\nUser-modified content.\n";
1490
1491 setup_github_skill_repo(dir.path(), name, cache_content);
1492
1493 let installed_dir = dir.path().join(".claude/skills");
1495 std::fs::create_dir_all(&installed_dir).unwrap();
1496 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1497
1498 let entry = make_skill_entry(name);
1499 let manifest = Manifest {
1500 entries: vec![entry.clone()],
1501 install_targets: vec![make_target("claude-code", Scope::Local)],
1502 };
1503
1504 auto_pin_entry(&entry, &manifest, dir.path());
1505
1506 assert!(
1507 skillfile_core::patch::has_patch(&entry, dir.path()),
1508 "patch should be written when installed differs from cache"
1509 );
1510
1511 let patch_text = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1513 let result = skillfile_core::patch::apply_patch_pure(cache_content, &patch_text).unwrap();
1514 assert_eq!(result, installed_content);
1515 }
1516
1517 #[test]
1518 fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1519 let dir = tempfile::tempdir().unwrap();
1520 let name = "my-skill";
1521
1522 let cache_content = "# My Skill\n\nOriginal.\n";
1523 let installed_content = "# My Skill\n\nModified.\n";
1524
1525 setup_github_skill_repo(dir.path(), name, cache_content);
1526
1527 let entry = make_skill_entry(name);
1528 let manifest = Manifest {
1529 entries: vec![entry.clone()],
1530 install_targets: vec![make_target("claude-code", Scope::Local)],
1531 };
1532
1533 let patch_text = skillfile_core::patch::generate_patch(
1535 cache_content,
1536 installed_content,
1537 &format!("{name}.md"),
1538 );
1539 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1540
1541 let installed_dir = dir.path().join(".claude/skills");
1543 std::fs::create_dir_all(&installed_dir).unwrap();
1544 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1545
1546 let patch_path = skillfile_core::patch::patch_path(&entry, dir.path());
1548 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1549
1550 std::thread::sleep(std::time::Duration::from_millis(20));
1552
1553 auto_pin_entry(&entry, &manifest, dir.path());
1554
1555 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1556
1557 assert_eq!(
1558 mtime_before, mtime_after,
1559 "patch must not be rewritten when already up to date"
1560 );
1561 }
1562
1563 #[test]
1564 fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1565 let dir = tempfile::tempdir().unwrap();
1566 let name = "my-skill";
1567
1568 let cache_content = "# My Skill\n\nOriginal.\n";
1569 let old_installed = "# My Skill\n\nFirst edit.\n";
1570 let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1571
1572 setup_github_skill_repo(dir.path(), name, cache_content);
1573
1574 let entry = make_skill_entry(name);
1575 let manifest = Manifest {
1576 entries: vec![entry.clone()],
1577 install_targets: vec![make_target("claude-code", Scope::Local)],
1578 };
1579
1580 let old_patch = skillfile_core::patch::generate_patch(
1582 cache_content,
1583 old_installed,
1584 &format!("{name}.md"),
1585 );
1586 skillfile_core::patch::write_patch(&entry, &old_patch, dir.path()).unwrap();
1587
1588 let installed_dir = dir.path().join(".claude/skills");
1590 std::fs::create_dir_all(&installed_dir).unwrap();
1591 std::fs::write(installed_dir.join(format!("{name}.md")), new_installed).unwrap();
1592
1593 auto_pin_entry(&entry, &manifest, dir.path());
1594
1595 let new_patch = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1597 let result = skillfile_core::patch::apply_patch_pure(cache_content, &new_patch).unwrap();
1598 assert_eq!(
1599 result, new_installed,
1600 "updated patch must describe the latest installed content"
1601 );
1602 }
1603
1604 #[test]
1609 fn auto_pin_dir_entry_writes_per_file_patches() {
1610 use skillfile_core::lock::write_lock;
1611
1612 let dir = tempfile::tempdir().unwrap();
1613 let name = "lang-pro";
1614
1615 std::fs::write(
1617 dir.path().join("Skillfile"),
1618 format!(
1619 "install claude-code local\ngithub skill {name} owner/repo skills/{name}\n"
1620 ),
1621 )
1622 .unwrap();
1623 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1624 locked.insert(
1625 format!("github/skill/{name}"),
1626 LockEntry {
1627 sha: "deadbeefdeadbeefdeadbeef".into(),
1628 raw_url: format!("https://example.com/{name}"),
1629 },
1630 );
1631 write_lock(dir.path(), &locked).unwrap();
1632
1633 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1635 std::fs::create_dir_all(&vdir).unwrap();
1636 std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1637 std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1638
1639 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1641 std::fs::create_dir_all(&inst_dir).unwrap();
1642 std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1643 std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1644
1645 let entry = make_dir_skill_entry(name);
1646 let manifest = Manifest {
1647 entries: vec![entry.clone()],
1648 install_targets: vec![make_target("claude-code", Scope::Local)],
1649 };
1650
1651 auto_pin_entry(&entry, &manifest, dir.path());
1652
1653 let skill_patch = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1655 assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1656
1657 let examples_patch =
1659 skillfile_core::patch::dir_patch_path(&entry, "examples.md", dir.path());
1660 assert!(
1661 !examples_patch.exists(),
1662 "patch for examples.md must not be written (content unchanged)"
1663 );
1664 }
1665
1666 #[test]
1667 fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1668 use skillfile_core::lock::write_lock;
1669
1670 let dir = tempfile::tempdir().unwrap();
1671 let name = "lang-pro";
1672
1673 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1675 locked.insert(
1676 format!("github/skill/{name}"),
1677 LockEntry {
1678 sha: "abc".into(),
1679 raw_url: "https://example.com".into(),
1680 },
1681 );
1682 write_lock(dir.path(), &locked).unwrap();
1683
1684 let entry = make_dir_skill_entry(name);
1685 let manifest = Manifest {
1686 entries: vec![entry.clone()],
1687 install_targets: vec![make_target("claude-code", Scope::Local)],
1688 };
1689
1690 auto_pin_entry(&entry, &manifest, dir.path());
1692
1693 assert!(!skillfile_core::patch::has_dir_patch(&entry, dir.path()));
1694 }
1695
1696 #[test]
1697 fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1698 use skillfile_core::lock::write_lock;
1699
1700 let dir = tempfile::tempdir().unwrap();
1701 let name = "lang-pro";
1702
1703 let cache_content = "# Lang Pro\n\nOriginal.\n";
1704 let modified = "# Lang Pro\n\nModified.\n";
1705
1706 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1708 locked.insert(
1709 format!("github/skill/{name}"),
1710 LockEntry {
1711 sha: "abc".into(),
1712 raw_url: "https://example.com".into(),
1713 },
1714 );
1715 write_lock(dir.path(), &locked).unwrap();
1716
1717 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1719 std::fs::create_dir_all(&vdir).unwrap();
1720 std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1721
1722 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1724 std::fs::create_dir_all(&inst_dir).unwrap();
1725 std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1726
1727 let entry = make_dir_skill_entry(name);
1728 let manifest = Manifest {
1729 entries: vec![entry.clone()],
1730 install_targets: vec![make_target("claude-code", Scope::Local)],
1731 };
1732
1733 let patch_text = skillfile_core::patch::generate_patch(cache_content, modified, "SKILL.md");
1735 skillfile_core::patch::write_dir_patch(
1736 &skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path()),
1737 &patch_text,
1738 )
1739 .unwrap();
1740
1741 let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1742 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1743
1744 std::thread::sleep(std::time::Duration::from_millis(20));
1745
1746 auto_pin_entry(&entry, &manifest, dir.path());
1747
1748 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1749
1750 assert_eq!(
1751 mtime_before, mtime_after,
1752 "dir patch must not be rewritten when already up to date"
1753 );
1754 }
1755
1756 #[test]
1761 fn apply_dir_patches_applies_patch_and_rebases() {
1762 let dir = tempfile::tempdir().unwrap();
1763
1764 let cache_content = "# Skill\n\nOriginal.\n";
1766 let installed_content = "# Skill\n\nModified.\n";
1767 let new_cache_content = "# Skill\n\nOriginal v2.\n";
1769 let expected_rebased_to_new_cache = installed_content;
1772
1773 let entry = make_dir_skill_entry("lang-pro");
1774
1775 let patch_text =
1777 skillfile_core::patch::generate_patch(cache_content, installed_content, "SKILL.md");
1778 skillfile_core::patch::write_dir_patch(
1779 &skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path()),
1780 &patch_text,
1781 )
1782 .unwrap();
1783
1784 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1786 std::fs::create_dir_all(&inst_dir).unwrap();
1787 std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1788
1789 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1791 std::fs::create_dir_all(&new_cache_dir).unwrap();
1792 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1793
1794 let mut installed_files = std::collections::HashMap::new();
1796 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1797
1798 apply_dir_patches(
1799 &PatchCtx {
1800 entry: &entry,
1801 repo_root: dir.path(),
1802 },
1803 &installed_files,
1804 &new_cache_dir,
1805 )
1806 .unwrap();
1807
1808 let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1810 assert_eq!(installed_after, installed_content);
1811
1812 let rebased_patch = std::fs::read_to_string(skillfile_core::patch::dir_patch_path(
1815 &entry,
1816 "SKILL.md",
1817 dir.path(),
1818 ))
1819 .unwrap();
1820 let rebase_result =
1821 skillfile_core::patch::apply_patch_pure(new_cache_content, &rebased_patch).unwrap();
1822 assert_eq!(
1823 rebase_result, expected_rebased_to_new_cache,
1824 "rebased patch applied to new_cache must reproduce installed_content"
1825 );
1826 }
1827
1828 #[test]
1829 fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1830 let dir = tempfile::tempdir().unwrap();
1831
1832 let original = "# Skill\n\nOriginal.\n";
1834 let modified = "# Skill\n\nModified.\n";
1835 let new_cache = modified; let entry = make_dir_skill_entry("lang-pro");
1839
1840 let patch_text = skillfile_core::patch::generate_patch(original, modified, "SKILL.md");
1841 skillfile_core::patch::write_dir_patch(
1842 &skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path()),
1843 &patch_text,
1844 )
1845 .unwrap();
1846
1847 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1849 std::fs::create_dir_all(&inst_dir).unwrap();
1850 std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1851
1852 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1853 std::fs::create_dir_all(&new_cache_dir).unwrap();
1854 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1855
1856 let mut installed_files = std::collections::HashMap::new();
1857 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1858
1859 apply_dir_patches(
1860 &PatchCtx {
1861 entry: &entry,
1862 repo_root: dir.path(),
1863 },
1864 &installed_files,
1865 &new_cache_dir,
1866 )
1867 .unwrap();
1868
1869 let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1871 assert!(
1872 !patch_path.exists(),
1873 "patch file must be removed when rebase yields empty diff"
1874 );
1875 }
1876
1877 #[test]
1878 fn apply_dir_patches_no_op_when_no_patches_dir() {
1879 let dir = tempfile::tempdir().unwrap();
1880
1881 let entry = make_dir_skill_entry("lang-pro");
1883 let installed_files = std::collections::HashMap::new();
1884 let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1885 std::fs::create_dir_all(&source_dir).unwrap();
1886
1887 apply_dir_patches(
1889 &PatchCtx {
1890 entry: &entry,
1891 repo_root: dir.path(),
1892 },
1893 &installed_files,
1894 &source_dir,
1895 )
1896 .unwrap();
1897 }
1898
1899 #[test]
1904 fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1905 let dir = tempfile::tempdir().unwrap();
1906
1907 let original = "# Skill\n\nOriginal.\n";
1908 let modified = "# Skill\n\nModified.\n";
1909 let new_cache = modified;
1911
1912 let entry = make_skill_entry("test");
1913
1914 let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1916 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1917
1918 let vdir = dir.path().join(".skillfile/cache/skills/test");
1920 std::fs::create_dir_all(&vdir).unwrap();
1921 let source = vdir.join("test.md");
1922 std::fs::write(&source, new_cache).unwrap();
1923
1924 let installed_dir = dir.path().join(".claude/skills");
1926 std::fs::create_dir_all(&installed_dir).unwrap();
1927 let dest = installed_dir.join("test.md");
1928 std::fs::write(&dest, original).unwrap();
1929
1930 apply_single_file_patch(
1931 &PatchCtx {
1932 entry: &entry,
1933 repo_root: dir.path(),
1934 },
1935 &dest,
1936 &source,
1937 )
1938 .unwrap();
1939
1940 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1942
1943 assert!(
1945 !skillfile_core::patch::has_patch(&entry, dir.path()),
1946 "patch must be removed when new cache already matches patched content"
1947 );
1948 }
1949
1950 #[test]
1951 fn apply_single_file_patch_rewrites_patch_after_rebase() {
1952 let dir = tempfile::tempdir().unwrap();
1953
1954 let original = "# Skill\n\nOriginal.\n";
1956 let modified = "# Skill\n\nModified.\n";
1957 let new_cache = "# Skill\n\nOriginal v2.\n";
1958 let expected_rebased_result = modified;
1961
1962 let entry = make_skill_entry("test");
1963
1964 let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1965 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1966
1967 let vdir = dir.path().join(".skillfile/cache/skills/test");
1969 std::fs::create_dir_all(&vdir).unwrap();
1970 let source = vdir.join("test.md");
1971 std::fs::write(&source, new_cache).unwrap();
1972
1973 let installed_dir = dir.path().join(".claude/skills");
1975 std::fs::create_dir_all(&installed_dir).unwrap();
1976 let dest = installed_dir.join("test.md");
1977 std::fs::write(&dest, original).unwrap();
1978
1979 apply_single_file_patch(
1980 &PatchCtx {
1981 entry: &entry,
1982 repo_root: dir.path(),
1983 },
1984 &dest,
1985 &source,
1986 )
1987 .unwrap();
1988
1989 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1991
1992 assert!(
1995 skillfile_core::patch::has_patch(&entry, dir.path()),
1996 "rebased patch must still exist (new_cache != modified)"
1997 );
1998 let rebased = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1999 let result = skillfile_core::patch::apply_patch_pure(new_cache, &rebased).unwrap();
2000 assert_eq!(
2001 result, expected_rebased_result,
2002 "rebased patch applied to new_cache must reproduce installed content"
2003 );
2004 }
2005
2006 #[test]
2011 fn check_preconditions_no_targets_returns_error() {
2012 let dir = tempfile::tempdir().unwrap();
2013 let manifest = Manifest {
2014 entries: vec![],
2015 install_targets: vec![],
2016 };
2017 let result = check_preconditions(&manifest, dir.path());
2018 assert!(result.is_err());
2019 assert!(result
2020 .unwrap_err()
2021 .to_string()
2022 .contains("No install targets"));
2023 }
2024
2025 #[test]
2026 fn check_preconditions_pending_conflict_returns_error() {
2027 use skillfile_core::conflict::write_conflict;
2028 use skillfile_core::models::ConflictState;
2029
2030 let dir = tempfile::tempdir().unwrap();
2031 let manifest = Manifest {
2032 entries: vec![],
2033 install_targets: vec![make_target("claude-code", Scope::Local)],
2034 };
2035
2036 write_conflict(
2037 dir.path(),
2038 &ConflictState {
2039 entry: "my-skill".into(),
2040 entity_type: EntityType::Skill,
2041 old_sha: "aaa".into(),
2042 new_sha: "bbb".into(),
2043 },
2044 )
2045 .unwrap();
2046
2047 let result = check_preconditions(&manifest, dir.path());
2048 assert!(result.is_err());
2049 assert!(result.unwrap_err().to_string().contains("pending conflict"));
2050 }
2051
2052 #[test]
2053 fn check_preconditions_ok_with_target_and_no_conflict() {
2054 let dir = tempfile::tempdir().unwrap();
2055 let manifest = Manifest {
2056 entries: vec![],
2057 install_targets: vec![make_target("claude-code", Scope::Local)],
2058 };
2059 check_preconditions(&manifest, dir.path()).unwrap();
2060 }
2061
2062 fn setup_deploy_all_conflict_scenario(
2067 dir: &std::path::Path,
2068 name: &str,
2069 ) -> (
2070 Entry,
2071 Manifest,
2072 std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
2073 std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
2074 ) {
2075 let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
2077 std::fs::create_dir_all(&vdir).unwrap();
2078 std::fs::write(
2079 vdir.join(format!("{name}.md")),
2080 "totally different content\n",
2081 )
2082 .unwrap();
2083
2084 let entry = make_skill_entry(name);
2086 let bad_patch =
2087 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-expected_original_line\n+modified\n";
2088 skillfile_core::patch::write_patch(&entry, bad_patch, dir).unwrap();
2089
2090 let inst_dir = dir.join(".claude/skills");
2092 std::fs::create_dir_all(&inst_dir).unwrap();
2093 std::fs::write(
2094 inst_dir.join(format!("{name}.md")),
2095 "totally different content\n",
2096 )
2097 .unwrap();
2098
2099 let manifest = Manifest {
2101 entries: vec![entry.clone()],
2102 install_targets: vec![make_target("claude-code", Scope::Local)],
2103 };
2104
2105 let lock_key_str = format!("github/skill/{name}");
2106 let old_sha = "a".repeat(40);
2107 let new_sha = "b".repeat(40);
2108
2109 let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2110 old_locked.insert(
2111 lock_key_str.clone(),
2112 LockEntry {
2113 sha: old_sha.clone(),
2114 raw_url: "https://example.com/old.md".into(),
2115 },
2116 );
2117
2118 let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2119 new_locked.insert(
2120 lock_key_str,
2121 LockEntry {
2122 sha: new_sha.clone(),
2123 raw_url: "https://example.com/new.md".into(),
2124 },
2125 );
2126
2127 (entry, manifest, new_locked, old_locked)
2128 }
2129
2130 #[test]
2131 fn deploy_all_patch_conflict_writes_conflict_state() {
2132 use skillfile_core::conflict::{has_conflict, read_conflict};
2133 use skillfile_core::lock::write_lock;
2134
2135 let dir = tempfile::tempdir().unwrap();
2136 let name = "test";
2137
2138 let (_, manifest, new_locked, old_locked) =
2139 setup_deploy_all_conflict_scenario(dir.path(), name);
2140
2141 write_lock(dir.path(), &new_locked).unwrap();
2142
2143 let old_sha = old_locked
2144 .get(&format!("github/skill/{name}"))
2145 .map(|l| l.sha.clone())
2146 .unwrap_or_default();
2147 let new_sha = new_locked
2148 .get(&format!("github/skill/{name}"))
2149 .map(|l| l.sha.clone())
2150 .unwrap_or_default();
2151
2152 let opts = InstallOptions {
2153 dry_run: false,
2154 overwrite: true,
2155 };
2156
2157 let result = deploy_all(
2158 &manifest,
2159 &DeployCtx {
2160 repo_root: dir.path(),
2161 opts: &opts,
2162 maps: LockMaps {
2163 locked: &new_locked,
2164 old_locked: &old_locked,
2165 },
2166 },
2167 );
2168
2169 assert!(
2171 result.is_err(),
2172 "deploy_all must return Err on PatchConflict"
2173 );
2174 let err_msg = result.unwrap_err().to_string();
2175 assert!(
2176 err_msg.contains("conflict"),
2177 "error message must mention conflict: {err_msg}"
2178 );
2179
2180 assert!(
2182 has_conflict(dir.path()),
2183 "conflict state file must be written after PatchConflict"
2184 );
2185
2186 let conflict = read_conflict(dir.path()).unwrap().unwrap();
2187 assert_eq!(conflict.entry, name);
2188 assert_eq!(conflict.old_sha, old_sha);
2189 assert_eq!(conflict.new_sha, new_sha);
2190 }
2191
2192 fn make_lock_map(key: &str, sha: &str, url: &str) -> BTreeMap<String, LockEntry> {
2193 let mut map: BTreeMap<String, LockEntry> = BTreeMap::new();
2194 map.insert(
2195 key.to_string(),
2196 LockEntry {
2197 sha: sha.to_string(),
2198 raw_url: url.to_string(),
2199 },
2200 );
2201 map
2202 }
2203
2204 #[test]
2205 fn deploy_all_patch_conflict_error_message_contains_sha_context() {
2206 use skillfile_core::lock::write_lock;
2207
2208 let dir = tempfile::tempdir().unwrap();
2209 let name = "test";
2210
2211 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
2212 std::fs::create_dir_all(&vdir).unwrap();
2213 std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
2214
2215 let entry = make_skill_entry(name);
2216 let bad_patch =
2217 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
2218 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
2219
2220 let inst_dir = dir.path().join(".claude/skills");
2221 std::fs::create_dir_all(&inst_dir).unwrap();
2222 std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
2223
2224 let manifest = Manifest {
2225 entries: vec![entry.clone()],
2226 install_targets: vec![make_target("claude-code", Scope::Local)],
2227 };
2228
2229 let lock_key = format!("github/skill/{name}");
2230 let old_sha = "aabbccddeeff001122334455aabbccddeeff0011";
2231 let new_sha = "99887766554433221100ffeeddccbbaa99887766";
2232 let old_locked = make_lock_map(&lock_key, old_sha, "https://example.com/old.md");
2233 let new_locked = make_lock_map(&lock_key, new_sha, "https://example.com/new.md");
2234
2235 write_lock(dir.path(), &new_locked).unwrap();
2236
2237 let opts = InstallOptions {
2238 dry_run: false,
2239 overwrite: true,
2240 };
2241
2242 let result = deploy_all(
2243 &manifest,
2244 &DeployCtx {
2245 repo_root: dir.path(),
2246 opts: &opts,
2247 maps: LockMaps {
2248 locked: &new_locked,
2249 old_locked: &old_locked,
2250 },
2251 },
2252 );
2253 assert!(result.is_err());
2254
2255 let err_msg = result.unwrap_err().to_string();
2256 assert!(
2257 err_msg.contains('\u{2192}'),
2258 "error message must contain the SHA arrow: {err_msg}"
2259 );
2260 assert!(
2261 err_msg.contains(&old_sha[..12]),
2262 "error must contain old SHA prefix: {err_msg}"
2263 );
2264 assert!(
2265 err_msg.contains(&new_sha[..12]),
2266 "error must contain new SHA prefix: {err_msg}"
2267 );
2268 }
2269
2270 #[test]
2271 fn deploy_all_patch_conflict_error_message_has_resolve_hints() {
2272 use skillfile_core::lock::write_lock;
2273
2274 let dir = tempfile::tempdir().unwrap();
2275 let name = "test";
2276
2277 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
2278 std::fs::create_dir_all(&vdir).unwrap();
2279 std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
2280
2281 let entry = make_skill_entry(name);
2282 let bad_patch =
2283 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
2284 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
2285
2286 let inst_dir = dir.path().join(".claude/skills");
2287 std::fs::create_dir_all(&inst_dir).unwrap();
2288 std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
2289
2290 let manifest = Manifest {
2291 entries: vec![entry.clone()],
2292 install_targets: vec![make_target("claude-code", Scope::Local)],
2293 };
2294
2295 let lock_key_str = format!("github/skill/{name}");
2296 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2297 locked.insert(
2298 lock_key_str,
2299 LockEntry {
2300 sha: "abc123".into(),
2301 raw_url: "https://example.com/test.md".into(),
2302 },
2303 );
2304 write_lock(dir.path(), &locked).unwrap();
2305
2306 let opts = InstallOptions {
2307 dry_run: false,
2308 overwrite: true,
2309 };
2310
2311 let result = deploy_all(
2312 &manifest,
2313 &DeployCtx {
2314 repo_root: dir.path(),
2315 opts: &opts,
2316 maps: LockMaps {
2317 locked: &locked,
2318 old_locked: &BTreeMap::new(),
2319 },
2320 },
2321 );
2322 assert!(result.is_err());
2323
2324 let err_msg = result.unwrap_err().to_string();
2325 assert!(
2326 err_msg.contains("skillfile resolve"),
2327 "error must mention resolve command: {err_msg}"
2328 );
2329 assert!(
2330 err_msg.contains("skillfile diff"),
2331 "error must mention diff command: {err_msg}"
2332 );
2333 assert!(
2334 err_msg.contains("--abort"),
2335 "error must mention --abort: {err_msg}"
2336 );
2337 }
2338
2339 #[test]
2340 fn deploy_all_unknown_platform_skips_gracefully() {
2341 let dir = tempfile::tempdir().unwrap();
2342
2343 let manifest = Manifest {
2345 entries: vec![],
2346 install_targets: vec![InstallTarget {
2347 adapter: "unknown-tool".into(),
2348 scope: Scope::Local,
2349 }],
2350 };
2351
2352 let opts = InstallOptions {
2353 dry_run: false,
2354 overwrite: true,
2355 };
2356
2357 deploy_all(
2359 &manifest,
2360 &DeployCtx {
2361 repo_root: dir.path(),
2362 opts: &opts,
2363 maps: LockMaps {
2364 locked: &BTreeMap::new(),
2365 old_locked: &BTreeMap::new(),
2366 },
2367 },
2368 )
2369 .unwrap();
2370 }
2371}