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 auto_pin_dir_entry(entry: &Entry, manifest: &Manifest, repo_root: &Path, vdir: &Path) {
192 if !vdir.is_dir() {
193 return;
194 }
195
196 let installed = match installed_dir_files(entry, manifest, repo_root) {
197 Ok(m) => m,
198 Err(_) => return,
199 };
200 if installed.is_empty() {
201 return;
202 }
203
204 let mut pinned: Vec<String> = Vec::new();
205 for cache_file in walkdir(vdir) {
206 if cache_file.file_name().is_some_and(|n| n == ".meta") {
207 continue;
208 }
209 let filename = match cache_file.strip_prefix(vdir).ok().and_then(|p| p.to_str()) {
210 Some(s) => s.to_string(),
211 None => continue,
212 };
213 let inst_path = match installed.get(&filename) {
214 Some(p) if p.exists() => p,
215 _ => continue,
216 };
217
218 let cache_text = match std::fs::read_to_string(&cache_file) {
219 Ok(s) => s,
220 Err(_) => continue,
221 };
222 let installed_text = match std::fs::read_to_string(inst_path) {
223 Ok(s) => s,
224 Err(_) => continue,
225 };
226
227 let p = dir_patch_path(entry, &filename, repo_root);
229 if p.exists() {
230 if let Ok(pt) = std::fs::read_to_string(&p) {
231 match apply_patch_pure(&cache_text, &pt) {
232 Ok(expected) if installed_text == expected => continue, Ok(_) => {} Err(_) => continue, }
236 }
237 }
238
239 let patch_text = generate_patch(&cache_text, &installed_text, &filename);
240 if !patch_text.is_empty()
241 && write_dir_patch(entry, &filename, &patch_text, repo_root).is_ok()
242 {
243 pinned.push(filename);
244 }
245 }
246
247 if !pinned.is_empty() {
248 progress!(
249 " {}: local changes auto-saved to .skillfile/patches/ ({})",
250 entry.name,
251 pinned.join(", ")
252 );
253 }
254}
255
256pub fn install_entry(
268 entry: &Entry,
269 target: &InstallTarget,
270 repo_root: &Path,
271 opts: Option<&InstallOptions>,
272) -> Result<(), SkillfileError> {
273 let default_opts = InstallOptions::default();
274 let opts = opts.unwrap_or(&default_opts);
275
276 let all_adapters = adapters();
277 let adapter = match all_adapters.get(&target.adapter) {
278 Some(a) => a,
279 None => return Ok(()),
280 };
281
282 if !adapter.supports(entry.entity_type.as_str()) {
283 return Ok(());
284 }
285
286 let source = match source_path(entry, repo_root) {
287 Some(p) if p.exists() => p,
288 _ => {
289 eprintln!(" warning: source missing for {}, skipping", entry.name);
290 return Ok(());
291 }
292 };
293
294 let is_dir = is_dir_entry(entry) || source.is_dir();
295 let installed = adapter.deploy_entry(entry, &source, target.scope, repo_root, opts);
296
297 if !installed.is_empty() && !opts.dry_run {
298 if is_dir {
299 apply_dir_patches(entry, &installed, &source, repo_root)?;
300 } else {
301 let key = format!("{}.md", entry.name);
302 if let Some(dest) = installed.get(&key) {
303 apply_single_file_patch(entry, dest, &source, repo_root)?;
304 }
305 }
306 }
307
308 Ok(())
309}
310
311fn check_preconditions(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
316 if manifest.install_targets.is_empty() {
317 return Err(SkillfileError::Manifest(
318 "No install targets configured. Run `skillfile init` first.".into(),
319 ));
320 }
321
322 if let Some(conflict) = read_conflict(repo_root)? {
323 return Err(SkillfileError::Install(format!(
324 "pending conflict for '{}' — \
325 run `skillfile diff {}` to review, \
326 or `skillfile resolve {}` to merge",
327 conflict.entry, conflict.entry, conflict.entry
328 )));
329 }
330
331 Ok(())
332}
333
334fn deploy_all(
339 manifest: &Manifest,
340 repo_root: &Path,
341 opts: &InstallOptions,
342 locked: &std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
343 old_locked: &std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
344) -> Result<(), SkillfileError> {
345 let mode = if opts.dry_run { " [dry-run]" } else { "" };
346 let all_adapters = adapters();
347
348 for target in &manifest.install_targets {
349 if !all_adapters.contains(&target.adapter) {
350 eprintln!("warning: unknown platform '{}', skipping", target.adapter);
351 continue;
352 }
353 progress!(
354 "Installing for {} ({}){mode}...",
355 target.adapter,
356 target.scope
357 );
358 for entry in &manifest.entries {
359 match install_entry(entry, target, repo_root, Some(opts)) {
360 Ok(()) => {}
361 Err(SkillfileError::PatchConflict { entry_name, .. }) => {
362 let key = lock_key(entry);
363 let old_sha = old_locked
364 .get(&key)
365 .map(|l| l.sha.clone())
366 .unwrap_or_default();
367 let new_sha = locked
368 .get(&key)
369 .map(|l| l.sha.clone())
370 .unwrap_or_else(|| old_sha.clone());
371
372 write_conflict(
373 repo_root,
374 &ConflictState {
375 entry: entry_name.clone(),
376 entity_type: entry.entity_type.to_string(),
377 old_sha: old_sha.clone(),
378 new_sha: new_sha.clone(),
379 },
380 )?;
381
382 let sha_info =
383 if !old_sha.is_empty() && !new_sha.is_empty() && old_sha != new_sha {
384 format!(
385 "\n upstream: {} \u{2192} {}",
386 short_sha(&old_sha),
387 short_sha(&new_sha)
388 )
389 } else {
390 String::new()
391 };
392
393 return Err(SkillfileError::Install(format!(
394 "upstream changes to '{entry_name}' conflict with your customisations.{sha_info}\n\
395 Your pinned edits could not be applied to the new upstream version.\n\
396 Run `skillfile diff {entry_name}` to review what changed upstream.\n\
397 Run `skillfile resolve {entry_name}` when ready to merge.\n\
398 Run `skillfile resolve --abort` to discard the conflict and keep the old version."
399 )));
400 }
401 Err(e) => return Err(e),
402 }
403 }
404 }
405
406 Ok(())
407}
408
409pub fn cmd_install(repo_root: &Path, dry_run: bool, update: bool) -> Result<(), SkillfileError> {
414 let manifest_path = repo_root.join(MANIFEST_NAME);
415 if !manifest_path.exists() {
416 return Err(SkillfileError::Manifest(format!(
417 "{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
418 repo_root.display()
419 )));
420 }
421
422 let result = parse_manifest(&manifest_path)?;
423 for w in &result.warnings {
424 eprintln!("{w}");
425 }
426 let manifest = result.manifest;
427
428 check_preconditions(&manifest, repo_root)?;
429
430 let old_locked = read_lock(repo_root).unwrap_or_default();
432
433 if update && !dry_run {
435 for entry in &manifest.entries {
436 auto_pin_entry(entry, &manifest, repo_root);
437 }
438 }
439
440 cmd_sync(repo_root, dry_run, None, update)?;
442
443 let locked = read_lock(repo_root).unwrap_or_default();
445
446 let opts = InstallOptions {
448 dry_run,
449 overwrite: update,
450 };
451 deploy_all(&manifest, repo_root, &opts, &locked, &old_locked)?;
452
453 if !dry_run {
454 progress!("Done.");
455 }
456
457 Ok(())
458}
459
460#[cfg(test)]
465mod tests {
466 use super::*;
467 use skillfile_core::models::{EntityType, Entry, InstallTarget, Scope, SourceFields};
468
469 fn make_agent_entry(name: &str) -> Entry {
470 Entry {
471 entity_type: EntityType::Agent,
472 name: name.into(),
473 source: SourceFields::Github {
474 owner_repo: "owner/repo".into(),
475 path_in_repo: "agents/agent.md".into(),
476 ref_: "main".into(),
477 },
478 }
479 }
480
481 fn make_local_entry(name: &str, path: &str) -> Entry {
482 Entry {
483 entity_type: EntityType::Skill,
484 name: name.into(),
485 source: SourceFields::Local { path: path.into() },
486 }
487 }
488
489 fn make_target(adapter: &str, scope: Scope) -> InstallTarget {
490 InstallTarget {
491 adapter: adapter.into(),
492 scope,
493 }
494 }
495
496 #[test]
499 fn install_local_entry_copy() {
500 let dir = tempfile::tempdir().unwrap();
501 let source_file = dir.path().join("skills/my-skill.md");
502 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
503 std::fs::write(&source_file, "# My Skill").unwrap();
504
505 let entry = make_local_entry("my-skill", "skills/my-skill.md");
506 let target = make_target("claude-code", Scope::Local);
507 install_entry(&entry, &target, dir.path(), None).unwrap();
508
509 let dest = dir.path().join(".claude/skills/my-skill.md");
510 assert!(dest.exists());
511 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
512 }
513
514 #[test]
515 fn install_local_dir_entry_copy() {
516 let dir = tempfile::tempdir().unwrap();
517 let source_dir = dir.path().join("skills/python-testing");
519 std::fs::create_dir_all(&source_dir).unwrap();
520 std::fs::write(source_dir.join("SKILL.md"), "# Python Testing").unwrap();
521 std::fs::write(source_dir.join("examples.md"), "# Examples").unwrap();
522
523 let entry = make_local_entry("python-testing", "skills/python-testing");
524 let target = make_target("claude-code", Scope::Local);
525 install_entry(&entry, &target, dir.path(), None).unwrap();
526
527 let dest = dir.path().join(".claude/skills/python-testing");
529 assert!(dest.is_dir(), "local dir entry must deploy as directory");
530 assert_eq!(
531 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
532 "# Python Testing"
533 );
534 assert_eq!(
535 std::fs::read_to_string(dest.join("examples.md")).unwrap(),
536 "# Examples"
537 );
538 assert!(
540 !dir.path().join(".claude/skills/python-testing.md").exists(),
541 "should not create python-testing.md for a dir source"
542 );
543 }
544
545 #[test]
546 fn install_entry_dry_run_no_write() {
547 let dir = tempfile::tempdir().unwrap();
548 let source_file = dir.path().join("skills/my-skill.md");
549 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
550 std::fs::write(&source_file, "# My Skill").unwrap();
551
552 let entry = make_local_entry("my-skill", "skills/my-skill.md");
553 let target = make_target("claude-code", Scope::Local);
554 let opts = InstallOptions {
555 dry_run: true,
556 ..Default::default()
557 };
558 install_entry(&entry, &target, dir.path(), Some(&opts)).unwrap();
559
560 let dest = dir.path().join(".claude/skills/my-skill.md");
561 assert!(!dest.exists());
562 }
563
564 #[test]
565 fn install_entry_overwrites_existing() {
566 let dir = tempfile::tempdir().unwrap();
567 let source_file = dir.path().join("skills/my-skill.md");
568 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
569 std::fs::write(&source_file, "# New content").unwrap();
570
571 let dest_dir = dir.path().join(".claude/skills");
572 std::fs::create_dir_all(&dest_dir).unwrap();
573 let dest = dest_dir.join("my-skill.md");
574 std::fs::write(&dest, "# Old content").unwrap();
575
576 let entry = make_local_entry("my-skill", "skills/my-skill.md");
577 let target = make_target("claude-code", Scope::Local);
578 install_entry(&entry, &target, dir.path(), None).unwrap();
579
580 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
581 }
582
583 #[test]
586 fn install_github_entry_copy() {
587 let dir = tempfile::tempdir().unwrap();
588 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
589 std::fs::create_dir_all(&vdir).unwrap();
590 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
591
592 let entry = make_agent_entry("my-agent");
593 let target = make_target("claude-code", Scope::Local);
594 install_entry(&entry, &target, dir.path(), None).unwrap();
595
596 let dest = dir.path().join(".claude/agents/my-agent.md");
597 assert!(dest.exists());
598 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
599 }
600
601 #[test]
602 fn install_github_dir_entry_copy() {
603 let dir = tempfile::tempdir().unwrap();
604 let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
605 std::fs::create_dir_all(&vdir).unwrap();
606 std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
607 std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
608
609 let entry = Entry {
610 entity_type: EntityType::Skill,
611 name: "python-pro".into(),
612 source: SourceFields::Github {
613 owner_repo: "owner/repo".into(),
614 path_in_repo: "skills/python-pro".into(),
615 ref_: "main".into(),
616 },
617 };
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/skills/python-pro");
622 assert!(dest.is_dir());
623 assert_eq!(
624 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
625 "# Python Pro"
626 );
627 }
628
629 #[test]
630 fn install_agent_dir_entry_explodes_to_individual_files() {
631 let dir = tempfile::tempdir().unwrap();
632 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
633 std::fs::create_dir_all(&vdir).unwrap();
634 std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
635 std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
636 std::fs::write(vdir.join(".meta"), "{}").unwrap();
637
638 let entry = Entry {
639 entity_type: EntityType::Agent,
640 name: "core-dev".into(),
641 source: SourceFields::Github {
642 owner_repo: "owner/repo".into(),
643 path_in_repo: "categories/core-dev".into(),
644 ref_: "main".into(),
645 },
646 };
647 let target = make_target("claude-code", Scope::Local);
648 install_entry(&entry, &target, dir.path(), None).unwrap();
649
650 let agents_dir = dir.path().join(".claude/agents");
651 assert_eq!(
652 std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
653 "# Backend"
654 );
655 assert_eq!(
656 std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
657 "# Frontend"
658 );
659 assert!(!agents_dir.join("core-dev").exists());
661 }
662
663 #[test]
664 fn install_entry_missing_source_warns() {
665 let dir = tempfile::tempdir().unwrap();
666 let entry = make_agent_entry("my-agent");
667 let target = make_target("claude-code", Scope::Local);
668
669 install_entry(&entry, &target, dir.path(), None).unwrap();
671 }
672
673 #[test]
676 fn install_applies_existing_patch() {
677 let dir = tempfile::tempdir().unwrap();
678
679 let vdir = dir.path().join(".skillfile/cache/skills/test");
681 std::fs::create_dir_all(&vdir).unwrap();
682 std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
683
684 let entry = Entry {
686 entity_type: EntityType::Skill,
687 name: "test".into(),
688 source: SourceFields::Github {
689 owner_repo: "owner/repo".into(),
690 path_in_repo: "skills/test.md".into(),
691 ref_: "main".into(),
692 },
693 };
694 let patch_text = skillfile_core::patch::generate_patch(
695 "# Test\n\nOriginal.\n",
696 "# Test\n\nModified.\n",
697 "test.md",
698 );
699 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
700
701 let target = make_target("claude-code", Scope::Local);
702 install_entry(&entry, &target, dir.path(), None).unwrap();
703
704 let dest = dir.path().join(".claude/skills/test.md");
705 assert_eq!(
706 std::fs::read_to_string(&dest).unwrap(),
707 "# Test\n\nModified.\n"
708 );
709 }
710
711 #[test]
712 fn install_patch_conflict_returns_error() {
713 let dir = tempfile::tempdir().unwrap();
714
715 let vdir = dir.path().join(".skillfile/cache/skills/test");
716 std::fs::create_dir_all(&vdir).unwrap();
717 std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
719
720 let entry = Entry {
721 entity_type: EntityType::Skill,
722 name: "test".into(),
723 source: SourceFields::Github {
724 owner_repo: "owner/repo".into(),
725 path_in_repo: "skills/test.md".into(),
726 ref_: "main".into(),
727 },
728 };
729 let bad_patch =
731 "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
732 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
733
734 let installed_dir = dir.path().join(".claude/skills");
736 std::fs::create_dir_all(&installed_dir).unwrap();
737 std::fs::write(
738 installed_dir.join("test.md"),
739 "totally different\ncontent\n",
740 )
741 .unwrap();
742
743 let target = make_target("claude-code", Scope::Local);
744 let result = install_entry(&entry, &target, dir.path(), None);
745 assert!(result.is_err());
746 matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
748 }
749
750 #[test]
753 fn install_local_skill_gemini_cli() {
754 let dir = tempfile::tempdir().unwrap();
755 let source_file = dir.path().join("skills/my-skill.md");
756 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
757 std::fs::write(&source_file, "# My Skill").unwrap();
758
759 let entry = make_local_entry("my-skill", "skills/my-skill.md");
760 let target = make_target("gemini-cli", Scope::Local);
761 install_entry(&entry, &target, dir.path(), None).unwrap();
762
763 let dest = dir.path().join(".gemini/skills/my-skill.md");
764 assert!(dest.exists());
765 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
766 }
767
768 #[test]
769 fn install_local_skill_codex() {
770 let dir = tempfile::tempdir().unwrap();
771 let source_file = dir.path().join("skills/my-skill.md");
772 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
773 std::fs::write(&source_file, "# My Skill").unwrap();
774
775 let entry = make_local_entry("my-skill", "skills/my-skill.md");
776 let target = make_target("codex", Scope::Local);
777 install_entry(&entry, &target, dir.path(), None).unwrap();
778
779 let dest = dir.path().join(".codex/skills/my-skill.md");
780 assert!(dest.exists());
781 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
782 }
783
784 #[test]
785 fn codex_skips_agent_entries() {
786 let dir = tempfile::tempdir().unwrap();
787 let entry = make_agent_entry("my-agent");
788 let target = make_target("codex", Scope::Local);
789 install_entry(&entry, &target, dir.path(), None).unwrap();
790
791 assert!(!dir.path().join(".codex").exists());
792 }
793
794 #[test]
795 fn install_github_agent_gemini_cli() {
796 let dir = tempfile::tempdir().unwrap();
797 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
798 std::fs::create_dir_all(&vdir).unwrap();
799 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
800
801 let entry = make_agent_entry("my-agent");
802 let target = make_target("gemini-cli", Scope::Local);
803 install_entry(
804 &entry,
805 &target,
806 dir.path(),
807 Some(&InstallOptions::default()),
808 )
809 .unwrap();
810
811 let dest = dir.path().join(".gemini/agents/my-agent.md");
812 assert!(dest.exists());
813 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
814 }
815
816 #[test]
817 fn install_skill_multi_adapter() {
818 for adapter in &["claude-code", "gemini-cli", "codex"] {
819 let dir = tempfile::tempdir().unwrap();
820 let source_file = dir.path().join("skills/my-skill.md");
821 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
822 std::fs::write(&source_file, "# Multi Skill").unwrap();
823
824 let entry = make_local_entry("my-skill", "skills/my-skill.md");
825 let target = make_target(adapter, Scope::Local);
826 install_entry(&entry, &target, dir.path(), None).unwrap();
827
828 let prefix = match *adapter {
829 "claude-code" => ".claude",
830 "gemini-cli" => ".gemini",
831 "codex" => ".codex",
832 _ => unreachable!(),
833 };
834 let dest = dir.path().join(format!("{prefix}/skills/my-skill.md"));
835 assert!(dest.exists(), "Failed for adapter {adapter}");
836 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
837 }
838 }
839
840 #[test]
843 fn cmd_install_no_manifest() {
844 let dir = tempfile::tempdir().unwrap();
845 let result = cmd_install(dir.path(), false, false);
846 assert!(result.is_err());
847 assert!(result.unwrap_err().to_string().contains("not found"));
848 }
849
850 #[test]
851 fn cmd_install_no_install_targets() {
852 let dir = tempfile::tempdir().unwrap();
853 std::fs::write(
854 dir.path().join("Skillfile"),
855 "local skill foo skills/foo.md\n",
856 )
857 .unwrap();
858
859 let result = cmd_install(dir.path(), false, false);
860 assert!(result.is_err());
861 assert!(result
862 .unwrap_err()
863 .to_string()
864 .contains("No install targets"));
865 }
866
867 #[test]
868 fn cmd_install_dry_run_no_files() {
869 let dir = tempfile::tempdir().unwrap();
870 std::fs::write(
871 dir.path().join("Skillfile"),
872 "install claude-code local\nlocal skill foo skills/foo.md\n",
873 )
874 .unwrap();
875 let source_file = dir.path().join("skills/foo.md");
876 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
877 std::fs::write(&source_file, "# Foo").unwrap();
878
879 cmd_install(dir.path(), true, false).unwrap();
880
881 assert!(!dir.path().join(".claude").exists());
882 }
883
884 #[test]
885 fn cmd_install_deploys_to_multiple_adapters() {
886 let dir = tempfile::tempdir().unwrap();
887 std::fs::write(
888 dir.path().join("Skillfile"),
889 "install claude-code local\n\
890 install gemini-cli local\n\
891 install codex local\n\
892 local skill foo skills/foo.md\n\
893 local agent bar agents/bar.md\n",
894 )
895 .unwrap();
896 std::fs::create_dir_all(dir.path().join("skills")).unwrap();
897 std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
898 std::fs::create_dir_all(dir.path().join("agents")).unwrap();
899 std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
900
901 cmd_install(dir.path(), false, false).unwrap();
902
903 assert!(dir.path().join(".claude/skills/foo.md").exists());
905 assert!(dir.path().join(".gemini/skills/foo.md").exists());
906 assert!(dir.path().join(".codex/skills/foo.md").exists());
907
908 assert!(dir.path().join(".claude/agents/bar.md").exists());
910 assert!(dir.path().join(".gemini/agents/bar.md").exists());
911 assert!(!dir.path().join(".codex/agents").exists());
912 }
913
914 #[test]
915 fn cmd_install_pending_conflict_blocks() {
916 use skillfile_core::conflict::write_conflict;
917 use skillfile_core::models::ConflictState;
918
919 let dir = tempfile::tempdir().unwrap();
920 std::fs::write(
921 dir.path().join("Skillfile"),
922 "install claude-code local\nlocal skill foo skills/foo.md\n",
923 )
924 .unwrap();
925
926 write_conflict(
927 dir.path(),
928 &ConflictState {
929 entry: "foo".into(),
930 entity_type: "skill".into(),
931 old_sha: "aaa".into(),
932 new_sha: "bbb".into(),
933 },
934 )
935 .unwrap();
936
937 let result = cmd_install(dir.path(), false, false);
938 assert!(result.is_err());
939 assert!(result.unwrap_err().to_string().contains("pending conflict"));
940 }
941
942 fn make_skill_entry(name: &str) -> Entry {
948 Entry {
949 entity_type: EntityType::Skill,
950 name: name.into(),
951 source: SourceFields::Github {
952 owner_repo: "owner/repo".into(),
953 path_in_repo: format!("skills/{name}.md"),
954 ref_: "main".into(),
955 },
956 }
957 }
958
959 fn make_dir_skill_entry(name: &str) -> Entry {
961 Entry {
962 entity_type: EntityType::Skill,
963 name: name.into(),
964 source: SourceFields::Github {
965 owner_repo: "owner/repo".into(),
966 path_in_repo: format!("skills/{name}"),
967 ref_: "main".into(),
968 },
969 }
970 }
971
972 fn setup_github_skill_repo(dir: &std::path::Path, name: &str, cache_content: &str) {
974 use skillfile_core::lock::write_lock;
975 use skillfile_core::models::LockEntry;
976 use std::collections::BTreeMap;
977
978 std::fs::write(
980 dir.join("Skillfile"),
981 format!("install claude-code local\ngithub skill {name} owner/repo skills/{name}.md\n"),
982 )
983 .unwrap();
984
985 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
987 locked.insert(
988 format!("github/skill/{name}"),
989 LockEntry {
990 sha: "abc123def456abc123def456abc123def456abc123".into(),
991 raw_url: format!(
992 "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
993 ),
994 },
995 );
996 write_lock(dir, &locked).unwrap();
997
998 let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
1000 std::fs::create_dir_all(&vdir).unwrap();
1001 std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
1002 }
1003
1004 #[test]
1009 fn auto_pin_entry_local_is_skipped() {
1010 let dir = tempfile::tempdir().unwrap();
1011
1012 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1014 let manifest = Manifest {
1015 entries: vec![entry.clone()],
1016 install_targets: vec![make_target("claude-code", Scope::Local)],
1017 };
1018
1019 let skills_dir = dir.path().join("skills");
1021 std::fs::create_dir_all(&skills_dir).unwrap();
1022 std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
1023
1024 auto_pin_entry(&entry, &manifest, dir.path());
1025
1026 assert!(
1028 !skillfile_core::patch::has_patch(&entry, dir.path()),
1029 "local entry must never be pinned"
1030 );
1031 }
1032
1033 #[test]
1034 fn auto_pin_entry_missing_lock_is_skipped() {
1035 let dir = tempfile::tempdir().unwrap();
1036
1037 let entry = make_skill_entry("test");
1038 let manifest = Manifest {
1039 entries: vec![entry.clone()],
1040 install_targets: vec![make_target("claude-code", Scope::Local)],
1041 };
1042
1043 auto_pin_entry(&entry, &manifest, dir.path());
1045
1046 assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1047 }
1048
1049 #[test]
1050 fn auto_pin_entry_missing_lock_key_is_skipped() {
1051 use skillfile_core::lock::write_lock;
1052 use skillfile_core::models::LockEntry;
1053 use std::collections::BTreeMap;
1054
1055 let dir = tempfile::tempdir().unwrap();
1056
1057 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1059 locked.insert(
1060 "github/skill/other".into(),
1061 LockEntry {
1062 sha: "aabbcc".into(),
1063 raw_url: "https://example.com/other.md".into(),
1064 },
1065 );
1066 write_lock(dir.path(), &locked).unwrap();
1067
1068 let entry = make_skill_entry("test");
1069 let manifest = Manifest {
1070 entries: vec![entry.clone()],
1071 install_targets: vec![make_target("claude-code", Scope::Local)],
1072 };
1073
1074 auto_pin_entry(&entry, &manifest, dir.path());
1075
1076 assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1077 }
1078
1079 #[test]
1080 fn auto_pin_entry_writes_patch_when_installed_differs() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let name = "my-skill";
1083
1084 let cache_content = "# My Skill\n\nOriginal content.\n";
1085 let installed_content = "# My Skill\n\nUser-modified content.\n";
1086
1087 setup_github_skill_repo(dir.path(), name, cache_content);
1088
1089 let installed_dir = dir.path().join(".claude/skills");
1091 std::fs::create_dir_all(&installed_dir).unwrap();
1092 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1093
1094 let entry = make_skill_entry(name);
1095 let manifest = Manifest {
1096 entries: vec![entry.clone()],
1097 install_targets: vec![make_target("claude-code", Scope::Local)],
1098 };
1099
1100 auto_pin_entry(&entry, &manifest, dir.path());
1101
1102 assert!(
1103 skillfile_core::patch::has_patch(&entry, dir.path()),
1104 "patch should be written when installed differs from cache"
1105 );
1106
1107 let patch_text = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1109 let result = skillfile_core::patch::apply_patch_pure(cache_content, &patch_text).unwrap();
1110 assert_eq!(result, installed_content);
1111 }
1112
1113 #[test]
1114 fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1115 let dir = tempfile::tempdir().unwrap();
1116 let name = "my-skill";
1117
1118 let cache_content = "# My Skill\n\nOriginal.\n";
1119 let installed_content = "# My Skill\n\nModified.\n";
1120
1121 setup_github_skill_repo(dir.path(), name, cache_content);
1122
1123 let entry = make_skill_entry(name);
1124 let manifest = Manifest {
1125 entries: vec![entry.clone()],
1126 install_targets: vec![make_target("claude-code", Scope::Local)],
1127 };
1128
1129 let patch_text = skillfile_core::patch::generate_patch(
1131 cache_content,
1132 installed_content,
1133 &format!("{name}.md"),
1134 );
1135 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1136
1137 let installed_dir = dir.path().join(".claude/skills");
1139 std::fs::create_dir_all(&installed_dir).unwrap();
1140 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1141
1142 let patch_path = skillfile_core::patch::patch_path(&entry, dir.path());
1144 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1145
1146 std::thread::sleep(std::time::Duration::from_millis(20));
1148
1149 auto_pin_entry(&entry, &manifest, dir.path());
1150
1151 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1152
1153 assert_eq!(
1154 mtime_before, mtime_after,
1155 "patch must not be rewritten when already up to date"
1156 );
1157 }
1158
1159 #[test]
1160 fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1161 let dir = tempfile::tempdir().unwrap();
1162 let name = "my-skill";
1163
1164 let cache_content = "# My Skill\n\nOriginal.\n";
1165 let old_installed = "# My Skill\n\nFirst edit.\n";
1166 let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1167
1168 setup_github_skill_repo(dir.path(), name, cache_content);
1169
1170 let entry = make_skill_entry(name);
1171 let manifest = Manifest {
1172 entries: vec![entry.clone()],
1173 install_targets: vec![make_target("claude-code", Scope::Local)],
1174 };
1175
1176 let old_patch = skillfile_core::patch::generate_patch(
1178 cache_content,
1179 old_installed,
1180 &format!("{name}.md"),
1181 );
1182 skillfile_core::patch::write_patch(&entry, &old_patch, dir.path()).unwrap();
1183
1184 let installed_dir = dir.path().join(".claude/skills");
1186 std::fs::create_dir_all(&installed_dir).unwrap();
1187 std::fs::write(installed_dir.join(format!("{name}.md")), new_installed).unwrap();
1188
1189 auto_pin_entry(&entry, &manifest, dir.path());
1190
1191 let new_patch = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1193 let result = skillfile_core::patch::apply_patch_pure(cache_content, &new_patch).unwrap();
1194 assert_eq!(
1195 result, new_installed,
1196 "updated patch must describe the latest installed content"
1197 );
1198 }
1199
1200 #[test]
1205 fn auto_pin_dir_entry_writes_per_file_patches() {
1206 use skillfile_core::lock::write_lock;
1207 use skillfile_core::models::LockEntry;
1208 use std::collections::BTreeMap;
1209
1210 let dir = tempfile::tempdir().unwrap();
1211 let name = "lang-pro";
1212
1213 std::fs::write(
1215 dir.path().join("Skillfile"),
1216 format!(
1217 "install claude-code local\ngithub skill {name} owner/repo skills/{name}\n"
1218 ),
1219 )
1220 .unwrap();
1221 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1222 locked.insert(
1223 format!("github/skill/{name}"),
1224 LockEntry {
1225 sha: "deadbeefdeadbeefdeadbeef".into(),
1226 raw_url: format!("https://example.com/{name}"),
1227 },
1228 );
1229 write_lock(dir.path(), &locked).unwrap();
1230
1231 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1233 std::fs::create_dir_all(&vdir).unwrap();
1234 std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1235 std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1236
1237 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1239 std::fs::create_dir_all(&inst_dir).unwrap();
1240 std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1241 std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1242
1243 let entry = make_dir_skill_entry(name);
1244 let manifest = Manifest {
1245 entries: vec![entry.clone()],
1246 install_targets: vec![make_target("claude-code", Scope::Local)],
1247 };
1248
1249 auto_pin_entry(&entry, &manifest, dir.path());
1250
1251 let skill_patch = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1253 assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1254
1255 let examples_patch =
1257 skillfile_core::patch::dir_patch_path(&entry, "examples.md", dir.path());
1258 assert!(
1259 !examples_patch.exists(),
1260 "patch for examples.md must not be written (content unchanged)"
1261 );
1262 }
1263
1264 #[test]
1265 fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1266 use skillfile_core::lock::write_lock;
1267 use skillfile_core::models::LockEntry;
1268 use std::collections::BTreeMap;
1269
1270 let dir = tempfile::tempdir().unwrap();
1271 let name = "lang-pro";
1272
1273 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1275 locked.insert(
1276 format!("github/skill/{name}"),
1277 LockEntry {
1278 sha: "abc".into(),
1279 raw_url: "https://example.com".into(),
1280 },
1281 );
1282 write_lock(dir.path(), &locked).unwrap();
1283
1284 let entry = make_dir_skill_entry(name);
1285 let manifest = Manifest {
1286 entries: vec![entry.clone()],
1287 install_targets: vec![make_target("claude-code", Scope::Local)],
1288 };
1289
1290 auto_pin_entry(&entry, &manifest, dir.path());
1292
1293 assert!(!skillfile_core::patch::has_dir_patch(&entry, dir.path()));
1294 }
1295
1296 #[test]
1297 fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1298 use skillfile_core::lock::write_lock;
1299 use skillfile_core::models::LockEntry;
1300 use std::collections::BTreeMap;
1301
1302 let dir = tempfile::tempdir().unwrap();
1303 let name = "lang-pro";
1304
1305 let cache_content = "# Lang Pro\n\nOriginal.\n";
1306 let modified = "# Lang Pro\n\nModified.\n";
1307
1308 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1310 locked.insert(
1311 format!("github/skill/{name}"),
1312 LockEntry {
1313 sha: "abc".into(),
1314 raw_url: "https://example.com".into(),
1315 },
1316 );
1317 write_lock(dir.path(), &locked).unwrap();
1318
1319 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1321 std::fs::create_dir_all(&vdir).unwrap();
1322 std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1323
1324 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1326 std::fs::create_dir_all(&inst_dir).unwrap();
1327 std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1328
1329 let entry = make_dir_skill_entry(name);
1330 let manifest = Manifest {
1331 entries: vec![entry.clone()],
1332 install_targets: vec![make_target("claude-code", Scope::Local)],
1333 };
1334
1335 let patch_text = skillfile_core::patch::generate_patch(cache_content, modified, "SKILL.md");
1337 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1338 .unwrap();
1339
1340 let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1341 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1342
1343 std::thread::sleep(std::time::Duration::from_millis(20));
1344
1345 auto_pin_entry(&entry, &manifest, dir.path());
1346
1347 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1348
1349 assert_eq!(
1350 mtime_before, mtime_after,
1351 "dir patch must not be rewritten when already up to date"
1352 );
1353 }
1354
1355 #[test]
1360 fn apply_dir_patches_applies_patch_and_rebases() {
1361 let dir = tempfile::tempdir().unwrap();
1362
1363 let cache_content = "# Skill\n\nOriginal.\n";
1365 let installed_content = "# Skill\n\nModified.\n";
1366 let new_cache_content = "# Skill\n\nOriginal v2.\n";
1368 let expected_rebased_to_new_cache = installed_content;
1371
1372 let entry = make_dir_skill_entry("lang-pro");
1373
1374 let patch_text =
1376 skillfile_core::patch::generate_patch(cache_content, installed_content, "SKILL.md");
1377 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1378 .unwrap();
1379
1380 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1382 std::fs::create_dir_all(&inst_dir).unwrap();
1383 std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1384
1385 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1387 std::fs::create_dir_all(&new_cache_dir).unwrap();
1388 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1389
1390 let mut installed_files = std::collections::HashMap::new();
1392 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1393
1394 apply_dir_patches(&entry, &installed_files, &new_cache_dir, dir.path()).unwrap();
1395
1396 let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1398 assert_eq!(installed_after, installed_content);
1399
1400 let rebased_patch = std::fs::read_to_string(skillfile_core::patch::dir_patch_path(
1403 &entry,
1404 "SKILL.md",
1405 dir.path(),
1406 ))
1407 .unwrap();
1408 let rebase_result =
1409 skillfile_core::patch::apply_patch_pure(new_cache_content, &rebased_patch).unwrap();
1410 assert_eq!(
1411 rebase_result, expected_rebased_to_new_cache,
1412 "rebased patch applied to new_cache must reproduce installed_content"
1413 );
1414 }
1415
1416 #[test]
1417 fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1418 let dir = tempfile::tempdir().unwrap();
1419
1420 let original = "# Skill\n\nOriginal.\n";
1422 let modified = "# Skill\n\nModified.\n";
1423 let new_cache = modified; let entry = make_dir_skill_entry("lang-pro");
1427
1428 let patch_text = skillfile_core::patch::generate_patch(original, modified, "SKILL.md");
1429 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1430 .unwrap();
1431
1432 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1434 std::fs::create_dir_all(&inst_dir).unwrap();
1435 std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1436
1437 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1438 std::fs::create_dir_all(&new_cache_dir).unwrap();
1439 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1440
1441 let mut installed_files = std::collections::HashMap::new();
1442 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1443
1444 apply_dir_patches(&entry, &installed_files, &new_cache_dir, dir.path()).unwrap();
1445
1446 let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1448 assert!(
1449 !patch_path.exists(),
1450 "patch file must be removed when rebase yields empty diff"
1451 );
1452 }
1453
1454 #[test]
1455 fn apply_dir_patches_no_op_when_no_patches_dir() {
1456 let dir = tempfile::tempdir().unwrap();
1457
1458 let entry = make_dir_skill_entry("lang-pro");
1460 let installed_files = std::collections::HashMap::new();
1461 let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1462 std::fs::create_dir_all(&source_dir).unwrap();
1463
1464 apply_dir_patches(&entry, &installed_files, &source_dir, dir.path()).unwrap();
1466 }
1467
1468 #[test]
1473 fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1474 let dir = tempfile::tempdir().unwrap();
1475
1476 let original = "# Skill\n\nOriginal.\n";
1477 let modified = "# Skill\n\nModified.\n";
1478 let new_cache = modified;
1480
1481 let entry = make_skill_entry("test");
1482
1483 let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1485 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1486
1487 let vdir = dir.path().join(".skillfile/cache/skills/test");
1489 std::fs::create_dir_all(&vdir).unwrap();
1490 let source = vdir.join("test.md");
1491 std::fs::write(&source, new_cache).unwrap();
1492
1493 let installed_dir = dir.path().join(".claude/skills");
1495 std::fs::create_dir_all(&installed_dir).unwrap();
1496 let dest = installed_dir.join("test.md");
1497 std::fs::write(&dest, original).unwrap();
1498
1499 apply_single_file_patch(&entry, &dest, &source, dir.path()).unwrap();
1500
1501 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1503
1504 assert!(
1506 !skillfile_core::patch::has_patch(&entry, dir.path()),
1507 "patch must be removed when new cache already matches patched content"
1508 );
1509 }
1510
1511 #[test]
1512 fn apply_single_file_patch_rewrites_patch_after_rebase() {
1513 let dir = tempfile::tempdir().unwrap();
1514
1515 let original = "# Skill\n\nOriginal.\n";
1517 let modified = "# Skill\n\nModified.\n";
1518 let new_cache = "# Skill\n\nOriginal v2.\n";
1519 let expected_rebased_result = modified;
1522
1523 let entry = make_skill_entry("test");
1524
1525 let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1526 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1527
1528 let vdir = dir.path().join(".skillfile/cache/skills/test");
1530 std::fs::create_dir_all(&vdir).unwrap();
1531 let source = vdir.join("test.md");
1532 std::fs::write(&source, new_cache).unwrap();
1533
1534 let installed_dir = dir.path().join(".claude/skills");
1536 std::fs::create_dir_all(&installed_dir).unwrap();
1537 let dest = installed_dir.join("test.md");
1538 std::fs::write(&dest, original).unwrap();
1539
1540 apply_single_file_patch(&entry, &dest, &source, dir.path()).unwrap();
1541
1542 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1544
1545 assert!(
1548 skillfile_core::patch::has_patch(&entry, dir.path()),
1549 "rebased patch must still exist (new_cache != modified)"
1550 );
1551 let rebased = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1552 let result = skillfile_core::patch::apply_patch_pure(new_cache, &rebased).unwrap();
1553 assert_eq!(
1554 result, expected_rebased_result,
1555 "rebased patch applied to new_cache must reproduce installed content"
1556 );
1557 }
1558
1559 #[test]
1564 fn check_preconditions_no_targets_returns_error() {
1565 let dir = tempfile::tempdir().unwrap();
1566 let manifest = Manifest {
1567 entries: vec![],
1568 install_targets: vec![],
1569 };
1570 let result = check_preconditions(&manifest, dir.path());
1571 assert!(result.is_err());
1572 assert!(result
1573 .unwrap_err()
1574 .to_string()
1575 .contains("No install targets"));
1576 }
1577
1578 #[test]
1579 fn check_preconditions_pending_conflict_returns_error() {
1580 use skillfile_core::conflict::write_conflict;
1581 use skillfile_core::models::ConflictState;
1582
1583 let dir = tempfile::tempdir().unwrap();
1584 let manifest = Manifest {
1585 entries: vec![],
1586 install_targets: vec![make_target("claude-code", Scope::Local)],
1587 };
1588
1589 write_conflict(
1590 dir.path(),
1591 &ConflictState {
1592 entry: "my-skill".into(),
1593 entity_type: "skill".into(),
1594 old_sha: "aaa".into(),
1595 new_sha: "bbb".into(),
1596 },
1597 )
1598 .unwrap();
1599
1600 let result = check_preconditions(&manifest, dir.path());
1601 assert!(result.is_err());
1602 assert!(result.unwrap_err().to_string().contains("pending conflict"));
1603 }
1604
1605 #[test]
1606 fn check_preconditions_ok_with_target_and_no_conflict() {
1607 let dir = tempfile::tempdir().unwrap();
1608 let manifest = Manifest {
1609 entries: vec![],
1610 install_targets: vec![make_target("claude-code", Scope::Local)],
1611 };
1612 check_preconditions(&manifest, dir.path()).unwrap();
1613 }
1614
1615 #[test]
1620 fn deploy_all_patch_conflict_writes_conflict_state() {
1621 use skillfile_core::conflict::{has_conflict, read_conflict};
1622 use skillfile_core::lock::write_lock;
1623 use skillfile_core::models::LockEntry;
1624 use std::collections::BTreeMap;
1625
1626 let dir = tempfile::tempdir().unwrap();
1627 let name = "test";
1628
1629 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1631 std::fs::create_dir_all(&vdir).unwrap();
1632 std::fs::write(
1633 vdir.join(format!("{name}.md")),
1634 "totally different content\n",
1635 )
1636 .unwrap();
1637
1638 let entry = make_skill_entry(name);
1640 let bad_patch =
1641 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-expected_original_line\n+modified\n";
1642 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1643
1644 let inst_dir = dir.path().join(".claude/skills");
1646 std::fs::create_dir_all(&inst_dir).unwrap();
1647 std::fs::write(
1648 inst_dir.join(format!("{name}.md")),
1649 "totally different content\n",
1650 )
1651 .unwrap();
1652
1653 let manifest = Manifest {
1655 entries: vec![entry.clone()],
1656 install_targets: vec![make_target("claude-code", Scope::Local)],
1657 };
1658
1659 let lock_key_str = format!("github/skill/{name}");
1661 let old_sha = "a".repeat(40);
1662 let new_sha = "b".repeat(40);
1663
1664 let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1665 old_locked.insert(
1666 lock_key_str.clone(),
1667 LockEntry {
1668 sha: old_sha.clone(),
1669 raw_url: "https://example.com/old.md".into(),
1670 },
1671 );
1672
1673 let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1674 new_locked.insert(
1675 lock_key_str,
1676 LockEntry {
1677 sha: new_sha.clone(),
1678 raw_url: "https://example.com/new.md".into(),
1679 },
1680 );
1681
1682 write_lock(dir.path(), &new_locked).unwrap();
1683
1684 let opts = InstallOptions {
1685 dry_run: false,
1686 overwrite: true,
1687 };
1688
1689 let result = deploy_all(&manifest, dir.path(), &opts, &new_locked, &old_locked);
1690
1691 assert!(
1693 result.is_err(),
1694 "deploy_all must return Err on PatchConflict"
1695 );
1696 let err_msg = result.unwrap_err().to_string();
1697 assert!(
1698 err_msg.contains("conflict"),
1699 "error message must mention conflict: {err_msg}"
1700 );
1701
1702 assert!(
1704 has_conflict(dir.path()),
1705 "conflict state file must be written after PatchConflict"
1706 );
1707
1708 let conflict = read_conflict(dir.path()).unwrap().unwrap();
1709 assert_eq!(conflict.entry, name);
1710 assert_eq!(conflict.old_sha, old_sha);
1711 assert_eq!(conflict.new_sha, new_sha);
1712 }
1713
1714 #[test]
1715 fn deploy_all_patch_conflict_error_message_contains_sha_context() {
1716 use skillfile_core::lock::write_lock;
1717 use skillfile_core::models::LockEntry;
1718 use std::collections::BTreeMap;
1719
1720 let dir = tempfile::tempdir().unwrap();
1721 let name = "test";
1722
1723 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1724 std::fs::create_dir_all(&vdir).unwrap();
1725 std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
1726
1727 let entry = make_skill_entry(name);
1728 let bad_patch =
1729 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
1730 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1731
1732 let inst_dir = dir.path().join(".claude/skills");
1733 std::fs::create_dir_all(&inst_dir).unwrap();
1734 std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
1735
1736 let manifest = Manifest {
1737 entries: vec![entry.clone()],
1738 install_targets: vec![make_target("claude-code", Scope::Local)],
1739 };
1740
1741 let lock_key_str = format!("github/skill/{name}");
1742 let old_sha = "aabbccddeeff001122334455aabbccddeeff0011".to_string();
1743 let new_sha = "99887766554433221100ffeeddccbbaa99887766".to_string();
1744
1745 let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1746 old_locked.insert(
1747 lock_key_str.clone(),
1748 LockEntry {
1749 sha: old_sha.clone(),
1750 raw_url: "https://example.com/old.md".into(),
1751 },
1752 );
1753
1754 let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1755 new_locked.insert(
1756 lock_key_str,
1757 LockEntry {
1758 sha: new_sha.clone(),
1759 raw_url: "https://example.com/new.md".into(),
1760 },
1761 );
1762
1763 write_lock(dir.path(), &new_locked).unwrap();
1764
1765 let opts = InstallOptions {
1766 dry_run: false,
1767 overwrite: true,
1768 };
1769
1770 let result = deploy_all(&manifest, dir.path(), &opts, &new_locked, &old_locked);
1771 assert!(result.is_err());
1772
1773 let err_msg = result.unwrap_err().to_string();
1774
1775 assert!(
1777 err_msg.contains('\u{2192}'),
1778 "error message must contain the SHA arrow (→): {err_msg}"
1779 );
1780 assert!(
1782 err_msg.contains(&old_sha[..12]),
1783 "error must contain old SHA prefix: {err_msg}"
1784 );
1785 assert!(
1786 err_msg.contains(&new_sha[..12]),
1787 "error must contain new SHA prefix: {err_msg}"
1788 );
1789 }
1790
1791 #[test]
1792 fn deploy_all_patch_conflict_error_message_has_resolve_hints() {
1793 use skillfile_core::lock::write_lock;
1794 use skillfile_core::models::LockEntry;
1795 use std::collections::BTreeMap;
1796
1797 let dir = tempfile::tempdir().unwrap();
1798 let name = "test";
1799
1800 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1801 std::fs::create_dir_all(&vdir).unwrap();
1802 std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
1803
1804 let entry = make_skill_entry(name);
1805 let bad_patch =
1806 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
1807 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1808
1809 let inst_dir = dir.path().join(".claude/skills");
1810 std::fs::create_dir_all(&inst_dir).unwrap();
1811 std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
1812
1813 let manifest = Manifest {
1814 entries: vec![entry.clone()],
1815 install_targets: vec![make_target("claude-code", Scope::Local)],
1816 };
1817
1818 let lock_key_str = format!("github/skill/{name}");
1819 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1820 locked.insert(
1821 lock_key_str,
1822 LockEntry {
1823 sha: "abc123".into(),
1824 raw_url: "https://example.com/test.md".into(),
1825 },
1826 );
1827 write_lock(dir.path(), &locked).unwrap();
1828
1829 let opts = InstallOptions {
1830 dry_run: false,
1831 overwrite: true,
1832 };
1833
1834 let result = deploy_all(
1835 &manifest,
1836 dir.path(),
1837 &opts,
1838 &locked,
1839 &BTreeMap::new(), );
1841 assert!(result.is_err());
1842
1843 let err_msg = result.unwrap_err().to_string();
1844 assert!(
1845 err_msg.contains("skillfile resolve"),
1846 "error must mention resolve command: {err_msg}"
1847 );
1848 assert!(
1849 err_msg.contains("skillfile diff"),
1850 "error must mention diff command: {err_msg}"
1851 );
1852 assert!(
1853 err_msg.contains("--abort"),
1854 "error must mention --abort: {err_msg}"
1855 );
1856 }
1857
1858 #[test]
1859 fn deploy_all_unknown_platform_skips_gracefully() {
1860 use std::collections::BTreeMap;
1861
1862 let dir = tempfile::tempdir().unwrap();
1863
1864 let manifest = Manifest {
1866 entries: vec![],
1867 install_targets: vec![InstallTarget {
1868 adapter: "unknown-tool".into(),
1869 scope: Scope::Local,
1870 }],
1871 };
1872
1873 let opts = InstallOptions {
1874 dry_run: false,
1875 overwrite: true,
1876 };
1877
1878 deploy_all(
1880 &manifest,
1881 dir.path(),
1882 &opts,
1883 &BTreeMap::new(),
1884 &BTreeMap::new(),
1885 )
1886 .unwrap();
1887 }
1888}