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);
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_entry_dry_run_no_write() {
516 let dir = tempfile::tempdir().unwrap();
517 let source_file = dir.path().join("skills/my-skill.md");
518 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
519 std::fs::write(&source_file, "# My Skill").unwrap();
520
521 let entry = make_local_entry("my-skill", "skills/my-skill.md");
522 let target = make_target("claude-code", Scope::Local);
523 let opts = InstallOptions {
524 dry_run: true,
525 ..Default::default()
526 };
527 install_entry(&entry, &target, dir.path(), Some(&opts)).unwrap();
528
529 let dest = dir.path().join(".claude/skills/my-skill.md");
530 assert!(!dest.exists());
531 }
532
533 #[test]
534 fn install_entry_overwrites_existing() {
535 let dir = tempfile::tempdir().unwrap();
536 let source_file = dir.path().join("skills/my-skill.md");
537 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
538 std::fs::write(&source_file, "# New content").unwrap();
539
540 let dest_dir = dir.path().join(".claude/skills");
541 std::fs::create_dir_all(&dest_dir).unwrap();
542 let dest = dest_dir.join("my-skill.md");
543 std::fs::write(&dest, "# Old content").unwrap();
544
545 let entry = make_local_entry("my-skill", "skills/my-skill.md");
546 let target = make_target("claude-code", Scope::Local);
547 install_entry(&entry, &target, dir.path(), None).unwrap();
548
549 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
550 }
551
552 #[test]
555 fn install_github_entry_copy() {
556 let dir = tempfile::tempdir().unwrap();
557 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
558 std::fs::create_dir_all(&vdir).unwrap();
559 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
560
561 let entry = make_agent_entry("my-agent");
562 let target = make_target("claude-code", Scope::Local);
563 install_entry(&entry, &target, dir.path(), None).unwrap();
564
565 let dest = dir.path().join(".claude/agents/my-agent.md");
566 assert!(dest.exists());
567 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
568 }
569
570 #[test]
571 fn install_github_dir_entry_copy() {
572 let dir = tempfile::tempdir().unwrap();
573 let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
574 std::fs::create_dir_all(&vdir).unwrap();
575 std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
576 std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
577
578 let entry = Entry {
579 entity_type: EntityType::Skill,
580 name: "python-pro".into(),
581 source: SourceFields::Github {
582 owner_repo: "owner/repo".into(),
583 path_in_repo: "skills/python-pro".into(),
584 ref_: "main".into(),
585 },
586 };
587 let target = make_target("claude-code", Scope::Local);
588 install_entry(&entry, &target, dir.path(), None).unwrap();
589
590 let dest = dir.path().join(".claude/skills/python-pro");
591 assert!(dest.is_dir());
592 assert_eq!(
593 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
594 "# Python Pro"
595 );
596 }
597
598 #[test]
599 fn install_agent_dir_entry_explodes_to_individual_files() {
600 let dir = tempfile::tempdir().unwrap();
601 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
602 std::fs::create_dir_all(&vdir).unwrap();
603 std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
604 std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
605 std::fs::write(vdir.join(".meta"), "{}").unwrap();
606
607 let entry = Entry {
608 entity_type: EntityType::Agent,
609 name: "core-dev".into(),
610 source: SourceFields::Github {
611 owner_repo: "owner/repo".into(),
612 path_in_repo: "categories/core-dev".into(),
613 ref_: "main".into(),
614 },
615 };
616 let target = make_target("claude-code", Scope::Local);
617 install_entry(&entry, &target, dir.path(), None).unwrap();
618
619 let agents_dir = dir.path().join(".claude/agents");
620 assert_eq!(
621 std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
622 "# Backend"
623 );
624 assert_eq!(
625 std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
626 "# Frontend"
627 );
628 assert!(!agents_dir.join("core-dev").exists());
630 }
631
632 #[test]
633 fn install_entry_missing_source_warns() {
634 let dir = tempfile::tempdir().unwrap();
635 let entry = make_agent_entry("my-agent");
636 let target = make_target("claude-code", Scope::Local);
637
638 install_entry(&entry, &target, dir.path(), None).unwrap();
640 }
641
642 #[test]
645 fn install_applies_existing_patch() {
646 let dir = tempfile::tempdir().unwrap();
647
648 let vdir = dir.path().join(".skillfile/cache/skills/test");
650 std::fs::create_dir_all(&vdir).unwrap();
651 std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
652
653 let entry = Entry {
655 entity_type: EntityType::Skill,
656 name: "test".into(),
657 source: SourceFields::Github {
658 owner_repo: "owner/repo".into(),
659 path_in_repo: "skills/test.md".into(),
660 ref_: "main".into(),
661 },
662 };
663 let patch_text = skillfile_core::patch::generate_patch(
664 "# Test\n\nOriginal.\n",
665 "# Test\n\nModified.\n",
666 "test.md",
667 );
668 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
669
670 let target = make_target("claude-code", Scope::Local);
671 install_entry(&entry, &target, dir.path(), None).unwrap();
672
673 let dest = dir.path().join(".claude/skills/test.md");
674 assert_eq!(
675 std::fs::read_to_string(&dest).unwrap(),
676 "# Test\n\nModified.\n"
677 );
678 }
679
680 #[test]
681 fn install_patch_conflict_returns_error() {
682 let dir = tempfile::tempdir().unwrap();
683
684 let vdir = dir.path().join(".skillfile/cache/skills/test");
685 std::fs::create_dir_all(&vdir).unwrap();
686 std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
688
689 let entry = Entry {
690 entity_type: EntityType::Skill,
691 name: "test".into(),
692 source: SourceFields::Github {
693 owner_repo: "owner/repo".into(),
694 path_in_repo: "skills/test.md".into(),
695 ref_: "main".into(),
696 },
697 };
698 let bad_patch =
700 "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
701 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
702
703 let installed_dir = dir.path().join(".claude/skills");
705 std::fs::create_dir_all(&installed_dir).unwrap();
706 std::fs::write(
707 installed_dir.join("test.md"),
708 "totally different\ncontent\n",
709 )
710 .unwrap();
711
712 let target = make_target("claude-code", Scope::Local);
713 let result = install_entry(&entry, &target, dir.path(), None);
714 assert!(result.is_err());
715 matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
717 }
718
719 #[test]
722 fn install_local_skill_gemini_cli() {
723 let dir = tempfile::tempdir().unwrap();
724 let source_file = dir.path().join("skills/my-skill.md");
725 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
726 std::fs::write(&source_file, "# My Skill").unwrap();
727
728 let entry = make_local_entry("my-skill", "skills/my-skill.md");
729 let target = make_target("gemini-cli", Scope::Local);
730 install_entry(&entry, &target, dir.path(), None).unwrap();
731
732 let dest = dir.path().join(".gemini/skills/my-skill.md");
733 assert!(dest.exists());
734 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
735 }
736
737 #[test]
738 fn install_local_skill_codex() {
739 let dir = tempfile::tempdir().unwrap();
740 let source_file = dir.path().join("skills/my-skill.md");
741 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
742 std::fs::write(&source_file, "# My Skill").unwrap();
743
744 let entry = make_local_entry("my-skill", "skills/my-skill.md");
745 let target = make_target("codex", Scope::Local);
746 install_entry(&entry, &target, dir.path(), None).unwrap();
747
748 let dest = dir.path().join(".codex/skills/my-skill.md");
749 assert!(dest.exists());
750 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
751 }
752
753 #[test]
754 fn codex_skips_agent_entries() {
755 let dir = tempfile::tempdir().unwrap();
756 let entry = make_agent_entry("my-agent");
757 let target = make_target("codex", Scope::Local);
758 install_entry(&entry, &target, dir.path(), None).unwrap();
759
760 assert!(!dir.path().join(".codex").exists());
761 }
762
763 #[test]
764 fn install_github_agent_gemini_cli() {
765 let dir = tempfile::tempdir().unwrap();
766 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
767 std::fs::create_dir_all(&vdir).unwrap();
768 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
769
770 let entry = make_agent_entry("my-agent");
771 let target = make_target("gemini-cli", Scope::Local);
772 install_entry(
773 &entry,
774 &target,
775 dir.path(),
776 Some(&InstallOptions::default()),
777 )
778 .unwrap();
779
780 let dest = dir.path().join(".gemini/agents/my-agent.md");
781 assert!(dest.exists());
782 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
783 }
784
785 #[test]
786 fn install_skill_multi_adapter() {
787 for adapter in &["claude-code", "gemini-cli", "codex"] {
788 let dir = tempfile::tempdir().unwrap();
789 let source_file = dir.path().join("skills/my-skill.md");
790 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
791 std::fs::write(&source_file, "# Multi Skill").unwrap();
792
793 let entry = make_local_entry("my-skill", "skills/my-skill.md");
794 let target = make_target(adapter, Scope::Local);
795 install_entry(&entry, &target, dir.path(), None).unwrap();
796
797 let prefix = match *adapter {
798 "claude-code" => ".claude",
799 "gemini-cli" => ".gemini",
800 "codex" => ".codex",
801 _ => unreachable!(),
802 };
803 let dest = dir.path().join(format!("{prefix}/skills/my-skill.md"));
804 assert!(dest.exists(), "Failed for adapter {adapter}");
805 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
806 }
807 }
808
809 #[test]
812 fn cmd_install_no_manifest() {
813 let dir = tempfile::tempdir().unwrap();
814 let result = cmd_install(dir.path(), false, false);
815 assert!(result.is_err());
816 assert!(result.unwrap_err().to_string().contains("not found"));
817 }
818
819 #[test]
820 fn cmd_install_no_install_targets() {
821 let dir = tempfile::tempdir().unwrap();
822 std::fs::write(
823 dir.path().join("Skillfile"),
824 "local skill foo skills/foo.md\n",
825 )
826 .unwrap();
827
828 let result = cmd_install(dir.path(), false, false);
829 assert!(result.is_err());
830 assert!(result
831 .unwrap_err()
832 .to_string()
833 .contains("No install targets"));
834 }
835
836 #[test]
837 fn cmd_install_dry_run_no_files() {
838 let dir = tempfile::tempdir().unwrap();
839 std::fs::write(
840 dir.path().join("Skillfile"),
841 "install claude-code local\nlocal skill foo skills/foo.md\n",
842 )
843 .unwrap();
844 let source_file = dir.path().join("skills/foo.md");
845 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
846 std::fs::write(&source_file, "# Foo").unwrap();
847
848 cmd_install(dir.path(), true, false).unwrap();
849
850 assert!(!dir.path().join(".claude").exists());
851 }
852
853 #[test]
854 fn cmd_install_deploys_to_multiple_adapters() {
855 let dir = tempfile::tempdir().unwrap();
856 std::fs::write(
857 dir.path().join("Skillfile"),
858 "install claude-code local\n\
859 install gemini-cli local\n\
860 install codex local\n\
861 local skill foo skills/foo.md\n\
862 local agent bar agents/bar.md\n",
863 )
864 .unwrap();
865 std::fs::create_dir_all(dir.path().join("skills")).unwrap();
866 std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
867 std::fs::create_dir_all(dir.path().join("agents")).unwrap();
868 std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
869
870 cmd_install(dir.path(), false, false).unwrap();
871
872 assert!(dir.path().join(".claude/skills/foo.md").exists());
874 assert!(dir.path().join(".gemini/skills/foo.md").exists());
875 assert!(dir.path().join(".codex/skills/foo.md").exists());
876
877 assert!(dir.path().join(".claude/agents/bar.md").exists());
879 assert!(dir.path().join(".gemini/agents/bar.md").exists());
880 assert!(!dir.path().join(".codex/agents").exists());
881 }
882
883 #[test]
884 fn cmd_install_pending_conflict_blocks() {
885 use skillfile_core::conflict::write_conflict;
886 use skillfile_core::models::ConflictState;
887
888 let dir = tempfile::tempdir().unwrap();
889 std::fs::write(
890 dir.path().join("Skillfile"),
891 "install claude-code local\nlocal skill foo skills/foo.md\n",
892 )
893 .unwrap();
894
895 write_conflict(
896 dir.path(),
897 &ConflictState {
898 entry: "foo".into(),
899 entity_type: "skill".into(),
900 old_sha: "aaa".into(),
901 new_sha: "bbb".into(),
902 },
903 )
904 .unwrap();
905
906 let result = cmd_install(dir.path(), false, false);
907 assert!(result.is_err());
908 assert!(result.unwrap_err().to_string().contains("pending conflict"));
909 }
910
911 fn make_skill_entry(name: &str) -> Entry {
917 Entry {
918 entity_type: EntityType::Skill,
919 name: name.into(),
920 source: SourceFields::Github {
921 owner_repo: "owner/repo".into(),
922 path_in_repo: format!("skills/{name}.md"),
923 ref_: "main".into(),
924 },
925 }
926 }
927
928 fn make_dir_skill_entry(name: &str) -> Entry {
930 Entry {
931 entity_type: EntityType::Skill,
932 name: name.into(),
933 source: SourceFields::Github {
934 owner_repo: "owner/repo".into(),
935 path_in_repo: format!("skills/{name}"),
936 ref_: "main".into(),
937 },
938 }
939 }
940
941 fn setup_github_skill_repo(dir: &std::path::Path, name: &str, cache_content: &str) {
943 use skillfile_core::lock::write_lock;
944 use skillfile_core::models::LockEntry;
945 use std::collections::BTreeMap;
946
947 std::fs::write(
949 dir.join("Skillfile"),
950 format!("install claude-code local\ngithub skill {name} owner/repo skills/{name}.md\n"),
951 )
952 .unwrap();
953
954 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
956 locked.insert(
957 format!("github/skill/{name}"),
958 LockEntry {
959 sha: "abc123def456abc123def456abc123def456abc123".into(),
960 raw_url: format!(
961 "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
962 ),
963 },
964 );
965 write_lock(dir, &locked).unwrap();
966
967 let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
969 std::fs::create_dir_all(&vdir).unwrap();
970 std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
971 }
972
973 #[test]
978 fn auto_pin_entry_local_is_skipped() {
979 let dir = tempfile::tempdir().unwrap();
980
981 let entry = make_local_entry("my-skill", "skills/my-skill.md");
983 let manifest = Manifest {
984 entries: vec![entry.clone()],
985 install_targets: vec![make_target("claude-code", Scope::Local)],
986 };
987
988 let skills_dir = dir.path().join("skills");
990 std::fs::create_dir_all(&skills_dir).unwrap();
991 std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
992
993 auto_pin_entry(&entry, &manifest, dir.path());
994
995 assert!(
997 !skillfile_core::patch::has_patch(&entry, dir.path()),
998 "local entry must never be pinned"
999 );
1000 }
1001
1002 #[test]
1003 fn auto_pin_entry_missing_lock_is_skipped() {
1004 let dir = tempfile::tempdir().unwrap();
1005
1006 let entry = make_skill_entry("test");
1007 let manifest = Manifest {
1008 entries: vec![entry.clone()],
1009 install_targets: vec![make_target("claude-code", Scope::Local)],
1010 };
1011
1012 auto_pin_entry(&entry, &manifest, dir.path());
1014
1015 assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1016 }
1017
1018 #[test]
1019 fn auto_pin_entry_missing_lock_key_is_skipped() {
1020 use skillfile_core::lock::write_lock;
1021 use skillfile_core::models::LockEntry;
1022 use std::collections::BTreeMap;
1023
1024 let dir = tempfile::tempdir().unwrap();
1025
1026 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1028 locked.insert(
1029 "github/skill/other".into(),
1030 LockEntry {
1031 sha: "aabbcc".into(),
1032 raw_url: "https://example.com/other.md".into(),
1033 },
1034 );
1035 write_lock(dir.path(), &locked).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());
1044
1045 assert!(!skillfile_core::patch::has_patch(&entry, dir.path()));
1046 }
1047
1048 #[test]
1049 fn auto_pin_entry_writes_patch_when_installed_differs() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let name = "my-skill";
1052
1053 let cache_content = "# My Skill\n\nOriginal content.\n";
1054 let installed_content = "# My Skill\n\nUser-modified content.\n";
1055
1056 setup_github_skill_repo(dir.path(), name, cache_content);
1057
1058 let installed_dir = dir.path().join(".claude/skills");
1060 std::fs::create_dir_all(&installed_dir).unwrap();
1061 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1062
1063 let entry = make_skill_entry(name);
1064 let manifest = Manifest {
1065 entries: vec![entry.clone()],
1066 install_targets: vec![make_target("claude-code", Scope::Local)],
1067 };
1068
1069 auto_pin_entry(&entry, &manifest, dir.path());
1070
1071 assert!(
1072 skillfile_core::patch::has_patch(&entry, dir.path()),
1073 "patch should be written when installed differs from cache"
1074 );
1075
1076 let patch_text = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1078 let result = skillfile_core::patch::apply_patch_pure(cache_content, &patch_text).unwrap();
1079 assert_eq!(result, installed_content);
1080 }
1081
1082 #[test]
1083 fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1084 let dir = tempfile::tempdir().unwrap();
1085 let name = "my-skill";
1086
1087 let cache_content = "# My Skill\n\nOriginal.\n";
1088 let installed_content = "# My Skill\n\nModified.\n";
1089
1090 setup_github_skill_repo(dir.path(), name, cache_content);
1091
1092 let entry = make_skill_entry(name);
1093 let manifest = Manifest {
1094 entries: vec![entry.clone()],
1095 install_targets: vec![make_target("claude-code", Scope::Local)],
1096 };
1097
1098 let patch_text = skillfile_core::patch::generate_patch(
1100 cache_content,
1101 installed_content,
1102 &format!("{name}.md"),
1103 );
1104 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1105
1106 let installed_dir = dir.path().join(".claude/skills");
1108 std::fs::create_dir_all(&installed_dir).unwrap();
1109 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1110
1111 let patch_path = skillfile_core::patch::patch_path(&entry, dir.path());
1113 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1114
1115 std::thread::sleep(std::time::Duration::from_millis(20));
1117
1118 auto_pin_entry(&entry, &manifest, dir.path());
1119
1120 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1121
1122 assert_eq!(
1123 mtime_before, mtime_after,
1124 "patch must not be rewritten when already up to date"
1125 );
1126 }
1127
1128 #[test]
1129 fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1130 let dir = tempfile::tempdir().unwrap();
1131 let name = "my-skill";
1132
1133 let cache_content = "# My Skill\n\nOriginal.\n";
1134 let old_installed = "# My Skill\n\nFirst edit.\n";
1135 let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1136
1137 setup_github_skill_repo(dir.path(), name, cache_content);
1138
1139 let entry = make_skill_entry(name);
1140 let manifest = Manifest {
1141 entries: vec![entry.clone()],
1142 install_targets: vec![make_target("claude-code", Scope::Local)],
1143 };
1144
1145 let old_patch = skillfile_core::patch::generate_patch(
1147 cache_content,
1148 old_installed,
1149 &format!("{name}.md"),
1150 );
1151 skillfile_core::patch::write_patch(&entry, &old_patch, dir.path()).unwrap();
1152
1153 let installed_dir = dir.path().join(".claude/skills");
1155 std::fs::create_dir_all(&installed_dir).unwrap();
1156 std::fs::write(installed_dir.join(format!("{name}.md")), new_installed).unwrap();
1157
1158 auto_pin_entry(&entry, &manifest, dir.path());
1159
1160 let new_patch = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1162 let result = skillfile_core::patch::apply_patch_pure(cache_content, &new_patch).unwrap();
1163 assert_eq!(
1164 result, new_installed,
1165 "updated patch must describe the latest installed content"
1166 );
1167 }
1168
1169 #[test]
1174 fn auto_pin_dir_entry_writes_per_file_patches() {
1175 use skillfile_core::lock::write_lock;
1176 use skillfile_core::models::LockEntry;
1177 use std::collections::BTreeMap;
1178
1179 let dir = tempfile::tempdir().unwrap();
1180 let name = "lang-pro";
1181
1182 std::fs::write(
1184 dir.path().join("Skillfile"),
1185 format!(
1186 "install claude-code local\ngithub skill {name} owner/repo skills/{name}\n"
1187 ),
1188 )
1189 .unwrap();
1190 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1191 locked.insert(
1192 format!("github/skill/{name}"),
1193 LockEntry {
1194 sha: "deadbeefdeadbeefdeadbeef".into(),
1195 raw_url: format!("https://example.com/{name}"),
1196 },
1197 );
1198 write_lock(dir.path(), &locked).unwrap();
1199
1200 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1202 std::fs::create_dir_all(&vdir).unwrap();
1203 std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1204 std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1205
1206 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1208 std::fs::create_dir_all(&inst_dir).unwrap();
1209 std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1210 std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1211
1212 let entry = make_dir_skill_entry(name);
1213 let manifest = Manifest {
1214 entries: vec![entry.clone()],
1215 install_targets: vec![make_target("claude-code", Scope::Local)],
1216 };
1217
1218 auto_pin_entry(&entry, &manifest, dir.path());
1219
1220 let skill_patch = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1222 assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1223
1224 let examples_patch =
1226 skillfile_core::patch::dir_patch_path(&entry, "examples.md", dir.path());
1227 assert!(
1228 !examples_patch.exists(),
1229 "patch for examples.md must not be written (content unchanged)"
1230 );
1231 }
1232
1233 #[test]
1234 fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1235 use skillfile_core::lock::write_lock;
1236 use skillfile_core::models::LockEntry;
1237 use std::collections::BTreeMap;
1238
1239 let dir = tempfile::tempdir().unwrap();
1240 let name = "lang-pro";
1241
1242 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1244 locked.insert(
1245 format!("github/skill/{name}"),
1246 LockEntry {
1247 sha: "abc".into(),
1248 raw_url: "https://example.com".into(),
1249 },
1250 );
1251 write_lock(dir.path(), &locked).unwrap();
1252
1253 let entry = make_dir_skill_entry(name);
1254 let manifest = Manifest {
1255 entries: vec![entry.clone()],
1256 install_targets: vec![make_target("claude-code", Scope::Local)],
1257 };
1258
1259 auto_pin_entry(&entry, &manifest, dir.path());
1261
1262 assert!(!skillfile_core::patch::has_dir_patch(&entry, dir.path()));
1263 }
1264
1265 #[test]
1266 fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1267 use skillfile_core::lock::write_lock;
1268 use skillfile_core::models::LockEntry;
1269 use std::collections::BTreeMap;
1270
1271 let dir = tempfile::tempdir().unwrap();
1272 let name = "lang-pro";
1273
1274 let cache_content = "# Lang Pro\n\nOriginal.\n";
1275 let modified = "# Lang Pro\n\nModified.\n";
1276
1277 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1279 locked.insert(
1280 format!("github/skill/{name}"),
1281 LockEntry {
1282 sha: "abc".into(),
1283 raw_url: "https://example.com".into(),
1284 },
1285 );
1286 write_lock(dir.path(), &locked).unwrap();
1287
1288 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1290 std::fs::create_dir_all(&vdir).unwrap();
1291 std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1292
1293 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1295 std::fs::create_dir_all(&inst_dir).unwrap();
1296 std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1297
1298 let entry = make_dir_skill_entry(name);
1299 let manifest = Manifest {
1300 entries: vec![entry.clone()],
1301 install_targets: vec![make_target("claude-code", Scope::Local)],
1302 };
1303
1304 let patch_text = skillfile_core::patch::generate_patch(cache_content, modified, "SKILL.md");
1306 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1307 .unwrap();
1308
1309 let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1310 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1311
1312 std::thread::sleep(std::time::Duration::from_millis(20));
1313
1314 auto_pin_entry(&entry, &manifest, dir.path());
1315
1316 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1317
1318 assert_eq!(
1319 mtime_before, mtime_after,
1320 "dir patch must not be rewritten when already up to date"
1321 );
1322 }
1323
1324 #[test]
1329 fn apply_dir_patches_applies_patch_and_rebases() {
1330 let dir = tempfile::tempdir().unwrap();
1331
1332 let cache_content = "# Skill\n\nOriginal.\n";
1334 let installed_content = "# Skill\n\nModified.\n";
1335 let new_cache_content = "# Skill\n\nOriginal v2.\n";
1337 let expected_rebased_to_new_cache = installed_content;
1340
1341 let entry = make_dir_skill_entry("lang-pro");
1342
1343 let patch_text =
1345 skillfile_core::patch::generate_patch(cache_content, installed_content, "SKILL.md");
1346 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1347 .unwrap();
1348
1349 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1351 std::fs::create_dir_all(&inst_dir).unwrap();
1352 std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1353
1354 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1356 std::fs::create_dir_all(&new_cache_dir).unwrap();
1357 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1358
1359 let mut installed_files = std::collections::HashMap::new();
1361 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1362
1363 apply_dir_patches(&entry, &installed_files, &new_cache_dir, dir.path()).unwrap();
1364
1365 let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1367 assert_eq!(installed_after, installed_content);
1368
1369 let rebased_patch = std::fs::read_to_string(skillfile_core::patch::dir_patch_path(
1372 &entry,
1373 "SKILL.md",
1374 dir.path(),
1375 ))
1376 .unwrap();
1377 let rebase_result =
1378 skillfile_core::patch::apply_patch_pure(new_cache_content, &rebased_patch).unwrap();
1379 assert_eq!(
1380 rebase_result, expected_rebased_to_new_cache,
1381 "rebased patch applied to new_cache must reproduce installed_content"
1382 );
1383 }
1384
1385 #[test]
1386 fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1387 let dir = tempfile::tempdir().unwrap();
1388
1389 let original = "# Skill\n\nOriginal.\n";
1391 let modified = "# Skill\n\nModified.\n";
1392 let new_cache = modified; let entry = make_dir_skill_entry("lang-pro");
1396
1397 let patch_text = skillfile_core::patch::generate_patch(original, modified, "SKILL.md");
1398 skillfile_core::patch::write_dir_patch(&entry, "SKILL.md", &patch_text, dir.path())
1399 .unwrap();
1400
1401 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1403 std::fs::create_dir_all(&inst_dir).unwrap();
1404 std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1405
1406 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1407 std::fs::create_dir_all(&new_cache_dir).unwrap();
1408 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1409
1410 let mut installed_files = std::collections::HashMap::new();
1411 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1412
1413 apply_dir_patches(&entry, &installed_files, &new_cache_dir, dir.path()).unwrap();
1414
1415 let patch_path = skillfile_core::patch::dir_patch_path(&entry, "SKILL.md", dir.path());
1417 assert!(
1418 !patch_path.exists(),
1419 "patch file must be removed when rebase yields empty diff"
1420 );
1421 }
1422
1423 #[test]
1424 fn apply_dir_patches_no_op_when_no_patches_dir() {
1425 let dir = tempfile::tempdir().unwrap();
1426
1427 let entry = make_dir_skill_entry("lang-pro");
1429 let installed_files = std::collections::HashMap::new();
1430 let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1431 std::fs::create_dir_all(&source_dir).unwrap();
1432
1433 apply_dir_patches(&entry, &installed_files, &source_dir, dir.path()).unwrap();
1435 }
1436
1437 #[test]
1442 fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1443 let dir = tempfile::tempdir().unwrap();
1444
1445 let original = "# Skill\n\nOriginal.\n";
1446 let modified = "# Skill\n\nModified.\n";
1447 let new_cache = modified;
1449
1450 let entry = make_skill_entry("test");
1451
1452 let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1454 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1455
1456 let vdir = dir.path().join(".skillfile/cache/skills/test");
1458 std::fs::create_dir_all(&vdir).unwrap();
1459 let source = vdir.join("test.md");
1460 std::fs::write(&source, new_cache).unwrap();
1461
1462 let installed_dir = dir.path().join(".claude/skills");
1464 std::fs::create_dir_all(&installed_dir).unwrap();
1465 let dest = installed_dir.join("test.md");
1466 std::fs::write(&dest, original).unwrap();
1467
1468 apply_single_file_patch(&entry, &dest, &source, dir.path()).unwrap();
1469
1470 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1472
1473 assert!(
1475 !skillfile_core::patch::has_patch(&entry, dir.path()),
1476 "patch must be removed when new cache already matches patched content"
1477 );
1478 }
1479
1480 #[test]
1481 fn apply_single_file_patch_rewrites_patch_after_rebase() {
1482 let dir = tempfile::tempdir().unwrap();
1483
1484 let original = "# Skill\n\nOriginal.\n";
1486 let modified = "# Skill\n\nModified.\n";
1487 let new_cache = "# Skill\n\nOriginal v2.\n";
1488 let expected_rebased_result = modified;
1491
1492 let entry = make_skill_entry("test");
1493
1494 let patch_text = skillfile_core::patch::generate_patch(original, modified, "test.md");
1495 skillfile_core::patch::write_patch(&entry, &patch_text, dir.path()).unwrap();
1496
1497 let vdir = dir.path().join(".skillfile/cache/skills/test");
1499 std::fs::create_dir_all(&vdir).unwrap();
1500 let source = vdir.join("test.md");
1501 std::fs::write(&source, new_cache).unwrap();
1502
1503 let installed_dir = dir.path().join(".claude/skills");
1505 std::fs::create_dir_all(&installed_dir).unwrap();
1506 let dest = installed_dir.join("test.md");
1507 std::fs::write(&dest, original).unwrap();
1508
1509 apply_single_file_patch(&entry, &dest, &source, dir.path()).unwrap();
1510
1511 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1513
1514 assert!(
1517 skillfile_core::patch::has_patch(&entry, dir.path()),
1518 "rebased patch must still exist (new_cache != modified)"
1519 );
1520 let rebased = skillfile_core::patch::read_patch(&entry, dir.path()).unwrap();
1521 let result = skillfile_core::patch::apply_patch_pure(new_cache, &rebased).unwrap();
1522 assert_eq!(
1523 result, expected_rebased_result,
1524 "rebased patch applied to new_cache must reproduce installed content"
1525 );
1526 }
1527
1528 #[test]
1533 fn check_preconditions_no_targets_returns_error() {
1534 let dir = tempfile::tempdir().unwrap();
1535 let manifest = Manifest {
1536 entries: vec![],
1537 install_targets: vec![],
1538 };
1539 let result = check_preconditions(&manifest, dir.path());
1540 assert!(result.is_err());
1541 assert!(result
1542 .unwrap_err()
1543 .to_string()
1544 .contains("No install targets"));
1545 }
1546
1547 #[test]
1548 fn check_preconditions_pending_conflict_returns_error() {
1549 use skillfile_core::conflict::write_conflict;
1550 use skillfile_core::models::ConflictState;
1551
1552 let dir = tempfile::tempdir().unwrap();
1553 let manifest = Manifest {
1554 entries: vec![],
1555 install_targets: vec![make_target("claude-code", Scope::Local)],
1556 };
1557
1558 write_conflict(
1559 dir.path(),
1560 &ConflictState {
1561 entry: "my-skill".into(),
1562 entity_type: "skill".into(),
1563 old_sha: "aaa".into(),
1564 new_sha: "bbb".into(),
1565 },
1566 )
1567 .unwrap();
1568
1569 let result = check_preconditions(&manifest, dir.path());
1570 assert!(result.is_err());
1571 assert!(result.unwrap_err().to_string().contains("pending conflict"));
1572 }
1573
1574 #[test]
1575 fn check_preconditions_ok_with_target_and_no_conflict() {
1576 let dir = tempfile::tempdir().unwrap();
1577 let manifest = Manifest {
1578 entries: vec![],
1579 install_targets: vec![make_target("claude-code", Scope::Local)],
1580 };
1581 check_preconditions(&manifest, dir.path()).unwrap();
1582 }
1583
1584 #[test]
1589 fn deploy_all_patch_conflict_writes_conflict_state() {
1590 use skillfile_core::conflict::{has_conflict, read_conflict};
1591 use skillfile_core::lock::write_lock;
1592 use skillfile_core::models::LockEntry;
1593 use std::collections::BTreeMap;
1594
1595 let dir = tempfile::tempdir().unwrap();
1596 let name = "test";
1597
1598 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1600 std::fs::create_dir_all(&vdir).unwrap();
1601 std::fs::write(
1602 vdir.join(format!("{name}.md")),
1603 "totally different content\n",
1604 )
1605 .unwrap();
1606
1607 let entry = make_skill_entry(name);
1609 let bad_patch =
1610 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-expected_original_line\n+modified\n";
1611 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1612
1613 let inst_dir = dir.path().join(".claude/skills");
1615 std::fs::create_dir_all(&inst_dir).unwrap();
1616 std::fs::write(
1617 inst_dir.join(format!("{name}.md")),
1618 "totally different content\n",
1619 )
1620 .unwrap();
1621
1622 let manifest = Manifest {
1624 entries: vec![entry.clone()],
1625 install_targets: vec![make_target("claude-code", Scope::Local)],
1626 };
1627
1628 let lock_key_str = format!("github/skill/{name}");
1630 let old_sha = "a".repeat(40);
1631 let new_sha = "b".repeat(40);
1632
1633 let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1634 old_locked.insert(
1635 lock_key_str.clone(),
1636 LockEntry {
1637 sha: old_sha.clone(),
1638 raw_url: "https://example.com/old.md".into(),
1639 },
1640 );
1641
1642 let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1643 new_locked.insert(
1644 lock_key_str,
1645 LockEntry {
1646 sha: new_sha.clone(),
1647 raw_url: "https://example.com/new.md".into(),
1648 },
1649 );
1650
1651 write_lock(dir.path(), &new_locked).unwrap();
1652
1653 let opts = InstallOptions {
1654 dry_run: false,
1655 overwrite: true,
1656 };
1657
1658 let result = deploy_all(&manifest, dir.path(), &opts, &new_locked, &old_locked);
1659
1660 assert!(
1662 result.is_err(),
1663 "deploy_all must return Err on PatchConflict"
1664 );
1665 let err_msg = result.unwrap_err().to_string();
1666 assert!(
1667 err_msg.contains("conflict"),
1668 "error message must mention conflict: {err_msg}"
1669 );
1670
1671 assert!(
1673 has_conflict(dir.path()),
1674 "conflict state file must be written after PatchConflict"
1675 );
1676
1677 let conflict = read_conflict(dir.path()).unwrap().unwrap();
1678 assert_eq!(conflict.entry, name);
1679 assert_eq!(conflict.old_sha, old_sha);
1680 assert_eq!(conflict.new_sha, new_sha);
1681 }
1682
1683 #[test]
1684 fn deploy_all_patch_conflict_error_message_contains_sha_context() {
1685 use skillfile_core::lock::write_lock;
1686 use skillfile_core::models::LockEntry;
1687 use std::collections::BTreeMap;
1688
1689 let dir = tempfile::tempdir().unwrap();
1690 let name = "test";
1691
1692 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1693 std::fs::create_dir_all(&vdir).unwrap();
1694 std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
1695
1696 let entry = make_skill_entry(name);
1697 let bad_patch =
1698 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
1699 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1700
1701 let inst_dir = dir.path().join(".claude/skills");
1702 std::fs::create_dir_all(&inst_dir).unwrap();
1703 std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
1704
1705 let manifest = Manifest {
1706 entries: vec![entry.clone()],
1707 install_targets: vec![make_target("claude-code", Scope::Local)],
1708 };
1709
1710 let lock_key_str = format!("github/skill/{name}");
1711 let old_sha = "aabbccddeeff001122334455aabbccddeeff0011".to_string();
1712 let new_sha = "99887766554433221100ffeeddccbbaa99887766".to_string();
1713
1714 let mut old_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1715 old_locked.insert(
1716 lock_key_str.clone(),
1717 LockEntry {
1718 sha: old_sha.clone(),
1719 raw_url: "https://example.com/old.md".into(),
1720 },
1721 );
1722
1723 let mut new_locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1724 new_locked.insert(
1725 lock_key_str,
1726 LockEntry {
1727 sha: new_sha.clone(),
1728 raw_url: "https://example.com/new.md".into(),
1729 },
1730 );
1731
1732 write_lock(dir.path(), &new_locked).unwrap();
1733
1734 let opts = InstallOptions {
1735 dry_run: false,
1736 overwrite: true,
1737 };
1738
1739 let result = deploy_all(&manifest, dir.path(), &opts, &new_locked, &old_locked);
1740 assert!(result.is_err());
1741
1742 let err_msg = result.unwrap_err().to_string();
1743
1744 assert!(
1746 err_msg.contains('\u{2192}'),
1747 "error message must contain the SHA arrow (→): {err_msg}"
1748 );
1749 assert!(
1751 err_msg.contains(&old_sha[..12]),
1752 "error must contain old SHA prefix: {err_msg}"
1753 );
1754 assert!(
1755 err_msg.contains(&new_sha[..12]),
1756 "error must contain new SHA prefix: {err_msg}"
1757 );
1758 }
1759
1760 #[test]
1761 fn deploy_all_patch_conflict_error_message_has_resolve_hints() {
1762 use skillfile_core::lock::write_lock;
1763 use skillfile_core::models::LockEntry;
1764 use std::collections::BTreeMap;
1765
1766 let dir = tempfile::tempdir().unwrap();
1767 let name = "test";
1768
1769 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1770 std::fs::create_dir_all(&vdir).unwrap();
1771 std::fs::write(vdir.join(format!("{name}.md")), "different\n").unwrap();
1772
1773 let entry = make_skill_entry(name);
1774 let bad_patch =
1775 "--- a/test.md\n+++ b/test.md\n@@ -1,1 +1,1 @@\n-nonexistent_line\n+other\n";
1776 skillfile_core::patch::write_patch(&entry, bad_patch, dir.path()).unwrap();
1777
1778 let inst_dir = dir.path().join(".claude/skills");
1779 std::fs::create_dir_all(&inst_dir).unwrap();
1780 std::fs::write(inst_dir.join(format!("{name}.md")), "different\n").unwrap();
1781
1782 let manifest = Manifest {
1783 entries: vec![entry.clone()],
1784 install_targets: vec![make_target("claude-code", Scope::Local)],
1785 };
1786
1787 let lock_key_str = format!("github/skill/{name}");
1788 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1789 locked.insert(
1790 lock_key_str,
1791 LockEntry {
1792 sha: "abc123".into(),
1793 raw_url: "https://example.com/test.md".into(),
1794 },
1795 );
1796 write_lock(dir.path(), &locked).unwrap();
1797
1798 let opts = InstallOptions {
1799 dry_run: false,
1800 overwrite: true,
1801 };
1802
1803 let result = deploy_all(
1804 &manifest,
1805 dir.path(),
1806 &opts,
1807 &locked,
1808 &BTreeMap::new(), );
1810 assert!(result.is_err());
1811
1812 let err_msg = result.unwrap_err().to_string();
1813 assert!(
1814 err_msg.contains("skillfile resolve"),
1815 "error must mention resolve command: {err_msg}"
1816 );
1817 assert!(
1818 err_msg.contains("skillfile diff"),
1819 "error must mention diff command: {err_msg}"
1820 );
1821 assert!(
1822 err_msg.contains("--abort"),
1823 "error must mention --abort: {err_msg}"
1824 );
1825 }
1826
1827 #[test]
1828 fn deploy_all_unknown_platform_skips_gracefully() {
1829 use std::collections::BTreeMap;
1830
1831 let dir = tempfile::tempdir().unwrap();
1832
1833 let manifest = Manifest {
1835 entries: vec![],
1836 install_targets: vec![InstallTarget {
1837 adapter: "unknown-tool".into(),
1838 scope: Scope::Local,
1839 }],
1840 };
1841
1842 let opts = InstallOptions {
1843 dry_run: false,
1844 overwrite: true,
1845 };
1846
1847 deploy_all(
1849 &manifest,
1850 dir.path(),
1851 &opts,
1852 &BTreeMap::new(),
1853 &BTreeMap::new(),
1854 )
1855 .unwrap();
1856 }
1857}