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
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
34fn 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 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
62fn 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
118fn 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 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, Ok(_) => {} Err(_) => return, }
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
190fn 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 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
232fn 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
259pub 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
314fn 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
337fn 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
412pub fn cmd_install(
417 repo_root: &Path,
418 dry_run: bool,
419 update: bool,
420 extra_targets: Option<&[InstallTarget]>,
421) -> Result<(), SkillfileError> {
422 let manifest_path = repo_root.join(MANIFEST_NAME);
423 if !manifest_path.exists() {
424 return Err(SkillfileError::Manifest(format!(
425 "{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
426 repo_root.display()
427 )));
428 }
429
430 let result = parse_manifest(&manifest_path)?;
431 for w in &result.warnings {
432 eprintln!("{w}");
433 }
434 let mut manifest = result.manifest;
435
436 if manifest.install_targets.is_empty() {
439 if let Some(targets) = extra_targets {
440 if !targets.is_empty() {
441 progress!(
442 "Using platform targets from personal config (Skillfile has no install lines)."
443 );
444 }
445 manifest.install_targets = targets.to_vec();
446 }
447 }
448
449 check_preconditions(&manifest, repo_root)?;
450
451 let cache_dir = repo_root.join(".skillfile").join("cache");
453 let first_install = !cache_dir.exists();
454
455 let old_locked = read_lock(repo_root).unwrap_or_default();
457
458 if update && !dry_run {
460 for entry in &manifest.entries {
461 auto_pin_entry(entry, &manifest, repo_root);
462 }
463 }
464
465 if !dry_run {
467 std::fs::create_dir_all(&cache_dir)?;
468 }
469
470 cmd_sync(repo_root, dry_run, None, update)?;
472
473 let locked = read_lock(repo_root).unwrap_or_default();
475
476 let opts = InstallOptions {
478 dry_run,
479 overwrite: update,
480 };
481 deploy_all(&manifest, repo_root, &opts, &locked, &old_locked)?;
482
483 if !dry_run {
484 progress!("Done.");
485
486 if first_install {
490 let platforms: Vec<String> = manifest
491 .install_targets
492 .iter()
493 .map(|t| format!("{} ({})", t.adapter, t.scope))
494 .collect();
495 progress!(" Configured platforms: {}", platforms.join(", "));
496 progress!(" Run `skillfile init` to add or change platforms.");
497 }
498 }
499
500 Ok(())
501}
502
503#[cfg(test)]
508mod tests {
509 use super::*;
510 use skillfile_core::models::{EntityType, Entry, InstallTarget, Scope, SourceFields};
511
512 fn make_agent_entry(name: &str) -> Entry {
513 Entry {
514 entity_type: EntityType::Agent,
515 name: name.into(),
516 source: SourceFields::Github {
517 owner_repo: "owner/repo".into(),
518 path_in_repo: "agents/agent.md".into(),
519 ref_: "main".into(),
520 },
521 }
522 }
523
524 fn make_local_entry(name: &str, path: &str) -> Entry {
525 Entry {
526 entity_type: EntityType::Skill,
527 name: name.into(),
528 source: SourceFields::Local { path: path.into() },
529 }
530 }
531
532 fn make_target(adapter: &str, scope: Scope) -> InstallTarget {
533 InstallTarget {
534 adapter: adapter.into(),
535 scope,
536 }
537 }
538
539 #[test]
542 fn install_local_entry_copy() {
543 let dir = tempfile::tempdir().unwrap();
544 let source_file = dir.path().join("skills/my-skill.md");
545 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
546 std::fs::write(&source_file, "# My Skill").unwrap();
547
548 let entry = make_local_entry("my-skill", "skills/my-skill.md");
549 let target = make_target("claude-code", Scope::Local);
550 install_entry(&entry, &target, dir.path(), None).unwrap();
551
552 let dest = dir.path().join(".claude/skills/my-skill.md");
553 assert!(dest.exists());
554 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
555 }
556
557 #[test]
558 fn install_local_dir_entry_copy() {
559 let dir = tempfile::tempdir().unwrap();
560 let source_dir = dir.path().join("skills/python-testing");
562 std::fs::create_dir_all(&source_dir).unwrap();
563 std::fs::write(source_dir.join("SKILL.md"), "# Python Testing").unwrap();
564 std::fs::write(source_dir.join("examples.md"), "# Examples").unwrap();
565
566 let entry = make_local_entry("python-testing", "skills/python-testing");
567 let target = make_target("claude-code", Scope::Local);
568 install_entry(&entry, &target, dir.path(), None).unwrap();
569
570 let dest = dir.path().join(".claude/skills/python-testing");
572 assert!(dest.is_dir(), "local dir entry must deploy as directory");
573 assert_eq!(
574 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
575 "# Python Testing"
576 );
577 assert_eq!(
578 std::fs::read_to_string(dest.join("examples.md")).unwrap(),
579 "# Examples"
580 );
581 assert!(
583 !dir.path().join(".claude/skills/python-testing.md").exists(),
584 "should not create python-testing.md for a dir source"
585 );
586 }
587
588 #[test]
589 fn install_entry_dry_run_no_write() {
590 let dir = tempfile::tempdir().unwrap();
591 let source_file = dir.path().join("skills/my-skill.md");
592 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
593 std::fs::write(&source_file, "# My Skill").unwrap();
594
595 let entry = make_local_entry("my-skill", "skills/my-skill.md");
596 let target = make_target("claude-code", Scope::Local);
597 let opts = InstallOptions {
598 dry_run: true,
599 ..Default::default()
600 };
601 install_entry(&entry, &target, dir.path(), Some(&opts)).unwrap();
602
603 let dest = dir.path().join(".claude/skills/my-skill.md");
604 assert!(!dest.exists());
605 }
606
607 #[test]
608 fn install_entry_overwrites_existing() {
609 let dir = tempfile::tempdir().unwrap();
610 let source_file = dir.path().join("skills/my-skill.md");
611 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
612 std::fs::write(&source_file, "# New content").unwrap();
613
614 let dest_dir = dir.path().join(".claude/skills");
615 std::fs::create_dir_all(&dest_dir).unwrap();
616 let dest = dest_dir.join("my-skill.md");
617 std::fs::write(&dest, "# Old content").unwrap();
618
619 let entry = make_local_entry("my-skill", "skills/my-skill.md");
620 let target = make_target("claude-code", Scope::Local);
621 install_entry(&entry, &target, dir.path(), None).unwrap();
622
623 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
624 }
625
626 #[test]
629 fn install_github_entry_copy() {
630 let dir = tempfile::tempdir().unwrap();
631 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
632 std::fs::create_dir_all(&vdir).unwrap();
633 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
634
635 let entry = make_agent_entry("my-agent");
636 let target = make_target("claude-code", Scope::Local);
637 install_entry(&entry, &target, dir.path(), None).unwrap();
638
639 let dest = dir.path().join(".claude/agents/my-agent.md");
640 assert!(dest.exists());
641 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
642 }
643
644 #[test]
645 fn install_github_dir_entry_copy() {
646 let dir = tempfile::tempdir().unwrap();
647 let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
648 std::fs::create_dir_all(&vdir).unwrap();
649 std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
650 std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
651
652 let entry = Entry {
653 entity_type: EntityType::Skill,
654 name: "python-pro".into(),
655 source: SourceFields::Github {
656 owner_repo: "owner/repo".into(),
657 path_in_repo: "skills/python-pro".into(),
658 ref_: "main".into(),
659 },
660 };
661 let target = make_target("claude-code", Scope::Local);
662 install_entry(&entry, &target, dir.path(), None).unwrap();
663
664 let dest = dir.path().join(".claude/skills/python-pro");
665 assert!(dest.is_dir());
666 assert_eq!(
667 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
668 "# Python Pro"
669 );
670 }
671
672 #[test]
673 fn install_agent_dir_entry_explodes_to_individual_files() {
674 let dir = tempfile::tempdir().unwrap();
675 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
676 std::fs::create_dir_all(&vdir).unwrap();
677 std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
678 std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
679 std::fs::write(vdir.join(".meta"), "{}").unwrap();
680
681 let entry = Entry {
682 entity_type: EntityType::Agent,
683 name: "core-dev".into(),
684 source: SourceFields::Github {
685 owner_repo: "owner/repo".into(),
686 path_in_repo: "categories/core-dev".into(),
687 ref_: "main".into(),
688 },
689 };
690 let target = make_target("claude-code", Scope::Local);
691 install_entry(&entry, &target, dir.path(), None).unwrap();
692
693 let agents_dir = dir.path().join(".claude/agents");
694 assert_eq!(
695 std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
696 "# Backend"
697 );
698 assert_eq!(
699 std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
700 "# Frontend"
701 );
702 assert!(!agents_dir.join("core-dev").exists());
704 }
705
706 #[test]
707 fn install_entry_missing_source_warns() {
708 let dir = tempfile::tempdir().unwrap();
709 let entry = make_agent_entry("my-agent");
710 let target = make_target("claude-code", Scope::Local);
711
712 install_entry(&entry, &target, dir.path(), None).unwrap();
714 }
715
716 #[test]
719 fn install_applies_existing_patch() {
720 let dir = tempfile::tempdir().unwrap();
721
722 let vdir = dir.path().join(".skillfile/cache/skills/test");
724 std::fs::create_dir_all(&vdir).unwrap();
725 std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
726
727 let entry = Entry {
729 entity_type: EntityType::Skill,
730 name: "test".into(),
731 source: SourceFields::Github {
732 owner_repo: "owner/repo".into(),
733 path_in_repo: "skills/test.md".into(),
734 ref_: "main".into(),
735 },
736 };
737 let patch_text = skillfile_core::patch::generate_patch(
738 "# Test\n\nOriginal.\n",
739 "# Test\n\nModified.\n",
740 "test.md",
741 );
742 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
743
744 let target = make_target("claude-code", Scope::Local);
745 install_entry(&entry, &target, dir.path(), None).unwrap();
746
747 let dest = dir.path().join(".claude/skills/test.md");
748 assert_eq!(
749 std::fs::read_to_string(&dest).unwrap(),
750 "# Test\n\nModified.\n"
751 );
752 }
753
754 #[test]
755 fn install_patch_conflict_returns_error() {
756 let dir = tempfile::tempdir().unwrap();
757
758 let vdir = dir.path().join(".skillfile/cache/skills/test");
759 std::fs::create_dir_all(&vdir).unwrap();
760 std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
762
763 let entry = Entry {
764 entity_type: EntityType::Skill,
765 name: "test".into(),
766 source: SourceFields::Github {
767 owner_repo: "owner/repo".into(),
768 path_in_repo: "skills/test.md".into(),
769 ref_: "main".into(),
770 },
771 };
772 let bad_patch =
774 "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
775 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
776
777 let installed_dir = dir.path().join(".claude/skills");
779 std::fs::create_dir_all(&installed_dir).unwrap();
780 std::fs::write(
781 installed_dir.join("test.md"),
782 "totally different\ncontent\n",
783 )
784 .unwrap();
785
786 let target = make_target("claude-code", Scope::Local);
787 let result = install_entry(&entry, &target, dir.path(), None);
788 assert!(result.is_err());
789 matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
791 }
792
793 #[test]
796 fn install_local_skill_gemini_cli() {
797 let dir = tempfile::tempdir().unwrap();
798 let source_file = dir.path().join("skills/my-skill.md");
799 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
800 std::fs::write(&source_file, "# My Skill").unwrap();
801
802 let entry = make_local_entry("my-skill", "skills/my-skill.md");
803 let target = make_target("gemini-cli", Scope::Local);
804 install_entry(&entry, &target, dir.path(), None).unwrap();
805
806 let dest = dir.path().join(".gemini/skills/my-skill.md");
807 assert!(dest.exists());
808 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
809 }
810
811 #[test]
812 fn install_local_skill_codex() {
813 let dir = tempfile::tempdir().unwrap();
814 let source_file = dir.path().join("skills/my-skill.md");
815 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
816 std::fs::write(&source_file, "# My Skill").unwrap();
817
818 let entry = make_local_entry("my-skill", "skills/my-skill.md");
819 let target = make_target("codex", Scope::Local);
820 install_entry(&entry, &target, dir.path(), None).unwrap();
821
822 let dest = dir.path().join(".codex/skills/my-skill.md");
823 assert!(dest.exists());
824 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
825 }
826
827 #[test]
828 fn codex_skips_agent_entries() {
829 let dir = tempfile::tempdir().unwrap();
830 let entry = make_agent_entry("my-agent");
831 let target = make_target("codex", Scope::Local);
832 install_entry(&entry, &target, dir.path(), None).unwrap();
833
834 assert!(!dir.path().join(".codex").exists());
835 }
836
837 #[test]
838 fn install_github_agent_gemini_cli() {
839 let dir = tempfile::tempdir().unwrap();
840 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
841 std::fs::create_dir_all(&vdir).unwrap();
842 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
843
844 let entry = make_agent_entry("my-agent");
845 let target = make_target("gemini-cli", Scope::Local);
846 install_entry(
847 &entry,
848 &target,
849 dir.path(),
850 Some(&InstallOptions::default()),
851 )
852 .unwrap();
853
854 let dest = dir.path().join(".gemini/agents/my-agent.md");
855 assert!(dest.exists());
856 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
857 }
858
859 #[test]
860 fn install_skill_multi_adapter() {
861 for adapter in &["claude-code", "gemini-cli", "codex"] {
862 let dir = tempfile::tempdir().unwrap();
863 let source_file = dir.path().join("skills/my-skill.md");
864 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
865 std::fs::write(&source_file, "# Multi Skill").unwrap();
866
867 let entry = make_local_entry("my-skill", "skills/my-skill.md");
868 let target = make_target(adapter, Scope::Local);
869 install_entry(&entry, &target, dir.path(), None).unwrap();
870
871 let prefix = match *adapter {
872 "claude-code" => ".claude",
873 "gemini-cli" => ".gemini",
874 "codex" => ".codex",
875 _ => unreachable!(),
876 };
877 let dest = dir.path().join(format!("{prefix}/skills/my-skill.md"));
878 assert!(dest.exists(), "Failed for adapter {adapter}");
879 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
880 }
881 }
882
883 #[test]
886 fn cmd_install_no_manifest() {
887 let dir = tempfile::tempdir().unwrap();
888 let result = cmd_install(dir.path(), false, false, None);
889 assert!(result.is_err());
890 assert!(result.unwrap_err().to_string().contains("not found"));
891 }
892
893 #[test]
894 fn cmd_install_no_install_targets() {
895 let dir = tempfile::tempdir().unwrap();
896 std::fs::write(
897 dir.path().join("Skillfile"),
898 "local skill foo skills/foo.md\n",
899 )
900 .unwrap();
901
902 let result = cmd_install(dir.path(), false, false, None);
903 assert!(result.is_err());
904 assert!(result
905 .unwrap_err()
906 .to_string()
907 .contains("No install targets"));
908 }
909
910 #[test]
911 fn cmd_install_extra_targets_fallback() {
912 let dir = tempfile::tempdir().unwrap();
913 std::fs::write(
915 dir.path().join("Skillfile"),
916 "local skill foo skills/foo.md\n",
917 )
918 .unwrap();
919 let source_file = dir.path().join("skills/foo.md");
920 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
921 std::fs::write(&source_file, "# Foo").unwrap();
922
923 let targets = vec![make_target("claude-code", Scope::Local)];
925 cmd_install(dir.path(), false, false, Some(&targets)).unwrap();
926
927 let dest = dir.path().join(".claude/skills/foo.md");
928 assert!(
929 dest.exists(),
930 "extra_targets must be used when Skillfile has none"
931 );
932 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
933 }
934
935 #[test]
936 fn cmd_install_skillfile_targets_win_over_extra() {
937 let dir = tempfile::tempdir().unwrap();
938 std::fs::write(
940 dir.path().join("Skillfile"),
941 "install claude-code local\nlocal skill foo skills/foo.md\n",
942 )
943 .unwrap();
944 let source_file = dir.path().join("skills/foo.md");
945 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
946 std::fs::write(&source_file, "# Foo").unwrap();
947
948 let targets = vec![make_target("gemini-cli", Scope::Local)];
950 cmd_install(dir.path(), false, false, Some(&targets)).unwrap();
951
952 assert!(dir.path().join(".claude/skills/foo.md").exists());
954 assert!(!dir.path().join(".gemini").exists());
956 }
957
958 #[test]
959 fn cmd_install_dry_run_no_files() {
960 let dir = tempfile::tempdir().unwrap();
961 std::fs::write(
962 dir.path().join("Skillfile"),
963 "install claude-code local\nlocal skill foo skills/foo.md\n",
964 )
965 .unwrap();
966 let source_file = dir.path().join("skills/foo.md");
967 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
968 std::fs::write(&source_file, "# Foo").unwrap();
969
970 cmd_install(dir.path(), true, false, None).unwrap();
971
972 assert!(!dir.path().join(".claude").exists());
973 }
974
975 #[test]
976 fn cmd_install_deploys_to_multiple_adapters() {
977 let dir = tempfile::tempdir().unwrap();
978 std::fs::write(
979 dir.path().join("Skillfile"),
980 "install claude-code local\n\
981 install gemini-cli local\n\
982 install codex local\n\
983 local skill foo skills/foo.md\n\
984 local agent bar agents/bar.md\n",
985 )
986 .unwrap();
987 std::fs::create_dir_all(dir.path().join("skills")).unwrap();
988 std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
989 std::fs::create_dir_all(dir.path().join("agents")).unwrap();
990 std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
991
992 cmd_install(dir.path(), false, false, None).unwrap();
993
994 assert!(dir.path().join(".claude/skills/foo.md").exists());
996 assert!(dir.path().join(".gemini/skills/foo.md").exists());
997 assert!(dir.path().join(".codex/skills/foo.md").exists());
998
999 assert!(dir.path().join(".claude/agents/bar.md").exists());
1001 assert!(dir.path().join(".gemini/agents/bar.md").exists());
1002 assert!(!dir.path().join(".codex/agents").exists());
1003 }
1004
1005 #[test]
1006 fn cmd_install_pending_conflict_blocks() {
1007 use skillfile_core::conflict::write_conflict;
1008 use skillfile_core::models::ConflictState;
1009
1010 let dir = tempfile::tempdir().unwrap();
1011 std::fs::write(
1012 dir.path().join("Skillfile"),
1013 "install claude-code local\nlocal skill foo skills/foo.md\n",
1014 )
1015 .unwrap();
1016
1017 write_conflict(
1018 dir.path(),
1019 &ConflictState {
1020 entry: "foo".into(),
1021 entity_type: "skill".into(),
1022 old_sha: "aaa".into(),
1023 new_sha: "bbb".into(),
1024 },
1025 )
1026 .unwrap();
1027
1028 let result = cmd_install(dir.path(), false, false, None);
1029 assert!(result.is_err());
1030 assert!(result.unwrap_err().to_string().contains("pending conflict"));
1031 }
1032
1033 fn make_skill_entry(name: &str) -> Entry {
1039 Entry {
1040 entity_type: EntityType::Skill,
1041 name: name.into(),
1042 source: SourceFields::Github {
1043 owner_repo: "owner/repo".into(),
1044 path_in_repo: format!("skills/{name}.md"),
1045 ref_: "main".into(),
1046 },
1047 }
1048 }
1049
1050 fn make_dir_skill_entry(name: &str) -> Entry {
1052 Entry {
1053 entity_type: EntityType::Skill,
1054 name: name.into(),
1055 source: SourceFields::Github {
1056 owner_repo: "owner/repo".into(),
1057 path_in_repo: format!("skills/{name}"),
1058 ref_: "main".into(),
1059 },
1060 }
1061 }
1062
1063 fn setup_github_skill_repo(dir: &std::path::Path, name: &str, cache_content: &str) {
1065 use skillfile_core::lock::write_lock;
1066 use skillfile_core::models::LockEntry;
1067 use std::collections::BTreeMap;
1068
1069 std::fs::write(
1071 dir.join("Skillfile"),
1072 format!("install claude-code local\ngithub skill {name} owner/repo skills/{name}.md\n"),
1073 )
1074 .unwrap();
1075
1076 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1078 locked.insert(
1079 format!("github/skill/{name}"),
1080 LockEntry {
1081 sha: "abc123def456abc123def456abc123def456abc123".into(),
1082 raw_url: format!(
1083 "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
1084 ),
1085 },
1086 );
1087 write_lock(dir, &locked).unwrap();
1088
1089 let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
1091 std::fs::create_dir_all(&vdir).unwrap();
1092 std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
1093 }
1094
1095 #[test]
1100 fn auto_pin_entry_local_is_skipped() {
1101 let dir = tempfile::tempdir().unwrap();
1102
1103 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1105 let manifest = Manifest {
1106 entries: vec![entry.clone()],
1107 install_targets: vec![make_target("claude-code", Scope::Local)],
1108 };
1109
1110 let skills_dir = dir.path().join("skills");
1112 std::fs::create_dir_all(&skills_dir).unwrap();
1113 std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
1114
1115 auto_pin_entry(&entry, &manifest, dir.path());
1116
1117 assert!(
1119 !skillfile_core::patch::has_patch(&entry, dir.path()),
1120 "local entry must never be pinned"
1121 );
1122 }
1123
1124 #[test]
1125 fn auto_pin_entry_missing_lock_is_skipped() {
1126 let dir = tempfile::tempdir().unwrap();
1127
1128 let entry = make_skill_entry("test");
1129 let manifest = Manifest {
1130 entries: vec![entry.clone()],
1131 install_targets: vec![make_target("claude-code", Scope::Local)],
1132 };
1133
1134 auto_pin_entry(&entry, &manifest, dir.path());
1136
1137 assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1138 }
1139
1140 #[test]
1141 fn auto_pin_entry_missing_lock_key_is_skipped() {
1142 use skillfile_core::lock::write_lock;
1143 use skillfile_core::models::LockEntry;
1144 use std::collections::BTreeMap;
1145
1146 let dir = tempfile::tempdir().unwrap();
1147
1148 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1150 locked.insert(
1151 "github/skill/other".into(),
1152 LockEntry {
1153 sha: "aabbcc".into(),
1154 raw_url: "https://example.com/other.md".into(),
1155 },
1156 );
1157 write_lock(dir.path(), &locked).unwrap();
1158
1159 let entry = make_skill_entry("test");
1160 let manifest = Manifest {
1161 entries: vec![entry.clone()],
1162 install_targets: vec![make_target("claude-code", Scope::Local)],
1163 };
1164
1165 auto_pin_entry(&entry, &manifest, dir.path());
1166
1167 assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1168 }
1169
1170 #[test]
1171 fn auto_pin_entry_writes_patch_when_installed_differs() {
1172 let dir = tempfile::tempdir().unwrap();
1173 let name = "my-skill";
1174
1175 let cache_content = "# My Skill\n\nOriginal content.\n";
1176 let installed_content = "# My Skill\n\nUser-modified content.\n";
1177
1178 setup_github_skill_repo(dir.path(), name, cache_content);
1179
1180 let installed_dir = dir.path().join(".claude/skills");
1182 std::fs::create_dir_all(&installed_dir).unwrap();
1183 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1184
1185 let entry = make_skill_entry(name);
1186 let manifest = Manifest {
1187 entries: vec![entry.clone()],
1188 install_targets: vec![make_target("claude-code", Scope::Local)],
1189 };
1190
1191 auto_pin_entry(&entry, &manifest, dir.path());
1192
1193 assert!(
1194 skillfile_core::patch::has_patch(&entry, dir.path()),
1195 "patch should be written when installed differs from cache"
1196 );
1197
1198 let patch_text = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1200 let result = skillfile_core::patch::apply_patch_pure(cache_content, &patch_text).unwrap();
1201 assert_eq!(result, installed_content);
1202 }
1203
1204 #[test]
1205 fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1206 let dir = tempfile::tempdir().unwrap();
1207 let name = "my-skill";
1208
1209 let cache_content = "# My Skill\n\nOriginal.\n";
1210 let installed_content = "# My Skill\n\nModified.\n";
1211
1212 setup_github_skill_repo(dir.path(), name, cache_content);
1213
1214 let entry = make_skill_entry(name);
1215 let manifest = Manifest {
1216 entries: vec![entry.clone()],
1217 install_targets: vec![make_target("claude-code", Scope::Local)],
1218 };
1219
1220 let patch_text = skillfile_core::patch::generate_patch(
1222 cache_content,
1223 installed_content,
1224 &format!("{name}.md"),
1225 );
1226 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1227
1228 let installed_dir = dir.path().join(".claude/skills");
1230 std::fs::create_dir_all(&installed_dir).unwrap();
1231 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1232
1233 let patch_path = skillfile_core::patch::patch_path(&entry, dir.path());
1235 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1236
1237 std::thread::sleep(std::time::Duration::from_millis(20));
1239
1240 auto_pin_entry(&entry, &manifest, dir.path());
1241
1242 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1243
1244 assert_eq!(
1245 mtime_before, mtime_after,
1246 "patch must not be rewritten when already up to date"
1247 );
1248 }
1249
1250 #[test]
1251 fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1252 let dir = tempfile::tempdir().unwrap();
1253 let name = "my-skill";
1254
1255 let cache_content = "# My Skill\n\nOriginal.\n";
1256 let old_installed = "# My Skill\n\nFirst edit.\n";
1257 let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1258
1259 setup_github_skill_repo(dir.path(), name, cache_content);
1260
1261 let entry = make_skill_entry(name);
1262 let manifest = Manifest {
1263 entries: vec![entry.clone()],
1264 install_targets: vec![make_target("claude-code", Scope::Local)],
1265 };
1266
1267 let old_patch = skillfile_core::patch::generate_patch(
1269 cache_content,
1270 old_installed,
1271 &format!("{name}.md"),
1272 );
1273 skillfile_core::patch::write_patch(&entry, &old_patch, dir.path()).unwrap();
1274
1275 let installed_dir = dir.path().join(".claude/skills");
1277 std::fs::create_dir_all(&installed_dir).unwrap();
1278 std::fs::write(installed_dir.join(format!("{name}.md")), new_installed).unwrap();
1279
1280 auto_pin_entry(&entry, &manifest, dir.path());
1281
1282 let new_patch = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1284 let result = skillfile_core::patch::apply_patch_pure(cache_content, &new_patch).unwrap();
1285 assert_eq!(
1286 result, new_installed,
1287 "updated patch must describe the latest installed content"
1288 );
1289 }
1290
1291 #[test]
1296 fn auto_pin_dir_entry_writes_per_file_patches() {
1297 use skillfile_core::lock::write_lock;
1298 use skillfile_core::models::LockEntry;
1299 use std::collections::BTreeMap;
1300
1301 let dir = tempfile::tempdir().unwrap();
1302 let name = "lang-pro";
1303
1304 std::fs::write(
1306 dir.path().join("Skillfile"),
1307 format!(
1308 "install claude-code local\ngithub skill {name} owner/repo skills/{name}\n"
1309 ),
1310 )
1311 .unwrap();
1312 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1313 locked.insert(
1314 format!("github/skill/{name}"),
1315 LockEntry {
1316 sha: "deadbeefdeadbeefdeadbeef".into(),
1317 raw_url: format!("https://example.com/{name}"),
1318 },
1319 );
1320 write_lock(dir.path(), &locked).unwrap();
1321
1322 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1324 std::fs::create_dir_all(&vdir).unwrap();
1325 std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1326 std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1327
1328 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1330 std::fs::create_dir_all(&inst_dir).unwrap();
1331 std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1332 std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1333
1334 let entry = make_dir_skill_entry(name);
1335 let manifest = Manifest {
1336 entries: vec![entry.clone()],
1337 install_targets: vec![make_target("claude-code", Scope::Local)],
1338 };
1339
1340 auto_pin_entry(&entry, &manifest, dir.path());
1341
1342 let skill_patch = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1344 assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1345
1346 let examples_patch =
1348 skillfile_core::patch::dir_patch_path(&entry, "examples.md", dir.path());
1349 assert!(
1350 !examples_patch.exists(),
1351 "patch for examples.md must not be written (content unchanged)"
1352 );
1353 }
1354
1355 #[test]
1356 fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1357 use skillfile_core::lock::write_lock;
1358 use skillfile_core::models::LockEntry;
1359 use std::collections::BTreeMap;
1360
1361 let dir = tempfile::tempdir().unwrap();
1362 let name = "lang-pro";
1363
1364 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1366 locked.insert(
1367 format!("github/skill/{name}"),
1368 LockEntry {
1369 sha: "abc".into(),
1370 raw_url: "https://example.com".into(),
1371 },
1372 );
1373 write_lock(dir.path(), &locked).unwrap();
1374
1375 let entry = make_dir_skill_entry(name);
1376 let manifest = Manifest {
1377 entries: vec![entry.clone()],
1378 install_targets: vec![make_target("claude-code", Scope::Local)],
1379 };
1380
1381 auto_pin_entry(&entry, &manifest, dir.path());
1383
1384 assert!(!skillfile_core::patch::has_dir_patch(&entry, dir.path()));
1385 }
1386
1387 #[test]
1388 fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1389 use skillfile_core::lock::write_lock;
1390 use skillfile_core::models::LockEntry;
1391 use std::collections::BTreeMap;
1392
1393 let dir = tempfile::tempdir().unwrap();
1394 let name = "lang-pro";
1395
1396 let cache_content = "# Lang Pro\n\nOriginal.\n";
1397 let modified = "# Lang Pro\n\nModified.\n";
1398
1399 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1401 locked.insert(
1402 format!("github/skill/{name}"),
1403 LockEntry {
1404 sha: "abc".into(),
1405 raw_url: "https://example.com".into(),
1406 },
1407 );
1408 write_lock(dir.path(), &locked).unwrap();
1409
1410 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1412 std::fs::create_dir_all(&vdir).unwrap();
1413 std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1414
1415 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1417 std::fs::create_dir_all(&inst_dir).unwrap();
1418 std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1419
1420 let entry = make_dir_skill_entry(name);
1421 let manifest = Manifest {
1422 entries: vec![entry.clone()],
1423 install_targets: vec![make_target("claude-code", Scope::Local)],
1424 };
1425
1426 let patch_text = skillfile_core::patch::generate_patch(cache_content, modified, "SKILL.md");
1428 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1429 .unwrap();
1430
1431 let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1432 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1433
1434 std::thread::sleep(std::time::Duration::from_millis(20));
1435
1436 auto_pin_entry(&entry, &manifest, dir.path());
1437
1438 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1439
1440 assert_eq!(
1441 mtime_before, mtime_after,
1442 "dir patch must not be rewritten when already up to date"
1443 );
1444 }
1445
1446 #[test]
1451 fn apply_dir_patches_applies_patch_and_rebases() {
1452 let dir = tempfile::tempdir().unwrap();
1453
1454 let cache_content = "# Skill\n\nOriginal.\n";
1456 let installed_content = "# Skill\n\nModified.\n";
1457 let new_cache_content = "# Skill\n\nOriginal v2.\n";
1459 let expected_rebased_to_new_cache = installed_content;
1462
1463 let entry = make_dir_skill_entry("lang-pro");
1464
1465 let patch_text =
1467 skillfile_core::patch::generate_patch(cache_content, installed_content, "SKILL.md");
1468 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1469 .unwrap();
1470
1471 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1473 std::fs::create_dir_all(&inst_dir).unwrap();
1474 std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1475
1476 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1478 std::fs::create_dir_all(&new_cache_dir).unwrap();
1479 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1480
1481 let mut installed_files = std::collections::HashMap::new();
1483 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1484
1485 apply_dir_patches(&entry, &installed_files, &new_cache_dir, dir.path()).unwrap();
1486
1487 let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1489 assert_eq!(installed_after, installed_content);
1490
1491 let rebased_patch = std::fs::read_to_string(skillfile_core::patch::dir_patch_path(
1494 &entry,
1495 "SKILL.md",
1496 dir.path(),
1497 ))
1498 .unwrap();
1499 let rebase_result =
1500 skillfile_core::patch::apply_patch_pure(new_cache_content, &rebased_patch).unwrap();
1501 assert_eq!(
1502 rebase_result, expected_rebased_to_new_cache,
1503 "rebased patch applied to new_cache must reproduce installed_content"
1504 );
1505 }
1506
1507 #[test]
1508 fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1509 let dir = tempfile::tempdir().unwrap();
1510
1511 let original = "# Skill\n\nOriginal.\n";
1513 let modified = "# Skill\n\nModified.\n";
1514 let new_cache = modified; let entry = make_dir_skill_entry("lang-pro");
1518
1519 let patch_text = skillfile_core::patch::generate_patch(original, modified, "SKILL.md");
1520 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1521 .unwrap();
1522
1523 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1525 std::fs::create_dir_all(&inst_dir).unwrap();
1526 std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1527
1528 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1529 std::fs::create_dir_all(&new_cache_dir).unwrap();
1530 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1531
1532 let mut installed_files = std::collections::HashMap::new();
1533 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1534
1535 apply_dir_patches(&entry, &installed_files, &new_cache_dir, dir.path()).unwrap();
1536
1537 let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1539 assert!(
1540 !patch_path.exists(),
1541 "patch file must be removed when rebase yields empty diff"
1542 );
1543 }
1544
1545 #[test]
1546 fn apply_dir_patches_no_op_when_no_patches_dir() {
1547 let dir = tempfile::tempdir().unwrap();
1548
1549 let entry = make_dir_skill_entry("lang-pro");
1551 let installed_files = std::collections::HashMap::new();
1552 let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1553 std::fs::create_dir_all(&source_dir).unwrap();
1554
1555 apply_dir_patches(&entry, &installed_files, &source_dir, dir.path()).unwrap();
1557 }
1558
1559 #[test]
1564 fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1565 let dir = tempfile::tempdir().unwrap();
1566
1567 let original = "# Skill\n\nOriginal.\n";
1568 let modified = "# Skill\n\nModified.\n";
1569 let new_cache = modified;
1571
1572 let entry = make_skill_entry("test");
1573
1574 let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1576 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1577
1578 let vdir = dir.path().join(".skillfile/cache/skills/test");
1580 std::fs::create_dir_all(&vdir).unwrap();
1581 let source = vdir.join("test.md");
1582 std::fs::write(&source, new_cache).unwrap();
1583
1584 let installed_dir = dir.path().join(".claude/skills");
1586 std::fs::create_dir_all(&installed_dir).unwrap();
1587 let dest = installed_dir.join("test.md");
1588 std::fs::write(&dest, original).unwrap();
1589
1590 apply_single_file_patch(&entry, &dest, &source, dir.path()).unwrap();
1591
1592 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1594
1595 assert!(
1597 !skillfile_core::patch::has_patch(&entry, dir.path()),
1598 "patch must be removed when new cache already matches patched content"
1599 );
1600 }
1601
1602 #[test]
1603 fn apply_single_file_patch_rewrites_patch_after_rebase() {
1604 let dir = tempfile::tempdir().unwrap();
1605
1606 let original = "# Skill\n\nOriginal.\n";
1608 let modified = "# Skill\n\nModified.\n";
1609 let new_cache = "# Skill\n\nOriginal v2.\n";
1610 let expected_rebased_result = modified;
1613
1614 let entry = make_skill_entry("test");
1615
1616 let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1617 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1618
1619 let vdir = dir.path().join(".skillfile/cache/skills/test");
1621 std::fs::create_dir_all(&vdir).unwrap();
1622 let source = vdir.join("test.md");
1623 std::fs::write(&source, new_cache).unwrap();
1624
1625 let installed_dir = dir.path().join(".claude/skills");
1627 std::fs::create_dir_all(&installed_dir).unwrap();
1628 let dest = installed_dir.join("test.md");
1629 std::fs::write(&dest, original).unwrap();
1630
1631 apply_single_file_patch(&entry, &dest, &source, dir.path()).unwrap();
1632
1633 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1635
1636 assert!(
1639 skillfile_core::patch::has_patch(&entry, dir.path()),
1640 "rebased patch must still exist (new_cache != modified)"
1641 );
1642 let rebased = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1643 let result = skillfile_core::patch::apply_patch_pure(new_cache, &rebased).unwrap();
1644 assert_eq!(
1645 result, expected_rebased_result,
1646 "rebased patch applied to new_cache must reproduce installed content"
1647 );
1648 }
1649
1650 #[test]
1655 fn check_preconditions_no_targets_returns_error() {
1656 let dir = tempfile::tempdir().unwrap();
1657 let manifest = Manifest {
1658 entries: vec![],
1659 install_targets: vec![],
1660 };
1661 let result = check_preconditions(&manifest, dir.path());
1662 assert!(result.is_err());
1663 assert!(result
1664 .unwrap_err()
1665 .to_string()
1666 .contains("No install targets"));
1667 }
1668
1669 #[test]
1670 fn check_preconditions_pending_conflict_returns_error() {
1671 use skillfile_core::conflict::write_conflict;
1672 use skillfile_core::models::ConflictState;
1673
1674 let dir = tempfile::tempdir().unwrap();
1675 let manifest = Manifest {
1676 entries: vec![],
1677 install_targets: vec![make_target("claude-code", Scope::Local)],
1678 };
1679
1680 write_conflict(
1681 dir.path(),
1682 &ConflictState {
1683 entry: "my-skill".into(),
1684 entity_type: "skill".into(),
1685 old_sha: "aaa".into(),
1686 new_sha: "bbb".into(),
1687 },
1688 )
1689 .unwrap();
1690
1691 let result = check_preconditions(&manifest, dir.path());
1692 assert!(result.is_err());
1693 assert!(result.unwrap_err().to_string().contains("pending conflict"));
1694 }
1695
1696 #[test]
1697 fn check_preconditions_ok_with_target_and_no_conflict() {
1698 let dir = tempfile::tempdir().unwrap();
1699 let manifest = Manifest {
1700 entries: vec![],
1701 install_targets: vec![make_target("claude-code", Scope::Local)],
1702 };
1703 check_preconditions(&manifest, dir.path()).unwrap();
1704 }
1705
1706 #[test]
1711 fn deploy_all_patch_conflict_writes_conflict_state() {
1712 use skillfile_core::conflict::{has_conflict, read_conflict};
1713 use skillfile_core::lock::write_lock;
1714 use skillfile_core::models::LockEntry;
1715 use std::collections::BTreeMap;
1716
1717 let dir = tempfile::tempdir().unwrap();
1718 let name = "test";
1719
1720 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1722 std::fs::create_dir_all(&vdir).unwrap();
1723 std::fs::write(
1724 vdir.join(format!("{name}.md")),
1725 "totally different content\n",
1726 )
1727 .unwrap();
1728
1729 let entry = make_skill_entry(name);
1731 let bad_patch =
1732 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-expected_original_line\n+modified\n";
1733 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1734
1735 let inst_dir = dir.path().join(".claude/skills");
1737 std::fs::create_dir_all(&inst_dir).unwrap();
1738 std::fs::write(
1739 inst_dir.join(format!("{name}.md")),
1740 "totally different content\n",
1741 )
1742 .unwrap();
1743
1744 let manifest = Manifest {
1746 entries: vec![entry.clone()],
1747 install_targets: vec![make_target("claude-code", Scope::Local)],
1748 };
1749
1750 let lock_key_str = format!("github/skill/{name}");
1752 let old_sha = "a".repeat(40);
1753 let new_sha = "b".repeat(40);
1754
1755 let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1756 old_locked.insert(
1757 lock_key_str.clone(),
1758 LockEntry {
1759 sha: old_sha.clone(),
1760 raw_url: "https://example.com/old.md".into(),
1761 },
1762 );
1763
1764 let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1765 new_locked.insert(
1766 lock_key_str,
1767 LockEntry {
1768 sha: new_sha.clone(),
1769 raw_url: "https://example.com/new.md".into(),
1770 },
1771 );
1772
1773 write_lock(dir.path(), &new_locked).unwrap();
1774
1775 let opts = InstallOptions {
1776 dry_run: false,
1777 overwrite: true,
1778 };
1779
1780 let result = deploy_all(&manifest, dir.path(), &opts, &new_locked, &old_locked);
1781
1782 assert!(
1784 result.is_err(),
1785 "deploy_all must return Err on PatchConflict"
1786 );
1787 let err_msg = result.unwrap_err().to_string();
1788 assert!(
1789 err_msg.contains("conflict"),
1790 "error message must mention conflict: {err_msg}"
1791 );
1792
1793 assert!(
1795 has_conflict(dir.path()),
1796 "conflict state file must be written after PatchConflict"
1797 );
1798
1799 let conflict = read_conflict(dir.path()).unwrap().unwrap();
1800 assert_eq!(conflict.entry, name);
1801 assert_eq!(conflict.old_sha, old_sha);
1802 assert_eq!(conflict.new_sha, new_sha);
1803 }
1804
1805 #[test]
1806 fn deploy_all_patch_conflict_error_message_contains_sha_context() {
1807 use skillfile_core::lock::write_lock;
1808 use skillfile_core::models::LockEntry;
1809 use std::collections::BTreeMap;
1810
1811 let dir = tempfile::tempdir().unwrap();
1812 let name = "test";
1813
1814 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1815 std::fs::create_dir_all(&vdir).unwrap();
1816 std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
1817
1818 let entry = make_skill_entry(name);
1819 let bad_patch =
1820 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
1821 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1822
1823 let inst_dir = dir.path().join(".claude/skills");
1824 std::fs::create_dir_all(&inst_dir).unwrap();
1825 std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
1826
1827 let manifest = Manifest {
1828 entries: vec![entry.clone()],
1829 install_targets: vec![make_target("claude-code", Scope::Local)],
1830 };
1831
1832 let lock_key_str = format!("github/skill/{name}");
1833 let old_sha = "aabbccddeeff001122334455aabbccddeeff0011".to_string();
1834 let new_sha = "99887766554433221100ffeeddccbbaa99887766".to_string();
1835
1836 let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1837 old_locked.insert(
1838 lock_key_str.clone(),
1839 LockEntry {
1840 sha: old_sha.clone(),
1841 raw_url: "https://example.com/old.md".into(),
1842 },
1843 );
1844
1845 let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1846 new_locked.insert(
1847 lock_key_str,
1848 LockEntry {
1849 sha: new_sha.clone(),
1850 raw_url: "https://example.com/new.md".into(),
1851 },
1852 );
1853
1854 write_lock(dir.path(), &new_locked).unwrap();
1855
1856 let opts = InstallOptions {
1857 dry_run: false,
1858 overwrite: true,
1859 };
1860
1861 let result = deploy_all(&manifest, dir.path(), &opts, &new_locked, &old_locked);
1862 assert!(result.is_err());
1863
1864 let err_msg = result.unwrap_err().to_string();
1865
1866 assert!(
1868 err_msg.contains('\u{2192}'),
1869 "error message must contain the SHA arrow (→): {err_msg}"
1870 );
1871 assert!(
1873 err_msg.contains(&old_sha[..12]),
1874 "error must contain old SHA prefix: {err_msg}"
1875 );
1876 assert!(
1877 err_msg.contains(&new_sha[..12]),
1878 "error must contain new SHA prefix: {err_msg}"
1879 );
1880 }
1881
1882 #[test]
1883 fn deploy_all_patch_conflict_error_message_has_resolve_hints() {
1884 use skillfile_core::lock::write_lock;
1885 use skillfile_core::models::LockEntry;
1886 use std::collections::BTreeMap;
1887
1888 let dir = tempfile::tempdir().unwrap();
1889 let name = "test";
1890
1891 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1892 std::fs::create_dir_all(&vdir).unwrap();
1893 std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
1894
1895 let entry = make_skill_entry(name);
1896 let bad_patch =
1897 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
1898 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1899
1900 let inst_dir = dir.path().join(".claude/skills");
1901 std::fs::create_dir_all(&inst_dir).unwrap();
1902 std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
1903
1904 let manifest = Manifest {
1905 entries: vec![entry.clone()],
1906 install_targets: vec![make_target("claude-code", Scope::Local)],
1907 };
1908
1909 let lock_key_str = format!("github/skill/{name}");
1910 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1911 locked.insert(
1912 lock_key_str,
1913 LockEntry {
1914 sha: "abc123".into(),
1915 raw_url: "https://example.com/test.md".into(),
1916 },
1917 );
1918 write_lock(dir.path(), &locked).unwrap();
1919
1920 let opts = InstallOptions {
1921 dry_run: false,
1922 overwrite: true,
1923 };
1924
1925 let result = deploy_all(
1926 &manifest,
1927 dir.path(),
1928 &opts,
1929 &locked,
1930 &BTreeMap::new(), );
1932 assert!(result.is_err());
1933
1934 let err_msg = result.unwrap_err().to_string();
1935 assert!(
1936 err_msg.contains("skillfile resolve"),
1937 "error must mention resolve command: {err_msg}"
1938 );
1939 assert!(
1940 err_msg.contains("skillfile diff"),
1941 "error must mention diff command: {err_msg}"
1942 );
1943 assert!(
1944 err_msg.contains("--abort"),
1945 "error must mention --abort: {err_msg}"
1946 );
1947 }
1948
1949 #[test]
1950 fn deploy_all_unknown_platform_skips_gracefully() {
1951 use std::collections::BTreeMap;
1952
1953 let dir = tempfile::tempdir().unwrap();
1954
1955 let manifest = Manifest {
1957 entries: vec![],
1958 install_targets: vec![InstallTarget {
1959 adapter: "unknown-tool".into(),
1960 scope: Scope::Local,
1961 }],
1962 };
1963
1964 let opts = InstallOptions {
1965 dry_run: false,
1966 overwrite: true,
1967 };
1968
1969 deploy_all(
1971 &manifest,
1972 dir.path(),
1973 &opts,
1974 &BTreeMap::new(),
1975 &BTreeMap::new(),
1976 )
1977 .unwrap();
1978 }
1979}