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