1use std::cell::Cell;
2use std::collections::{BTreeMap, HashMap, HashSet};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use skillfile_core::conflict::{read_conflict, write_conflict};
8use skillfile_core::error::SkillfileError;
9use skillfile_core::lock::{lock_key, read_lock};
10use skillfile_core::models::{
11 short_sha, ConflictState, Entry, InstallOptions, InstallTarget, Manifest,
12};
13use skillfile_core::parser::{parse_manifest, MANIFEST_NAME};
14use skillfile_core::patch::{
15 apply_patch_pure, dir_patch_path, generate_patch, has_patch, patch_path, patches_root,
16 read_patch, remove_dir_patch, remove_patch, walkdir, write_dir_patch, write_patch,
17};
18use skillfile_core::progress;
19use skillfile_sources::strategy::{content_file, is_dir_entry};
20use skillfile_sources::sync::{cmd_sync, vendor_dir_for};
21
22use crate::adapter::{adapters, AdapterScope, DeployRequest, DirInstallMode, PlatformAdapter};
23use crate::paths::source_path;
24
25static SNAPSHOT_COUNTER: AtomicU64 = AtomicU64::new(0);
26
27fn to_patch_conflict(err: &SkillfileError, entry_name: &str) -> SkillfileError {
32 SkillfileError::PatchConflict {
33 message: err.to_string(),
34 entry_name: entry_name.to_string(),
35 }
36}
37
38struct PatchCtx<'a> {
39 entry: &'a Entry,
40 repo_root: &'a Path,
41}
42
43fn rebase_single_patch(
46 ctx: &PatchCtx<'_>,
47 source: &Path,
48 patched: &str,
49) -> Result<(), SkillfileError> {
50 let cache_text = std::fs::read_to_string(source)?;
51 let new_patch = generate_patch(&cache_text, patched, &format!("{}.md", ctx.entry.name));
52 if new_patch.is_empty() {
53 remove_patch(ctx.entry, ctx.repo_root)?;
54 } else {
55 write_patch(ctx.entry, &new_patch, ctx.repo_root)?;
56 }
57 Ok(())
58}
59
60fn apply_single_file_patch(
63 ctx: &PatchCtx<'_>,
64 dest: &Path,
65 source: &Path,
66) -> Result<(), SkillfileError> {
67 if !has_patch(ctx.entry, ctx.repo_root) {
68 return Ok(());
69 }
70 let patch_text = read_patch(ctx.entry, ctx.repo_root)?;
71 let original = std::fs::read_to_string(dest)?;
72 let patched = apply_patch_pure(&original, &patch_text)
73 .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
74 std::fs::write(dest, &patched)?;
75
76 rebase_single_patch(ctx, source, &patched)
78}
79
80fn apply_dir_patches(
83 ctx: &PatchCtx<'_>,
84 installed_files: &HashMap<String, PathBuf>,
85 source_dir: &Path,
86) -> Result<(), SkillfileError> {
87 let patches_dir = patches_root(ctx.repo_root)
88 .join(ctx.entry.entity_type.dir_name())
89 .join(&ctx.entry.name);
90 if !patches_dir.is_dir() {
91 return Ok(());
92 }
93
94 let patch_files: Vec<PathBuf> = walkdir(&patches_dir)
95 .into_iter()
96 .filter(|p| p.extension().is_some_and(|e| e == "patch"))
97 .collect();
98
99 for patch_file in patch_files {
100 let Some(rel) = patch_file
101 .strip_prefix(&patches_dir)
102 .ok()
103 .and_then(|p| p.to_str())
104 .and_then(|s| s.strip_suffix(".patch"))
105 .map(str::to_string)
106 else {
107 continue;
108 };
109
110 let Some(target) = installed_files.get(&rel).filter(|p| p.exists()) else {
111 continue;
112 };
113
114 let patch_text = std::fs::read_to_string(&patch_file)?;
115 let original = std::fs::read_to_string(target)?;
116 let patched = apply_patch_pure(&original, &patch_text)
117 .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
118 std::fs::write(target, &patched)?;
119
120 let cache_file = source_dir.join(&rel);
122 if !cache_file.exists() {
123 continue;
124 }
125 let cache_text = std::fs::read_to_string(&cache_file)?;
126 let new_patch = generate_patch(&cache_text, &patched, &rel);
127 if new_patch.is_empty() {
128 std::fs::remove_file(&patch_file)?;
129 } else {
130 write_dir_patch(&dir_patch_path(ctx.entry, &rel, ctx.repo_root), &new_patch)?;
131 }
132 }
133 Ok(())
134}
135
136fn patch_already_covers(patch_text: &str, cache_text: &str, installed_text: &str) -> bool {
147 match apply_patch_pure(cache_text, patch_text) {
148 Ok(expected) if installed_text == expected => true, Err(_) => true, Ok(_) => false, }
152}
153
154fn should_skip_pin(ctx: &PatchCtx<'_>, cache_text: &str, installed_text: &str) -> bool {
155 if !has_patch(ctx.entry, ctx.repo_root) {
156 return false;
157 }
158 let Ok(pt) = read_patch(ctx.entry, ctx.repo_root) else {
159 return false;
160 };
161 patch_already_covers(&pt, cache_text, installed_text)
162}
163
164fn divergent_auto_pin_error(entry_name: &str, labels: &[String]) -> SkillfileError {
165 SkillfileError::Install(format!(
166 "'{entry_name}' has divergent edits across install targets: {} — reconcile them before running `skillfile install --update`",
167 labels.join(", ")
168 ))
169}
170
171fn target_adapter(target: &InstallTarget) -> Result<&'static dyn PlatformAdapter, SkillfileError> {
172 adapters()
173 .get(&target.adapter)
174 .ok_or_else(|| SkillfileError::Manifest(format!("unknown adapter '{}'", target.adapter)))
175}
176
177struct SingleInstalledVariant {
178 label: String,
179 content: String,
180}
181
182fn installed_single_file_variants(
183 entry: &Entry,
184 manifest: &Manifest,
185 repo_root: &Path,
186) -> Result<Vec<SingleInstalledVariant>, SkillfileError> {
187 let mut variants = Vec::new();
188 for target in &manifest.install_targets {
189 let adapter = target_adapter(target)?;
190 if !adapter.supports(entry.entity_type) {
191 continue;
192 }
193 let scope = AdapterScope {
194 scope: target.scope,
195 repo_root,
196 };
197 let path = adapter.installed_path(entry, &scope);
198 if !path.exists() {
199 continue;
200 }
201 variants.push(SingleInstalledVariant {
202 label: target.to_string(),
203 content: std::fs::read_to_string(path)?,
204 });
205 }
206 Ok(variants)
207}
208
209fn representative_single_file_content(
210 entry_name: &str,
211 cache_text: &str,
212 variants: &[SingleInstalledVariant],
213) -> Result<Option<String>, SkillfileError> {
214 let modified: Vec<&SingleInstalledVariant> = variants
215 .iter()
216 .filter(|variant| variant.content != cache_text)
217 .collect();
218 if modified.is_empty() {
219 return Ok(None);
220 }
221 let representative = &modified[0].content;
222 if modified
223 .iter()
224 .any(|variant| variant.content != *representative)
225 {
226 let labels: Vec<String> = modified
227 .iter()
228 .map(|variant| variant.label.clone())
229 .collect();
230 return Err(divergent_auto_pin_error(entry_name, &labels));
231 }
232 Ok(Some(representative.clone()))
233}
234
235struct AutoPinSingleCtx<'a> {
236 entry: &'a Entry,
237 manifest: &'a Manifest,
238 repo_root: &'a Path,
239 cache_file: &'a Path,
240}
241
242fn auto_pin_single_file_entry(ctx: &AutoPinSingleCtx<'_>) -> Result<(), SkillfileError> {
243 let cache_text = std::fs::read_to_string(ctx.cache_file)?;
244 let variants = installed_single_file_variants(ctx.entry, ctx.manifest, ctx.repo_root)?;
245 let Some(installed_text) =
246 representative_single_file_content(&ctx.entry.name, &cache_text, &variants)?
247 else {
248 return Ok(());
249 };
250
251 let patch_ctx = PatchCtx {
252 entry: ctx.entry,
253 repo_root: ctx.repo_root,
254 };
255 if should_skip_pin(&patch_ctx, &cache_text, &installed_text) {
256 return Ok(());
257 }
258
259 let patch_text = generate_patch(
260 &cache_text,
261 &installed_text,
262 &format!("{}.md", ctx.entry.name),
263 );
264 if !patch_text.is_empty() && write_patch(ctx.entry, &patch_text, ctx.repo_root).is_ok() {
265 progress!(
266 " {}: local changes auto-saved to .skillfile/patches/",
267 ctx.entry.name
268 );
269 }
270 Ok(())
271}
272
273fn auto_pin_entry(
274 entry: &Entry,
275 manifest: &Manifest,
276 repo_root: &Path,
277) -> Result<(), SkillfileError> {
278 if entry.source_type() == "local" {
279 return Ok(());
280 }
281
282 let Ok(locked) = read_lock(repo_root) else {
283 return Ok(());
284 };
285 let key = lock_key(entry);
286 if !locked.contains_key(&key) {
287 return Ok(());
288 }
289
290 let vdir = vendor_dir_for(entry, repo_root);
291
292 if is_dir_entry(entry) {
293 return auto_pin_dir_entry(entry, manifest, repo_root);
294 }
295
296 let cf = content_file(entry);
297 if cf.is_empty() {
298 return Ok(());
299 }
300 let cache_file = vdir.join(&cf);
301 if !cache_file.exists() {
302 return Ok(());
303 }
304 auto_pin_single_file_entry(&AutoPinSingleCtx {
305 entry,
306 manifest,
307 repo_root,
308 cache_file: &cache_file,
309 })
310}
311
312fn dir_patch_already_matches(patch_path: &Path, cache_text: &str, installed_text: &str) -> bool {
315 if !patch_path.exists() {
316 return false;
317 }
318 let Ok(pt) = std::fs::read_to_string(patch_path) else {
319 return false;
320 };
321 patch_already_covers(&pt, cache_text, installed_text)
322}
323
324fn load_cache_files(vdir: &Path) -> BTreeMap<String, PathBuf> {
325 walkdir(vdir)
326 .into_iter()
327 .filter(|cache_file| cache_file.file_name().is_none_or(|name| name != ".meta"))
328 .filter_map(|cache_file| {
329 let filename = cache_file
330 .strip_prefix(vdir)
331 .ok()
332 .and_then(|path| path.to_str())
333 .map(str::to_string)?;
334 Some((filename, cache_file))
335 })
336 .collect()
337}
338
339struct DirInstalledVariant {
340 label: String,
341 files: HashMap<String, PathBuf>,
342}
343
344type DirModifiedMap = BTreeMap<String, String>;
345
346fn installed_dir_variants(
347 entry: &Entry,
348 manifest: &Manifest,
349 repo_root: &Path,
350) -> Result<Vec<DirInstalledVariant>, SkillfileError> {
351 let mut variants = Vec::new();
352 for target in &manifest.install_targets {
353 let adapter = target_adapter(target)?;
354 if !adapter.supports(entry.entity_type) {
355 continue;
356 }
357 let scope = AdapterScope {
358 scope: target.scope,
359 repo_root,
360 };
361 let files = adapter.installed_dir_files(entry, &scope);
362 if files.is_empty() {
363 continue;
364 }
365 variants.push(DirInstalledVariant {
366 label: target.to_string(),
367 files,
368 });
369 }
370 Ok(variants)
371}
372
373fn modified_dir_content(
374 cache_files: &BTreeMap<String, PathBuf>,
375 variant: &DirInstalledVariant,
376) -> Result<DirModifiedMap, SkillfileError> {
377 let mut modified = BTreeMap::new();
378 for (filename, cache_file) in cache_files {
379 let Some(installed_path) = variant.files.get(filename).filter(|path| path.exists()) else {
380 continue;
381 };
382 let cache_text = std::fs::read_to_string(cache_file)?;
383 let installed_text = std::fs::read_to_string(installed_path)?;
384 if installed_text != cache_text {
385 modified.insert(filename.clone(), installed_text);
386 }
387 }
388 Ok(modified)
389}
390
391fn representative_dir_changes(
392 entry_name: &str,
393 cache_files: &BTreeMap<String, PathBuf>,
394 variants: &[DirInstalledVariant],
395) -> Result<Option<DirModifiedMap>, SkillfileError> {
396 let mut modified = Vec::new();
397 for variant in variants {
398 let changed = modified_dir_content(cache_files, variant)?;
399 if !changed.is_empty() {
400 modified.push((variant.label.clone(), changed));
401 }
402 }
403 if modified.is_empty() {
404 return Ok(None);
405 }
406 let representative = &modified[0].1;
407 if modified
408 .iter()
409 .any(|(_, changed)| changed != representative)
410 {
411 let labels: Vec<String> = modified.iter().map(|(label, _)| label.clone()).collect();
412 return Err(divergent_auto_pin_error(entry_name, &labels));
413 }
414 Ok(Some(representative.clone()))
415}
416
417struct WriteDirPatchesCtx<'a> {
418 entry: &'a Entry,
419 repo_root: &'a Path,
420 cache_files: &'a BTreeMap<String, PathBuf>,
421 representative: &'a DirModifiedMap,
422}
423
424fn write_auto_pin_dir_patches(ctx: &WriteDirPatchesCtx<'_>) -> Result<Vec<String>, SkillfileError> {
425 let mut pinned = Vec::new();
426 for (filename, cache_file) in ctx.cache_files {
427 let Some(installed_text) = ctx.representative.get(filename) else {
428 remove_dir_patch(ctx.entry, filename, ctx.repo_root)?;
429 continue;
430 };
431
432 let cache_text = std::fs::read_to_string(cache_file)?;
433 let patch_path = dir_patch_path(ctx.entry, filename, ctx.repo_root);
434 if dir_patch_already_matches(&patch_path, &cache_text, installed_text) {
435 continue;
436 }
437
438 let patch_text = generate_patch(&cache_text, installed_text, filename);
439 if patch_text.is_empty() {
440 continue;
441 }
442
443 write_dir_patch(&patch_path, &patch_text)?;
444 pinned.push(filename.clone());
445 }
446 Ok(pinned)
447}
448
449fn auto_pin_dir_entry(
450 entry: &Entry,
451 manifest: &Manifest,
452 repo_root: &Path,
453) -> Result<(), SkillfileError> {
454 let vdir = &vendor_dir_for(entry, repo_root);
455 if !vdir.is_dir() {
456 return Ok(());
457 }
458 let installed = installed_dir_variants(entry, manifest, repo_root)?;
459 if installed.is_empty() {
460 return Ok(());
461 }
462 let cache_files = load_cache_files(vdir);
463 let Some(representative) = representative_dir_changes(&entry.name, &cache_files, &installed)?
464 else {
465 return Ok(());
466 };
467 let pinned = write_auto_pin_dir_patches(&WriteDirPatchesCtx {
468 entry,
469 repo_root,
470 cache_files: &cache_files,
471 representative: &representative,
472 })?;
473
474 if !pinned.is_empty() {
475 progress!(
476 " {}: local changes auto-saved to .skillfile/patches/ ({})",
477 entry.name,
478 pinned.join(", ")
479 );
480 }
481 Ok(())
482}
483
484pub struct InstallCtx<'a> {
489 pub repo_root: &'a Path,
490 pub opts: Option<&'a InstallOptions>,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum InstallSkipReason {
496 UnknownAdapter,
497 UnsupportedEntity,
498 MissingSource,
499 NothingDeployed,
500 DryRun,
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum InstallOutcome {
506 Installed,
507 Skipped(InstallSkipReason),
508}
509
510pub fn install_entry(
511 entry: &Entry,
512 target: &InstallTarget,
513 ctx: &InstallCtx<'_>,
514) -> Result<(), SkillfileError> {
515 let _ = install_entry_with_outcome(entry, target, ctx)?;
516 Ok(())
517}
518
519fn install_failure(entry: &Entry, target: &InstallTarget, detail: &str) -> SkillfileError {
520 SkillfileError::Install(format!(
521 "failed to install '{}' to {target}: {detail}",
522 entry.name
523 ))
524}
525
526struct PathSnapshot {
527 live_path: PathBuf,
528 snapshot_path: Option<PathBuf>,
529}
530
531#[derive(Default)]
532pub struct InstallSnapshot {
533 scratch_dir: Option<PathBuf>,
534 paths: Vec<PathSnapshot>,
535 preserve_scratch: Cell<bool>,
536}
537
538impl Drop for InstallSnapshot {
539 fn drop(&mut self) {
540 if self.preserve_scratch.get() {
541 return;
542 }
543 if let Some(path) = &self.scratch_dir {
544 let _ = std::fs::remove_dir_all(path);
545 remove_empty_dir(path.parent());
546 }
547 }
548}
549
550fn remove_empty_dir(path: Option<&Path>) {
551 let Some(path) = path else {
552 return;
553 };
554 if path
555 .read_dir()
556 .is_ok_and(|mut entries| entries.next().is_none())
557 {
558 let _ = std::fs::remove_dir(path);
559 }
560}
561
562fn remove_path(path: &Path) -> std::io::Result<()> {
563 let Ok(metadata) = std::fs::symlink_metadata(path) else {
564 return Ok(());
565 };
566 let file_type = metadata.file_type();
567 #[cfg(windows)]
568 {
569 use std::os::windows::fs::FileTypeExt as _;
570
571 if file_type.is_symlink_dir() {
572 return std::fs::remove_dir(path);
573 }
574 }
575 if file_type.is_dir() {
576 std::fs::remove_dir_all(path)
577 } else {
578 std::fs::remove_file(path)
579 }
580}
581
582#[cfg(unix)]
583fn create_symlink(target: &Path, link: &Path, _is_dir: bool) -> std::io::Result<()> {
584 std::os::unix::fs::symlink(target, link)
585}
586
587#[cfg(windows)]
588fn create_symlink(target: &Path, link: &Path, is_dir: bool) -> std::io::Result<()> {
589 if is_dir {
590 std::os::windows::fs::symlink_dir(target, link)
591 } else {
592 std::os::windows::fs::symlink_file(target, link)
593 }
594}
595
596#[cfg(windows)]
597fn symlink_is_dir(path: &Path) -> std::io::Result<bool> {
598 use std::os::windows::fs::FileTypeExt as _;
599
600 Ok(std::fs::symlink_metadata(path)?
601 .file_type()
602 .is_symlink_dir())
603}
604
605#[cfg(windows)]
606fn copy_symlink(source: &Path, dest: &Path) -> std::io::Result<()> {
607 let target = std::fs::read_link(source)?;
608 create_symlink(&target, dest, symlink_is_dir(source)?)
609}
610
611#[cfg(not(windows))]
612fn copy_symlink(source: &Path, dest: &Path) -> std::io::Result<()> {
613 let target = std::fs::read_link(source)?;
614 create_symlink(&target, dest, false)
615}
616
617fn copy_path(source: &Path, dest: &Path) -> std::io::Result<()> {
618 if let Some(parent) = dest.parent() {
619 std::fs::create_dir_all(parent)?;
620 }
621 let metadata = std::fs::symlink_metadata(source)?;
622 if metadata.file_type().is_symlink() {
623 copy_symlink(source, dest)
624 } else if metadata.is_dir() {
625 copy_snapshot_dir(source, dest)
626 } else {
627 std::fs::copy(source, dest).map(|_| ())
628 }
629}
630
631fn copy_snapshot_dir(source: &Path, dest: &Path) -> std::io::Result<()> {
632 std::fs::create_dir_all(dest)?;
633 for entry in std::fs::read_dir(source)? {
634 let entry = entry?;
635 let source_path = entry.path();
636 let dest_path = dest.join(entry.file_name());
637 copy_path(&source_path, &dest_path)?;
638 }
639 Ok(())
640}
641
642fn snapshot_scratch_dir(repo_root: &Path) -> std::io::Result<PathBuf> {
643 let stamp = SystemTime::now()
644 .duration_since(UNIX_EPOCH)
645 .map_or(0, |duration| duration.as_nanos());
646 let seq = SNAPSHOT_COUNTER.fetch_add(1, Ordering::Relaxed);
647 let dir = repo_root.join(".skillfile").join("tmp").join(format!(
648 "install-snapshot-{}-{stamp}-{seq}",
649 std::process::id()
650 ));
651 std::fs::create_dir_all(&dir)?;
652 Ok(dir)
653}
654
655fn capture_path_snapshot(
656 live_path: PathBuf,
657 scratch_dir: &Path,
658 index: usize,
659) -> std::io::Result<PathSnapshot> {
660 let snapshot_path = if live_path.exists() || live_path.is_symlink() {
661 let snapshot_path = scratch_dir.join(index.to_string());
662 copy_path(&live_path, &snapshot_path)?;
663 Some(snapshot_path)
664 } else {
665 None
666 };
667 Ok(PathSnapshot {
668 live_path,
669 snapshot_path,
670 })
671}
672
673impl InstallSnapshot {
674 fn capture(repo_root: &Path, paths: Vec<PathBuf>) -> Result<Self, SkillfileError> {
675 let mut seen = HashSet::new();
676 let paths: Vec<PathBuf> = paths
677 .into_iter()
678 .filter(|path| seen.insert(path.clone()))
679 .collect();
680 if paths.is_empty() {
681 return Ok(Self::default());
682 }
683
684 let scratch_dir = snapshot_scratch_dir(repo_root)?;
685 let mut snapshot = Self {
686 scratch_dir: Some(scratch_dir.clone()),
687 paths: Vec::new(),
688 preserve_scratch: Cell::new(false),
689 };
690 for (index, path) in paths.into_iter().enumerate() {
691 snapshot
692 .paths
693 .push(capture_path_snapshot(path, &scratch_dir, index)?);
694 }
695 Ok(snapshot)
696 }
697
698 pub fn restore(&self) -> Result<(), SkillfileError> {
699 for snapshot in self.paths.iter().rev() {
700 self.restore_path(snapshot)?;
701 }
702 Ok(())
703 }
704
705 fn scratch_dir(&self) -> Option<&Path> {
706 self.scratch_dir.as_deref()
707 }
708
709 fn restore_path(&self, snapshot: &PathSnapshot) -> Result<(), SkillfileError> {
710 let result = restore_path_snapshot(snapshot);
711 if result.is_err() {
712 self.preserve_scratch.set(true);
713 }
714 result.map_err(SkillfileError::from)
715 }
716}
717
718fn restore_path_snapshot(snapshot: &PathSnapshot) -> std::io::Result<()> {
719 remove_path(&snapshot.live_path)?;
720 let Some(snapshot_path) = &snapshot.snapshot_path else {
721 return Ok(());
722 };
723 copy_path(snapshot_path, &snapshot.live_path)
724}
725
726fn forward_slash(path: &Path) -> String {
727 path.to_string_lossy().replace('\\', "/")
728}
729
730struct InstallValidationCtx<'a> {
731 entry: &'a Entry,
732 target: &'a InstallTarget,
733 repo_root: &'a Path,
734 source: &'a Path,
735 adapter: &'a dyn PlatformAdapter,
736 is_dir: bool,
737 opts: &'a InstallOptions,
738}
739
740struct InstallPlan {
741 expected: HashMap<String, PathBuf>,
742 existing_before: HashSet<String>,
743}
744
745struct ValidatedInstall {
746 installed: HashMap<String, PathBuf>,
747 outcome: InstallOutcome,
748}
749
750fn flat_expected_paths(source: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
751 walkdir(source)
752 .into_iter()
753 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
754 .filter_map(|path| {
755 let rel = path.strip_prefix(source).ok()?;
756 let name = path.file_name()?;
757 Some((forward_slash(rel), target_dir.join(name)))
758 })
759 .collect()
760}
761
762fn nested_expected_paths(source: &Path, dest_root: &Path) -> HashMap<String, PathBuf> {
763 walkdir(source)
764 .into_iter()
765 .filter(|path| path.file_name().is_none_or(|name| name != ".meta"))
766 .filter_map(|path| {
767 let rel = path.strip_prefix(source).ok()?;
768 Some((forward_slash(rel), dest_root.join(rel)))
769 })
770 .collect()
771}
772
773fn planned_install_paths(ctx: &InstallValidationCtx<'_>) -> HashMap<String, PathBuf> {
774 let scope = AdapterScope {
775 scope: ctx.target.scope,
776 repo_root: ctx.repo_root,
777 };
778 let target_dir = ctx.adapter.target_dir(ctx.entry.entity_type, &scope);
779 if !ctx.is_dir {
780 let key = format!("{}.md", ctx.entry.name);
781 return HashMap::from([(key, ctx.adapter.installed_path(ctx.entry, &scope))]);
782 }
783
784 match ctx.adapter.dir_mode(ctx.entry.entity_type) {
785 Some(DirInstallMode::Flat) => flat_expected_paths(ctx.source, &target_dir),
786 _ => nested_expected_paths(ctx.source, &target_dir.join(&ctx.entry.name)),
787 }
788}
789
790fn patch_effect_path(ctx: &InstallValidationCtx<'_>) -> PathBuf {
791 if ctx.is_dir {
792 patches_root(ctx.repo_root)
793 .join(ctx.entry.entity_type.dir_name())
794 .join(&ctx.entry.name)
795 } else {
796 patch_path(ctx.entry, ctx.repo_root)
797 }
798}
799
800fn install_effect_paths(ctx: &InstallValidationCtx<'_>) -> Vec<PathBuf> {
801 let scope = AdapterScope {
802 scope: ctx.target.scope,
803 repo_root: ctx.repo_root,
804 };
805 let target_dir = ctx.adapter.target_dir(ctx.entry.entity_type, &scope);
806 let mut paths = if !ctx.is_dir {
807 vec![ctx.adapter.installed_path(ctx.entry, &scope)]
808 } else if ctx.adapter.dir_mode(ctx.entry.entity_type) == Some(DirInstallMode::Flat) {
809 flat_expected_paths(ctx.source, &target_dir)
810 .into_values()
811 .collect()
812 } else {
813 vec![target_dir.join(&ctx.entry.name)]
814 };
815
816 if ctx.adapter.dir_mode(ctx.entry.entity_type) != Some(DirInstallMode::Flat) {
817 paths.push(target_dir.join(format!("{}.md", ctx.entry.name)));
818 }
819 paths.push(patch_effect_path(ctx));
820 paths
821}
822
823pub fn capture_install_snapshot(
824 entry: &Entry,
825 targets: &[InstallTarget],
826 repo_root: &Path,
827) -> Result<InstallSnapshot, SkillfileError> {
828 let mut paths = Vec::new();
829 for target in targets {
830 paths.extend(install_effect_paths_for_target(entry, target, repo_root));
831 }
832 InstallSnapshot::capture(repo_root, paths)
833}
834
835fn install_effect_paths_for_target(
836 entry: &Entry,
837 target: &InstallTarget,
838 repo_root: &Path,
839) -> Vec<PathBuf> {
840 let all_adapters = adapters();
841 let Some(adapter) = all_adapters.get(&target.adapter) else {
842 return Vec::new();
843 };
844 if !adapter.supports(entry.entity_type) {
845 return Vec::new();
846 }
847 let Some(source) = source_path(entry, repo_root).filter(|path| path.exists()) else {
848 return Vec::new();
849 };
850 let default_opts = InstallOptions::default();
851 let validation_ctx = InstallValidationCtx {
852 entry,
853 target,
854 repo_root,
855 source: &source,
856 adapter,
857 is_dir: is_dir_entry(entry) || source.is_dir(),
858 opts: &default_opts,
859 };
860 install_effect_paths(&validation_ctx)
861}
862
863fn build_install_plan(ctx: &InstallValidationCtx<'_>) -> InstallPlan {
864 let expected = planned_install_paths(ctx);
865 let existing_before = expected
866 .iter()
867 .filter_map(|(key, path)| path.is_file().then_some(key.clone()))
868 .collect();
869 InstallPlan {
870 expected,
871 existing_before,
872 }
873}
874
875fn cleanup_created_files(plan: &InstallPlan, installed: &HashMap<String, PathBuf>) {
876 for (key, path) in installed {
877 if plan.existing_before.contains(key) {
878 continue;
879 }
880 let _ = std::fs::remove_file(path);
881 }
882}
883
884fn validate_installed_files(
885 ctx: &InstallValidationCtx<'_>,
886 plan: &InstallPlan,
887 installed: HashMap<String, PathBuf>,
888) -> Result<ValidatedInstall, SkillfileError> {
889 let reported_count = installed.len();
890 let existing = installed
891 .into_iter()
892 .filter(|(_, path)| path.is_file())
893 .collect::<HashMap<_, _>>();
894
895 if existing.len() != reported_count {
896 return Err(install_failure(
897 ctx.entry,
898 ctx.target,
899 "adapter reported installed files that do not exist on disk",
900 ));
901 }
902
903 let expected = plan.expected.len();
904 if expected == 0 {
905 return Ok(ValidatedInstall {
906 installed: existing,
907 outcome: InstallOutcome::Skipped(InstallSkipReason::NothingDeployed),
908 });
909 }
910
911 let present = plan.expected.values().filter(|path| path.is_file()).count();
912 if present == 0 {
913 cleanup_created_files(plan, &existing);
914 return Err(install_failure(
915 ctx.entry,
916 ctx.target,
917 "no files were written to the target platform directory",
918 ));
919 }
920
921 if present < expected {
922 cleanup_created_files(plan, &existing);
923 return Err(install_failure(
924 ctx.entry,
925 ctx.target,
926 &format!("only {present} of {expected} expected file(s) were written"),
927 ));
928 }
929
930 let outcome = if ctx.opts.overwrite || plan.existing_before.len() != expected {
931 InstallOutcome::Installed
932 } else {
933 InstallOutcome::Skipped(InstallSkipReason::NothingDeployed)
934 };
935 Ok(ValidatedInstall {
936 installed: existing,
937 outcome,
938 })
939}
940
941fn restore_on_install_error(snapshot: &InstallSnapshot, error: SkillfileError) -> SkillfileError {
942 match snapshot.restore() {
943 Ok(()) => error,
944 Err(rollback_error) => {
945 let snapshot_hint = snapshot.scratch_dir().map_or_else(String::new, |path| {
946 format!("; rollback snapshot kept at {}", path.display())
947 });
948 let rollback_detail = format!(
949 "rollback failed: {rollback_error}{snapshot_hint}; target may need manual cleanup"
950 );
951 match error {
952 SkillfileError::PatchConflict {
953 message,
954 entry_name,
955 } => SkillfileError::PatchConflict {
956 message: format!("{message}; {rollback_detail}"),
957 entry_name,
958 },
959 other => SkillfileError::Install(format!("{other}; {rollback_detail}")),
960 }
961 }
962 }
963}
964
965fn deploy_and_patch_entry(
966 validation_ctx: &InstallValidationCtx<'_>,
967 plan: &InstallPlan,
968) -> Result<InstallOutcome, SkillfileError> {
969 let installed = validation_ctx.adapter.deploy_entry(&DeployRequest {
970 entry: validation_ctx.entry,
971 source: validation_ctx.source,
972 scope: validation_ctx.target.scope,
973 repo_root: validation_ctx.repo_root,
974 opts: validation_ctx.opts,
975 });
976
977 if validation_ctx.opts.dry_run {
978 return Ok(InstallOutcome::Skipped(InstallSkipReason::DryRun));
979 }
980 let validated = validate_installed_files(validation_ctx, plan, installed)?;
981 if let InstallOutcome::Skipped(reason) = validated.outcome {
982 return Ok(InstallOutcome::Skipped(reason));
983 }
984
985 let patch_ctx = PatchCtx {
986 entry: validation_ctx.entry,
987 repo_root: validation_ctx.repo_root,
988 };
989 if validation_ctx.is_dir {
990 apply_dir_patches(&patch_ctx, &validated.installed, validation_ctx.source)?;
991 } else {
992 let key = format!("{}.md", validation_ctx.entry.name);
993 if let Some(dest) = validated.installed.get(&key) {
994 apply_single_file_patch(&patch_ctx, dest, validation_ctx.source)?;
995 }
996 }
997
998 Ok(InstallOutcome::Installed)
999}
1000
1001pub fn install_entry_with_outcome(
1003 entry: &Entry,
1004 target: &InstallTarget,
1005 ctx: &InstallCtx<'_>,
1006) -> Result<InstallOutcome, SkillfileError> {
1007 let default_opts = InstallOptions::default();
1008 let opts = ctx.opts.unwrap_or(&default_opts);
1009
1010 let all_adapters = adapters();
1011 let Some(adapter) = all_adapters.get(&target.adapter) else {
1012 return Ok(InstallOutcome::Skipped(InstallSkipReason::UnknownAdapter));
1013 };
1014
1015 if !adapter.supports(entry.entity_type) {
1016 return Ok(InstallOutcome::Skipped(
1017 InstallSkipReason::UnsupportedEntity,
1018 ));
1019 }
1020
1021 let source = match source_path(entry, ctx.repo_root) {
1022 Some(p) if p.exists() => p,
1023 _ => {
1024 eprintln!(" warning: source missing for {}, skipping", entry.name);
1025 return Ok(InstallOutcome::Skipped(InstallSkipReason::MissingSource));
1026 }
1027 };
1028
1029 let is_dir = is_dir_entry(entry) || source.is_dir();
1030 let validation_ctx = InstallValidationCtx {
1031 entry,
1032 target,
1033 repo_root: ctx.repo_root,
1034 source: &source,
1035 adapter,
1036 is_dir,
1037 opts,
1038 };
1039 let plan = build_install_plan(&validation_ctx);
1040 let snapshot = if opts.dry_run {
1041 InstallSnapshot::default()
1042 } else {
1043 InstallSnapshot::capture(ctx.repo_root, install_effect_paths(&validation_ctx))?
1044 };
1045 deploy_and_patch_entry(&validation_ctx, &plan)
1046 .map_err(|error| restore_on_install_error(&snapshot, error))
1047}
1048
1049fn check_preconditions(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
1054 if manifest.install_targets.is_empty() {
1055 return Err(SkillfileError::Manifest(
1056 "No install targets configured. Run `skillfile init` first.".into(),
1057 ));
1058 }
1059
1060 if let Some(conflict) = read_conflict(repo_root)? {
1061 return Err(SkillfileError::Install(format!(
1062 "pending conflict for '{}' — \
1063 run `skillfile diff {}` to review, \
1064 or `skillfile resolve {}` to merge",
1065 conflict.entry, conflict.entry, conflict.entry
1066 )));
1067 }
1068
1069 Ok(())
1070}
1071
1072fn sha_transition_hint(old_sha: &str, new_sha: &str) -> String {
1077 if !old_sha.is_empty() && !new_sha.is_empty() && old_sha != new_sha {
1078 format!(
1079 "\n upstream: {} \u{2192} {}",
1080 short_sha(old_sha),
1081 short_sha(new_sha)
1082 )
1083 } else {
1084 String::new()
1085 }
1086}
1087
1088struct LockMaps<'a> {
1089 locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
1090 old_locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
1091}
1092
1093struct DeployCtx<'a> {
1094 repo_root: &'a Path,
1095 opts: &'a InstallOptions,
1096 maps: LockMaps<'a>,
1097}
1098
1099fn handle_patch_conflict(
1100 entry: &Entry,
1101 entry_name: &str,
1102 ctx: &DeployCtx<'_>,
1103) -> Result<(), SkillfileError> {
1104 let key = lock_key(entry);
1105 let old_sha = ctx
1106 .maps
1107 .old_locked
1108 .get(&key)
1109 .map(|l| l.sha.clone())
1110 .unwrap_or_default();
1111 let new_sha = ctx
1112 .maps
1113 .locked
1114 .get(&key)
1115 .map_or_else(|| old_sha.clone(), |l| l.sha.clone());
1116
1117 write_conflict(
1118 ctx.repo_root,
1119 &ConflictState {
1120 entry: entry_name.to_string(),
1121 entity_type: entry.entity_type,
1122 old_sha: old_sha.clone(),
1123 new_sha: new_sha.clone(),
1124 },
1125 )?;
1126
1127 let sha_info = sha_transition_hint(&old_sha, &new_sha);
1128 Err(SkillfileError::Install(format!(
1129 "upstream changes to '{entry_name}' conflict with your customisations.{sha_info}\n\
1130 Your pinned edits could not be applied to the new upstream version.\n\
1131 Run `skillfile diff {entry_name}` to review what changed upstream.\n\
1132 Run `skillfile resolve {entry_name}` when ready to merge.\n\
1133 Run `skillfile resolve --abort` to discard the conflict and keep the old version."
1134 )))
1135}
1136
1137fn append_rollback_detail(error: SkillfileError, patch_message: &str) -> SkillfileError {
1138 if !patch_message.contains("rollback failed") {
1139 return error;
1140 }
1141 match error {
1142 SkillfileError::Install(message) => {
1143 SkillfileError::Install(format!("{message}\nRollback warning: {patch_message}"))
1144 }
1145 other => other,
1146 }
1147}
1148
1149fn install_entry_or_conflict(
1150 entry: &Entry,
1151 target: &InstallTarget,
1152 ctx: &DeployCtx<'_>,
1153) -> Result<(), SkillfileError> {
1154 let install_ctx = InstallCtx {
1155 repo_root: ctx.repo_root,
1156 opts: Some(ctx.opts),
1157 };
1158 match install_entry(entry, target, &install_ctx) {
1159 Ok(()) => Ok(()),
1160 Err(SkillfileError::PatchConflict {
1161 entry_name,
1162 message,
1163 }) => handle_patch_conflict(entry, &entry_name, ctx)
1164 .map_err(|error| append_rollback_detail(error, &message)),
1165 Err(e) => Err(e),
1166 }
1167}
1168
1169fn deploy_all(manifest: &Manifest, ctx: &DeployCtx<'_>) -> Result<(), SkillfileError> {
1170 let mode = if ctx.opts.dry_run { " [dry-run]" } else { "" };
1171 let all_adapters = adapters();
1172
1173 for target in &manifest.install_targets {
1174 if !all_adapters.contains(&target.adapter) {
1175 eprintln!("warning: unknown platform '{}', skipping", target.adapter);
1176 continue;
1177 }
1178 progress!(
1179 "Installing for {} ({}){mode}...",
1180 target.adapter,
1181 target.scope
1182 );
1183 for entry in &manifest.entries {
1184 install_entry_or_conflict(entry, target, ctx)?;
1185 }
1186 }
1187
1188 Ok(())
1189}
1190
1191fn apply_extra_targets(manifest: &mut Manifest, extra_targets: Option<&[InstallTarget]>) {
1196 let Some(targets) = extra_targets else {
1197 return;
1198 };
1199 if !targets.is_empty() {
1200 progress!("Using platform targets from personal config (Skillfile has no install lines).");
1201 }
1202 manifest.install_targets = targets.to_vec();
1203}
1204
1205fn load_manifest(
1206 repo_root: &Path,
1207 extra_targets: Option<&[InstallTarget]>,
1208) -> Result<Manifest, SkillfileError> {
1209 let manifest_path = repo_root.join(MANIFEST_NAME);
1210 if !manifest_path.exists() {
1211 return Err(SkillfileError::Manifest(format!(
1212 "{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
1213 repo_root.display()
1214 )));
1215 }
1216
1217 let result = parse_manifest(&manifest_path)?;
1218 for w in &result.warnings {
1219 eprintln!("{w}");
1220 }
1221 let mut manifest = result.manifest;
1222
1223 if manifest.install_targets.is_empty() {
1226 apply_extra_targets(&mut manifest, extra_targets);
1227 }
1228
1229 Ok(manifest)
1230}
1231
1232fn auto_pin_all(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
1233 for entry in &manifest.entries {
1234 auto_pin_entry(entry, manifest, repo_root)?;
1235 }
1236 Ok(())
1237}
1238
1239fn print_first_install_hint(manifest: &Manifest) {
1240 let platforms: Vec<String> = manifest
1241 .install_targets
1242 .iter()
1243 .map(|t| format!("{} ({})", t.adapter, t.scope))
1244 .collect();
1245 progress!(" Configured platforms: {}", platforms.join(", "));
1246 progress!(" Run `skillfile init` to add or change platforms.");
1247}
1248
1249pub struct CmdInstallOpts<'a> {
1250 pub dry_run: bool,
1251 pub update: bool,
1252 pub extra_targets: Option<&'a [InstallTarget]>,
1253}
1254
1255pub fn cmd_install(repo_root: &Path, opts: &CmdInstallOpts<'_>) -> Result<(), SkillfileError> {
1256 let manifest = load_manifest(repo_root, opts.extra_targets)?;
1257
1258 check_preconditions(&manifest, repo_root)?;
1259
1260 let cache_dir = repo_root.join(".skillfile").join("cache");
1262 let first_install = !cache_dir.exists();
1263
1264 let old_locked = read_lock(repo_root).unwrap_or_default();
1266
1267 if opts.update && !opts.dry_run {
1269 auto_pin_all(&manifest, repo_root)?;
1270 }
1271
1272 if !opts.dry_run {
1274 std::fs::create_dir_all(&cache_dir)?;
1275 }
1276
1277 cmd_sync(&skillfile_sources::sync::SyncCmdOpts {
1279 repo_root,
1280 dry_run: opts.dry_run,
1281 entry_filter: None,
1282 update: opts.update,
1283 })?;
1284
1285 let locked = read_lock(repo_root).unwrap_or_default();
1287
1288 let install_opts = InstallOptions {
1290 dry_run: opts.dry_run,
1291 overwrite: opts.update,
1292 };
1293 let deploy_ctx = DeployCtx {
1294 repo_root,
1295 opts: &install_opts,
1296 maps: LockMaps {
1297 locked: &locked,
1298 old_locked: &old_locked,
1299 },
1300 };
1301 deploy_all(&manifest, &deploy_ctx)?;
1302
1303 if !opts.dry_run {
1304 progress!("Done.");
1305
1306 if first_install {
1310 print_first_install_hint(&manifest);
1311 }
1312 }
1313
1314 Ok(())
1315}
1316
1317#[cfg(test)]
1322mod tests {
1323 use super::*;
1324 use skillfile_core::models::{
1325 EntityType, Entry, InstallTarget, LockEntry, Scope, SourceFields,
1326 };
1327 use std::collections::BTreeMap;
1328 use std::path::{Path, PathBuf};
1329
1330 fn patch_fixture_path(dir: &Path, entry: &Entry) -> PathBuf {
1337 dir.join(".skillfile/patches")
1338 .join(entry.entity_type.dir_name())
1339 .join(format!("{}.patch", entry.name))
1340 }
1341
1342 fn dir_patch_fixture_path(dir: &Path, entry: &Entry, rel: &str) -> PathBuf {
1345 dir.join(".skillfile/patches")
1346 .join(entry.entity_type.dir_name())
1347 .join(&entry.name)
1348 .join(format!("{rel}.patch"))
1349 }
1350
1351 fn write_patch_fixture(dir: &Path, entry: &Entry, text: &str) {
1353 let p = patch_fixture_path(dir, entry);
1354 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1355 std::fs::write(p, text).unwrap();
1356 }
1357
1358 fn write_lock_fixture(dir: &Path, locked: &BTreeMap<String, LockEntry>) {
1360 let json = serde_json::to_string_pretty(locked).unwrap();
1361 std::fs::write(dir.join("Skillfile.lock"), format!("{json}\n")).unwrap();
1362 }
1363
1364 fn write_conflict_fixture(dir: &Path, state: &ConflictState) {
1366 let p = dir.join(".skillfile/conflict");
1367 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1368 let json = serde_json::to_string_pretty(state).unwrap();
1369 std::fs::write(p, format!("{json}\n")).unwrap();
1370 }
1371
1372 fn has_dir_patch_fixture(dir: &Path, entry: &Entry) -> bool {
1374 let d = dir
1375 .join(".skillfile/patches")
1376 .join(entry.entity_type.dir_name())
1377 .join(&entry.name);
1378 if !d.is_dir() {
1379 return false;
1380 }
1381 std::fs::read_dir(&d).is_ok_and(|rd| {
1382 rd.filter_map(std::result::Result::ok)
1383 .any(|e| e.path().extension().is_some_and(|x| x == "patch"))
1384 })
1385 }
1386
1387 fn make_agent_entry(name: &str) -> Entry {
1392 Entry {
1393 entity_type: EntityType::Agent,
1394 name: name.into(),
1395 source: SourceFields::Github {
1396 owner_repo: "owner/repo".into(),
1397 path_in_repo: "agents/agent.md".into(),
1398 ref_: "main".into(),
1399 },
1400 }
1401 }
1402
1403 fn make_local_entry(name: &str, path: &str) -> Entry {
1404 Entry {
1405 entity_type: EntityType::Skill,
1406 name: name.into(),
1407 source: SourceFields::Local { path: path.into() },
1408 }
1409 }
1410
1411 fn make_target(adapter: &str, scope: Scope) -> InstallTarget {
1412 InstallTarget {
1413 adapter: adapter.into(),
1414 scope,
1415 }
1416 }
1417
1418 #[test]
1421 fn install_local_entry_copy() {
1422 let dir = tempfile::tempdir().unwrap();
1423 let source_file = dir.path().join("skills/my-skill.md");
1424 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1425 std::fs::write(&source_file, "# My Skill").unwrap();
1426
1427 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1428 let target = make_target("claude-code", Scope::Local);
1429 let outcome = install_entry_with_outcome(
1430 &entry,
1431 &target,
1432 &InstallCtx {
1433 repo_root: dir.path(),
1434 opts: None,
1435 },
1436 )
1437 .unwrap();
1438 assert_eq!(outcome, InstallOutcome::Installed);
1439
1440 let dest = dir.path().join(".claude/skills/my-skill/SKILL.md");
1441 assert!(dest.exists());
1442 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
1443 }
1444
1445 #[test]
1446 fn install_local_dir_entry_copy() {
1447 let dir = tempfile::tempdir().unwrap();
1448 let source_dir = dir.path().join("skills/python-testing");
1450 std::fs::create_dir_all(&source_dir).unwrap();
1451 std::fs::write(source_dir.join("SKILL.md"), "# Python Testing").unwrap();
1452 std::fs::write(source_dir.join("examples.md"), "# Examples").unwrap();
1453
1454 let entry = make_local_entry("python-testing", "skills/python-testing");
1455 let target = make_target("claude-code", Scope::Local);
1456 install_entry(
1457 &entry,
1458 &target,
1459 &InstallCtx {
1460 repo_root: dir.path(),
1461 opts: None,
1462 },
1463 )
1464 .unwrap();
1465
1466 let dest = dir.path().join(".claude/skills/python-testing");
1468 assert!(dest.is_dir(), "local dir entry must deploy as directory");
1469 assert_eq!(
1470 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
1471 "# Python Testing"
1472 );
1473 assert_eq!(
1474 std::fs::read_to_string(dest.join("examples.md")).unwrap(),
1475 "# Examples"
1476 );
1477 assert!(
1479 !dir.path().join(".claude/skills/python-testing.md").exists(),
1480 "should not create python-testing.md for a dir source"
1481 );
1482 }
1483
1484 #[test]
1485 fn install_entry_dry_run_no_write() {
1486 let dir = tempfile::tempdir().unwrap();
1487 let source_file = dir.path().join("skills/my-skill.md");
1488 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1489 std::fs::write(&source_file, "# My Skill").unwrap();
1490
1491 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1492 let target = make_target("claude-code", Scope::Local);
1493 let opts = InstallOptions {
1494 dry_run: true,
1495 ..Default::default()
1496 };
1497 let outcome = install_entry_with_outcome(
1498 &entry,
1499 &target,
1500 &InstallCtx {
1501 repo_root: dir.path(),
1502 opts: Some(&opts),
1503 },
1504 )
1505 .unwrap();
1506 assert_eq!(outcome, InstallOutcome::Skipped(InstallSkipReason::DryRun));
1507
1508 let dest = dir.path().join(".claude/skills/my-skill/SKILL.md");
1509 assert!(!dest.exists());
1510 }
1511
1512 #[test]
1513 fn install_entry_overwrites_existing() {
1514 let dir = tempfile::tempdir().unwrap();
1515 let source_file = dir.path().join("skills/my-skill.md");
1516 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1517 std::fs::write(&source_file, "# New content").unwrap();
1518
1519 let dest_dir = dir.path().join(".claude/skills/my-skill");
1520 std::fs::create_dir_all(&dest_dir).unwrap();
1521 let dest = dest_dir.join("SKILL.md");
1522 std::fs::write(&dest, "# Old content").unwrap();
1523
1524 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1525 let target = make_target("claude-code", Scope::Local);
1526 install_entry(
1527 &entry,
1528 &target,
1529 &InstallCtx {
1530 repo_root: dir.path(),
1531 opts: None,
1532 },
1533 )
1534 .unwrap();
1535
1536 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
1537 }
1538
1539 #[test]
1542 fn install_github_entry_copy() {
1543 let dir = tempfile::tempdir().unwrap();
1544 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
1545 std::fs::create_dir_all(&vdir).unwrap();
1546 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
1547
1548 let entry = make_agent_entry("my-agent");
1549 let target = make_target("claude-code", Scope::Local);
1550 install_entry(
1551 &entry,
1552 &target,
1553 &InstallCtx {
1554 repo_root: dir.path(),
1555 opts: None,
1556 },
1557 )
1558 .unwrap();
1559
1560 let dest = dir.path().join(".claude/agents/my-agent.md");
1561 assert!(dest.exists());
1562 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
1563 }
1564
1565 #[test]
1566 fn install_github_dir_entry_copy() {
1567 let dir = tempfile::tempdir().unwrap();
1568 let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
1569 std::fs::create_dir_all(&vdir).unwrap();
1570 std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
1571 std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
1572
1573 let entry = Entry {
1574 entity_type: EntityType::Skill,
1575 name: "python-pro".into(),
1576 source: SourceFields::Github {
1577 owner_repo: "owner/repo".into(),
1578 path_in_repo: "skills/python-pro".into(),
1579 ref_: "main".into(),
1580 },
1581 };
1582 let target = make_target("claude-code", Scope::Local);
1583 install_entry(
1584 &entry,
1585 &target,
1586 &InstallCtx {
1587 repo_root: dir.path(),
1588 opts: None,
1589 },
1590 )
1591 .unwrap();
1592
1593 let dest = dir.path().join(".claude/skills/python-pro");
1594 assert!(dest.is_dir());
1595 assert_eq!(
1596 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
1597 "# Python Pro"
1598 );
1599 }
1600
1601 #[test]
1602 fn install_agent_dir_entry_explodes_to_individual_files() {
1603 let dir = tempfile::tempdir().unwrap();
1604 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1605 std::fs::create_dir_all(&vdir).unwrap();
1606 std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
1607 std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
1608 std::fs::write(vdir.join(".meta"), "{}").unwrap();
1609
1610 let entry = Entry {
1611 entity_type: EntityType::Agent,
1612 name: "core-dev".into(),
1613 source: SourceFields::Github {
1614 owner_repo: "owner/repo".into(),
1615 path_in_repo: "categories/core-dev".into(),
1616 ref_: "main".into(),
1617 },
1618 };
1619 let target = make_target("claude-code", Scope::Local);
1620 install_entry(
1621 &entry,
1622 &target,
1623 &InstallCtx {
1624 repo_root: dir.path(),
1625 opts: None,
1626 },
1627 )
1628 .unwrap();
1629
1630 let agents_dir = dir.path().join(".claude/agents");
1631 assert_eq!(
1632 std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
1633 "# Backend"
1634 );
1635 assert_eq!(
1636 std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
1637 "# Frontend"
1638 );
1639 assert!(!agents_dir.join("core-dev").exists());
1641 }
1642
1643 #[test]
1644 fn install_entry_missing_source_warns() {
1645 let dir = tempfile::tempdir().unwrap();
1646 let entry = make_agent_entry("my-agent");
1647 let target = make_target("claude-code", Scope::Local);
1648
1649 let outcome = install_entry_with_outcome(
1650 &entry,
1651 &target,
1652 &InstallCtx {
1653 repo_root: dir.path(),
1654 opts: None,
1655 },
1656 )
1657 .unwrap();
1658 assert_eq!(
1659 outcome,
1660 InstallOutcome::Skipped(InstallSkipReason::MissingSource)
1661 );
1662 }
1663
1664 #[test]
1665 fn install_entry_unknown_adapter_is_skipped() {
1666 let dir = tempfile::tempdir().unwrap();
1667 let source_file = dir.path().join("skills/my-skill.md");
1668 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1669 std::fs::write(&source_file, "# My Skill").unwrap();
1670
1671 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1672 let target = make_target("unknown-adapter", Scope::Local);
1673 let outcome = install_entry_with_outcome(
1674 &entry,
1675 &target,
1676 &InstallCtx {
1677 repo_root: dir.path(),
1678 opts: None,
1679 },
1680 )
1681 .unwrap();
1682 assert_eq!(
1683 outcome,
1684 InstallOutcome::Skipped(InstallSkipReason::UnknownAdapter)
1685 );
1686 }
1687
1688 #[test]
1689 fn install_entry_errors_when_target_path_is_blocked() {
1690 let dir = tempfile::tempdir().unwrap();
1691 let source_file = dir.path().join("skills/my-skill.md");
1692 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1693 std::fs::write(&source_file, "# My Skill").unwrap();
1694 std::fs::write(dir.path().join(".claude"), "not a directory").unwrap();
1695
1696 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1697 let target = make_target("claude-code", Scope::Local);
1698 let result = install_entry(
1699 &entry,
1700 &target,
1701 &InstallCtx {
1702 repo_root: dir.path(),
1703 opts: None,
1704 },
1705 );
1706 assert!(matches!(
1707 result,
1708 Err(SkillfileError::Install(message))
1709 if message.contains("failed to install 'my-skill' to claude-code (local)")
1710 ));
1711 }
1712
1713 #[test]
1714 fn install_snapshot_restores_previous_directory_contents() {
1715 let dir = tempfile::tempdir().unwrap();
1716 let source_dir = dir.path().join("skills/foo");
1717 let dest_dir = dir.path().join(".claude/skills/foo");
1718 std::fs::create_dir_all(&source_dir).unwrap();
1719 std::fs::create_dir_all(&dest_dir).unwrap();
1720 std::fs::write(source_dir.join("SKILL.md"), "# Source\n").unwrap();
1721 std::fs::write(dest_dir.join("SKILL.md"), "# Old\n").unwrap();
1722
1723 let entry = make_local_entry("foo", "skills/foo");
1724 let target = make_target("claude-code", Scope::Local);
1725 let snapshot =
1726 capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
1727
1728 std::fs::remove_dir_all(&dest_dir).unwrap();
1729 std::fs::create_dir_all(&dest_dir).unwrap();
1730 std::fs::write(dest_dir.join("SKILL.md"), "# New\n").unwrap();
1731
1732 snapshot.restore().unwrap();
1733 assert_eq!(
1734 std::fs::read_to_string(dest_dir.join("SKILL.md")).unwrap(),
1735 "# Old\n"
1736 );
1737 }
1738
1739 #[test]
1740 fn install_snapshot_restores_legacy_flat_file_side_effect() {
1741 let dir = tempfile::tempdir().unwrap();
1742 let source_file = dir.path().join("skills/foo.md");
1743 let legacy_file = dir.path().join(".claude/skills/foo.md");
1744 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1745 std::fs::create_dir_all(legacy_file.parent().unwrap()).unwrap();
1746 std::fs::write(&source_file, "# Source\n").unwrap();
1747 std::fs::write(&legacy_file, "# Legacy\n").unwrap();
1748
1749 let entry = make_local_entry("foo", "skills/foo.md");
1750 let target = make_target("claude-code", Scope::Local);
1751 let snapshot =
1752 capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
1753
1754 std::fs::remove_file(&legacy_file).unwrap();
1755 snapshot.restore().unwrap();
1756 assert_eq!(std::fs::read_to_string(&legacy_file).unwrap(), "# Legacy\n");
1757 }
1758
1759 #[test]
1760 fn install_snapshot_restores_patch_file() {
1761 let dir = tempfile::tempdir().unwrap();
1762 let source_file = dir.path().join("skills/foo.md");
1763 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1764 std::fs::write(&source_file, "# Source\n").unwrap();
1765
1766 let entry = make_local_entry("foo", "skills/foo.md");
1767 let patch = patch_fixture_path(dir.path(), &entry);
1768 std::fs::create_dir_all(patch.parent().unwrap()).unwrap();
1769 std::fs::write(&patch, "old patch").unwrap();
1770
1771 let target = make_target("claude-code", Scope::Local);
1772 let snapshot =
1773 capture_install_snapshot(&entry, std::slice::from_ref(&target), dir.path()).unwrap();
1774 std::fs::write(&patch, "new patch").unwrap();
1775
1776 snapshot.restore().unwrap();
1777 assert_eq!(std::fs::read_to_string(&patch).unwrap(), "old patch");
1778 }
1779
1780 #[cfg(unix)]
1781 #[test]
1782 fn install_entry_restores_existing_directory_when_copy_fails() {
1783 use std::os::unix::fs::symlink;
1784
1785 let dir = tempfile::tempdir().unwrap();
1786 let source_dir = dir.path().join("skills/foo");
1787 let dest_dir = dir.path().join(".claude/skills/foo");
1788 std::fs::create_dir_all(&source_dir).unwrap();
1789 std::fs::create_dir_all(&dest_dir).unwrap();
1790 std::fs::write(source_dir.join("SKILL.md"), "# New\n").unwrap();
1791 symlink("missing-target.md", source_dir.join("dangling.md")).unwrap();
1792 std::fs::write(dest_dir.join("SKILL.md"), "# Old\n").unwrap();
1793 std::fs::write(dest_dir.join("keep.md"), "# Keep\n").unwrap();
1794
1795 let entry = make_local_entry("foo", "skills/foo");
1796 let target = make_target("claude-code", Scope::Local);
1797 let result = install_entry(
1798 &entry,
1799 &target,
1800 &InstallCtx {
1801 repo_root: dir.path(),
1802 opts: None,
1803 },
1804 );
1805
1806 assert!(matches!(
1807 result,
1808 Err(SkillfileError::Install(message))
1809 if message.contains("failed to install 'foo' to claude-code (local)")
1810 ));
1811 assert_eq!(
1812 std::fs::read_to_string(dest_dir.join("SKILL.md")).unwrap(),
1813 "# Old\n"
1814 );
1815 assert_eq!(
1816 std::fs::read_to_string(dest_dir.join("keep.md")).unwrap(),
1817 "# Keep\n"
1818 );
1819 }
1820
1821 #[cfg(unix)]
1822 #[test]
1823 fn install_snapshot_restores_symlink_as_symlink() {
1824 use std::os::unix::fs::symlink;
1825
1826 let dir = tempfile::tempdir().unwrap();
1827 let link = dir.path().join(".claude/skills/foo.md");
1828 std::fs::create_dir_all(link.parent().unwrap()).unwrap();
1829 symlink("target.md", &link).unwrap();
1830 let snapshot = InstallSnapshot::capture(dir.path(), vec![link.clone()]).unwrap();
1831
1832 std::fs::remove_file(&link).unwrap();
1833 std::fs::write(&link, "# regular file\n").unwrap();
1834
1835 snapshot.restore().unwrap();
1836 assert!(std::fs::symlink_metadata(&link)
1837 .unwrap()
1838 .file_type()
1839 .is_symlink());
1840 assert_eq!(
1841 std::fs::read_link(&link).unwrap(),
1842 PathBuf::from("target.md")
1843 );
1844 }
1845
1846 #[cfg(windows)]
1847 #[test]
1848 fn install_snapshot_restores_dangling_directory_symlink_kind() {
1849 use std::os::windows::fs::{symlink_dir, FileTypeExt as _};
1850
1851 let dir = tempfile::tempdir().unwrap();
1852 let link = dir.path().join(".claude/skills/foo");
1853 std::fs::create_dir_all(link.parent().unwrap()).unwrap();
1854 if symlink_dir("missing-dir", &link).is_err() {
1855 return;
1856 }
1857 let snapshot = InstallSnapshot::capture(dir.path(), vec![link.clone()]).unwrap();
1858
1859 remove_path(&link).unwrap();
1860 std::fs::write(&link, "# regular file\n").unwrap();
1861
1862 snapshot.restore().unwrap();
1863 let file_type = std::fs::symlink_metadata(&link).unwrap().file_type();
1864 assert!(file_type.is_symlink_dir());
1865 assert_eq!(
1866 std::fs::read_link(&link).unwrap(),
1867 PathBuf::from("missing-dir")
1868 );
1869 }
1870
1871 #[test]
1872 fn install_snapshot_keeps_scratch_dir_when_restore_fails() {
1873 let dir = tempfile::tempdir().unwrap();
1874 let live_path = dir.path().join("target/foo.md");
1875 std::fs::create_dir_all(live_path.parent().unwrap()).unwrap();
1876 std::fs::write(&live_path, "# old\n").unwrap();
1877 let snapshot = InstallSnapshot::capture(dir.path(), vec![live_path.clone()]).unwrap();
1878 let scratch_dir = snapshot.scratch_dir().unwrap().to_path_buf();
1879
1880 std::fs::remove_dir_all(live_path.parent().unwrap()).unwrap();
1881 std::fs::write(live_path.parent().unwrap(), "parent is a file").unwrap();
1882
1883 let result = snapshot.restore();
1884 assert!(result.is_err());
1885 drop(snapshot);
1886 assert!(scratch_dir.exists());
1887
1888 std::fs::remove_dir_all(&scratch_dir).unwrap();
1889 remove_empty_dir(scratch_dir.parent());
1890 }
1891
1892 #[test]
1893 fn patch_conflict_type_survives_rollback_failure() {
1894 let dir = tempfile::tempdir().unwrap();
1895 let live_path = dir.path().join("target/foo.md");
1896 std::fs::create_dir_all(live_path.parent().unwrap()).unwrap();
1897 std::fs::write(&live_path, "# old\n").unwrap();
1898 let snapshot = InstallSnapshot::capture(dir.path(), vec![live_path.clone()]).unwrap();
1899 let scratch_dir = snapshot.scratch_dir().unwrap().to_path_buf();
1900
1901 std::fs::remove_dir_all(live_path.parent().unwrap()).unwrap();
1902 std::fs::write(live_path.parent().unwrap(), "parent is a file").unwrap();
1903
1904 let error = restore_on_install_error(
1905 &snapshot,
1906 SkillfileError::PatchConflict {
1907 message: "patch failed".to_string(),
1908 entry_name: "foo".to_string(),
1909 },
1910 );
1911 assert!(matches!(
1912 error,
1913 SkillfileError::PatchConflict { ref message, ref entry_name }
1914 if entry_name == "foo"
1915 && message.contains("patch failed")
1916 && message.contains("rollback failed")
1917 ));
1918 drop(snapshot);
1919
1920 std::fs::remove_dir_all(&scratch_dir).unwrap();
1921 remove_empty_dir(scratch_dir.parent());
1922 }
1923
1924 #[test]
1927 fn install_applies_existing_patch() {
1928 let dir = tempfile::tempdir().unwrap();
1929
1930 let vdir = dir.path().join(".skillfile/cache/skills/test");
1932 std::fs::create_dir_all(&vdir).unwrap();
1933 std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
1934
1935 let entry = Entry {
1937 entity_type: EntityType::Skill,
1938 name: "test".into(),
1939 source: SourceFields::Github {
1940 owner_repo: "owner/repo".into(),
1941 path_in_repo: "skills/test.md".into(),
1942 ref_: "main".into(),
1943 },
1944 };
1945 let patch_text =
1947 "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Test\n \n-Original.\n+Modified.\n";
1948 write_patch_fixture(dir.path(), &entry, patch_text);
1949
1950 let target = make_target("claude-code", Scope::Local);
1951 install_entry(
1952 &entry,
1953 &target,
1954 &InstallCtx {
1955 repo_root: dir.path(),
1956 opts: None,
1957 },
1958 )
1959 .unwrap();
1960
1961 let dest = dir.path().join(".claude/skills/test/SKILL.md");
1962 assert_eq!(
1963 std::fs::read_to_string(&dest).unwrap(),
1964 "# Test\n\nModified.\n"
1965 );
1966 }
1967
1968 #[test]
1969 fn install_patch_conflict_returns_error() {
1970 let dir = tempfile::tempdir().unwrap();
1971
1972 let vdir = dir.path().join(".skillfile/cache/skills/test");
1973 std::fs::create_dir_all(&vdir).unwrap();
1974 std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
1976
1977 let entry = Entry {
1978 entity_type: EntityType::Skill,
1979 name: "test".into(),
1980 source: SourceFields::Github {
1981 owner_repo: "owner/repo".into(),
1982 path_in_repo: "skills/test.md".into(),
1983 ref_: "main".into(),
1984 },
1985 };
1986 let bad_patch =
1988 "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
1989 write_patch_fixture(dir.path(), &entry, bad_patch);
1990
1991 let installed_dir = dir.path().join(".claude/skills/test");
1993 std::fs::create_dir_all(&installed_dir).unwrap();
1994 std::fs::write(
1995 installed_dir.join("SKILL.md"),
1996 "totally different\ncontent\n",
1997 )
1998 .unwrap();
1999
2000 let target = make_target("claude-code", Scope::Local);
2001 let result = install_entry(
2002 &entry,
2003 &target,
2004 &InstallCtx {
2005 repo_root: dir.path(),
2006 opts: None,
2007 },
2008 );
2009 assert!(result.is_err());
2010 matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
2012 }
2013
2014 #[test]
2015 fn install_patch_conflict_restores_previous_installed_file() {
2016 let dir = tempfile::tempdir().unwrap();
2017
2018 let vdir = dir.path().join(".skillfile/cache/skills/test");
2019 std::fs::create_dir_all(&vdir).unwrap();
2020 std::fs::write(vdir.join("test.md"), "# New upstream\n").unwrap();
2021
2022 let entry = Entry {
2023 entity_type: EntityType::Skill,
2024 name: "test".into(),
2025 source: SourceFields::Github {
2026 owner_repo: "owner/repo".into(),
2027 path_in_repo: "skills/test.md".into(),
2028 ref_: "main".into(),
2029 },
2030 };
2031 let bad_patch =
2032 "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
2033 write_patch_fixture(dir.path(), &entry, bad_patch);
2034
2035 let dest = dir.path().join(".claude/skills/test/SKILL.md");
2036 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
2037 std::fs::write(&dest, "# Old installed\n").unwrap();
2038
2039 let target = make_target("claude-code", Scope::Local);
2040 let result = install_entry(
2041 &entry,
2042 &target,
2043 &InstallCtx {
2044 repo_root: dir.path(),
2045 opts: None,
2046 },
2047 );
2048
2049 assert!(matches!(result, Err(SkillfileError::PatchConflict { .. })));
2050 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old installed\n");
2051 }
2052
2053 #[test]
2056 fn install_local_skill_gemini_cli() {
2057 let dir = tempfile::tempdir().unwrap();
2058 let source_file = dir.path().join("skills/my-skill.md");
2059 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2060 std::fs::write(&source_file, "# My Skill").unwrap();
2061
2062 let entry = make_local_entry("my-skill", "skills/my-skill.md");
2063 let target = make_target("gemini-cli", Scope::Local);
2064 install_entry(
2065 &entry,
2066 &target,
2067 &InstallCtx {
2068 repo_root: dir.path(),
2069 opts: None,
2070 },
2071 )
2072 .unwrap();
2073
2074 let dest = dir.path().join(".gemini/skills/my-skill/SKILL.md");
2075 assert!(dest.exists());
2076 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
2077 }
2078
2079 #[test]
2080 fn install_local_skill_codex() {
2081 let dir = tempfile::tempdir().unwrap();
2082 let source_file = dir.path().join("skills/my-skill.md");
2083 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2084 std::fs::write(&source_file, "# My Skill").unwrap();
2085
2086 let entry = make_local_entry("my-skill", "skills/my-skill.md");
2087 let target = make_target("codex", Scope::Local);
2088 install_entry(
2089 &entry,
2090 &target,
2091 &InstallCtx {
2092 repo_root: dir.path(),
2093 opts: None,
2094 },
2095 )
2096 .unwrap();
2097
2098 let dest = dir.path().join(".codex/skills/my-skill/SKILL.md");
2099 assert!(dest.exists());
2100 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
2101 }
2102
2103 #[test]
2104 fn codex_skips_agent_entries() {
2105 let dir = tempfile::tempdir().unwrap();
2106 let entry = make_agent_entry("my-agent");
2107 let target = make_target("codex", Scope::Local);
2108 let outcome = install_entry_with_outcome(
2109 &entry,
2110 &target,
2111 &InstallCtx {
2112 repo_root: dir.path(),
2113 opts: None,
2114 },
2115 )
2116 .unwrap();
2117 assert_eq!(
2118 outcome,
2119 InstallOutcome::Skipped(InstallSkipReason::UnsupportedEntity)
2120 );
2121
2122 assert!(!dir.path().join(".codex").exists());
2123 }
2124
2125 #[test]
2126 fn install_github_agent_gemini_cli() {
2127 let dir = tempfile::tempdir().unwrap();
2128 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
2129 std::fs::create_dir_all(&vdir).unwrap();
2130 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
2131
2132 let entry = make_agent_entry("my-agent");
2133 let target = make_target("gemini-cli", Scope::Local);
2134 install_entry(
2135 &entry,
2136 &target,
2137 &InstallCtx {
2138 repo_root: dir.path(),
2139 opts: Some(&InstallOptions::default()),
2140 },
2141 )
2142 .unwrap();
2143
2144 let dest = dir.path().join(".gemini/agents/my-agent.md");
2145 assert!(dest.exists());
2146 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
2147 }
2148
2149 #[test]
2150 fn install_skill_multi_adapter() {
2151 for adapter in &["claude-code", "gemini-cli", "codex"] {
2152 let dir = tempfile::tempdir().unwrap();
2153 let source_file = dir.path().join("skills/my-skill.md");
2154 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2155 std::fs::write(&source_file, "# Multi Skill").unwrap();
2156
2157 let entry = make_local_entry("my-skill", "skills/my-skill.md");
2158 let target = make_target(adapter, Scope::Local);
2159 install_entry(
2160 &entry,
2161 &target,
2162 &InstallCtx {
2163 repo_root: dir.path(),
2164 opts: None,
2165 },
2166 )
2167 .unwrap();
2168
2169 let prefix = match *adapter {
2170 "claude-code" => ".claude",
2171 "gemini-cli" => ".gemini",
2172 "codex" => ".codex",
2173 _ => unreachable!(),
2174 };
2175 let dest = dir
2176 .path()
2177 .join(format!("{prefix}/skills/my-skill/SKILL.md"));
2178 assert!(dest.exists(), "Failed for adapter {adapter}");
2179 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
2180 }
2181 }
2182
2183 #[test]
2186 fn cmd_install_no_manifest() {
2187 let dir = tempfile::tempdir().unwrap();
2188 let result = cmd_install(
2189 dir.path(),
2190 &CmdInstallOpts {
2191 dry_run: false,
2192 update: false,
2193 extra_targets: None,
2194 },
2195 );
2196 assert!(result.is_err());
2197 assert!(result.unwrap_err().to_string().contains("not found"));
2198 }
2199
2200 #[test]
2201 fn cmd_install_no_install_targets() {
2202 let dir = tempfile::tempdir().unwrap();
2203 std::fs::write(
2204 dir.path().join("Skillfile"),
2205 "local skill foo skills/foo.md\n",
2206 )
2207 .unwrap();
2208
2209 let result = cmd_install(
2210 dir.path(),
2211 &CmdInstallOpts {
2212 dry_run: false,
2213 update: false,
2214 extra_targets: None,
2215 },
2216 );
2217 assert!(result.is_err());
2218 assert!(result
2219 .unwrap_err()
2220 .to_string()
2221 .contains("No install targets"));
2222 }
2223
2224 #[test]
2225 fn cmd_install_extra_targets_fallback() {
2226 let dir = tempfile::tempdir().unwrap();
2227 std::fs::write(
2229 dir.path().join("Skillfile"),
2230 "local skill foo skills/foo.md\n",
2231 )
2232 .unwrap();
2233 let source_file = dir.path().join("skills/foo.md");
2234 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2235 std::fs::write(&source_file, "# Foo").unwrap();
2236
2237 let targets = vec![make_target("claude-code", Scope::Local)];
2239 cmd_install(
2240 dir.path(),
2241 &CmdInstallOpts {
2242 dry_run: false,
2243 update: false,
2244 extra_targets: Some(&targets),
2245 },
2246 )
2247 .unwrap();
2248
2249 let dest = dir.path().join(".claude/skills/foo/SKILL.md");
2250 assert!(
2251 dest.exists(),
2252 "extra_targets must be used when Skillfile has none"
2253 );
2254 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
2255 }
2256
2257 #[test]
2258 fn cmd_install_skillfile_targets_win_over_extra() {
2259 let dir = tempfile::tempdir().unwrap();
2260 std::fs::write(
2262 dir.path().join("Skillfile"),
2263 "install claude-code local\nlocal skill foo skills/foo.md\n",
2264 )
2265 .unwrap();
2266 let source_file = dir.path().join("skills/foo.md");
2267 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2268 std::fs::write(&source_file, "# Foo").unwrap();
2269
2270 let targets = vec![make_target("gemini-cli", Scope::Local)];
2272 cmd_install(
2273 dir.path(),
2274 &CmdInstallOpts {
2275 dry_run: false,
2276 update: false,
2277 extra_targets: Some(&targets),
2278 },
2279 )
2280 .unwrap();
2281
2282 assert!(dir.path().join(".claude/skills/foo/SKILL.md").exists());
2284 assert!(!dir.path().join(".gemini").exists());
2286 }
2287
2288 #[test]
2289 fn cmd_install_dry_run_no_files() {
2290 let dir = tempfile::tempdir().unwrap();
2291 std::fs::write(
2292 dir.path().join("Skillfile"),
2293 "install claude-code local\nlocal skill foo skills/foo.md\n",
2294 )
2295 .unwrap();
2296 let source_file = dir.path().join("skills/foo.md");
2297 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
2298 std::fs::write(&source_file, "# Foo").unwrap();
2299
2300 cmd_install(
2301 dir.path(),
2302 &CmdInstallOpts {
2303 dry_run: true,
2304 update: false,
2305 extra_targets: None,
2306 },
2307 )
2308 .unwrap();
2309
2310 assert!(!dir.path().join(".claude").exists());
2311 }
2312
2313 #[test]
2314 fn cmd_install_deploys_to_multiple_adapters() {
2315 let dir = tempfile::tempdir().unwrap();
2316 std::fs::write(
2317 dir.path().join("Skillfile"),
2318 "install claude-code local\n\
2319 install gemini-cli local\n\
2320 install codex local\n\
2321 local skill foo skills/foo.md\n\
2322 local agent bar agents/bar.md\n",
2323 )
2324 .unwrap();
2325 std::fs::create_dir_all(dir.path().join("skills")).unwrap();
2326 std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
2327 std::fs::create_dir_all(dir.path().join("agents")).unwrap();
2328 std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
2329
2330 cmd_install(
2331 dir.path(),
2332 &CmdInstallOpts {
2333 dry_run: false,
2334 update: false,
2335 extra_targets: None,
2336 },
2337 )
2338 .unwrap();
2339
2340 assert!(dir.path().join(".claude/skills/foo/SKILL.md").exists());
2342 assert!(dir.path().join(".gemini/skills/foo/SKILL.md").exists());
2343 assert!(dir.path().join(".codex/skills/foo/SKILL.md").exists());
2344
2345 assert!(dir.path().join(".claude/agents/bar.md").exists());
2347 assert!(dir.path().join(".gemini/agents/bar.md").exists());
2348 assert!(!dir.path().join(".codex/agents").exists());
2349 }
2350
2351 #[test]
2352 fn cmd_install_pending_conflict_blocks() {
2353 let dir = tempfile::tempdir().unwrap();
2354 std::fs::write(
2355 dir.path().join("Skillfile"),
2356 "install claude-code local\nlocal skill foo skills/foo.md\n",
2357 )
2358 .unwrap();
2359
2360 write_conflict_fixture(
2361 dir.path(),
2362 &ConflictState {
2363 entry: "foo".into(),
2364 entity_type: EntityType::Skill,
2365 old_sha: "aaa".into(),
2366 new_sha: "bbb".into(),
2367 },
2368 );
2369
2370 let result = cmd_install(
2371 dir.path(),
2372 &CmdInstallOpts {
2373 dry_run: false,
2374 update: false,
2375 extra_targets: None,
2376 },
2377 );
2378 assert!(result.is_err());
2379 assert!(result.unwrap_err().to_string().contains("pending conflict"));
2380 }
2381
2382 fn make_skill_entry(name: &str) -> Entry {
2388 Entry {
2389 entity_type: EntityType::Skill,
2390 name: name.into(),
2391 source: SourceFields::Github {
2392 owner_repo: "owner/repo".into(),
2393 path_in_repo: format!("skills/{name}.md"),
2394 ref_: "main".into(),
2395 },
2396 }
2397 }
2398
2399 fn make_dir_skill_entry(name: &str) -> Entry {
2401 Entry {
2402 entity_type: EntityType::Skill,
2403 name: name.into(),
2404 source: SourceFields::Github {
2405 owner_repo: "owner/repo".into(),
2406 path_in_repo: format!("skills/{name}"),
2407 ref_: "main".into(),
2408 },
2409 }
2410 }
2411
2412 fn setup_github_skill_repo(dir: &Path, name: &str, cache_content: &str) {
2414 std::fs::write(
2416 dir.join("Skillfile"),
2417 format!(
2418 "install claude-code local\ngithub skill {name} owner/repo skills/{name}.md\n"
2419 ),
2420 )
2421 .unwrap();
2422
2423 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2425 locked.insert(
2426 format!("github/skill/{name}"),
2427 LockEntry {
2428 sha: "abc123def456abc123def456abc123def456abc123".into(),
2429 raw_url: format!(
2430 "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
2431 ),
2432 },
2433 );
2434 write_lock_fixture(dir, &locked);
2435
2436 let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
2438 std::fs::create_dir_all(&vdir).unwrap();
2439 std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
2440 }
2441
2442 #[test]
2447 fn auto_pin_entry_local_is_skipped() {
2448 let dir = tempfile::tempdir().unwrap();
2449
2450 let entry = make_local_entry("my-skill", "skills/my-skill.md");
2452 let manifest = Manifest {
2453 entries: vec![entry.clone()],
2454 install_targets: vec![make_target("claude-code", Scope::Local)],
2455 };
2456
2457 let skills_dir = dir.path().join("skills");
2459 std::fs::create_dir_all(&skills_dir).unwrap();
2460 std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
2461
2462 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2463
2464 assert!(
2466 !patch_fixture_path(dir.path(), &entry).exists(),
2467 "local entry must never be pinned"
2468 );
2469 }
2470
2471 #[test]
2472 fn auto_pin_entry_missing_lock_is_skipped() {
2473 let dir = tempfile::tempdir().unwrap();
2474
2475 let entry = make_skill_entry("test");
2476 let manifest = Manifest {
2477 entries: vec![entry.clone()],
2478 install_targets: vec![make_target("claude-code", Scope::Local)],
2479 };
2480
2481 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2483
2484 assert!(!patch_fixture_path(dir.path(), &entry).exists());
2485 }
2486
2487 #[test]
2488 fn auto_pin_entry_missing_lock_key_is_skipped() {
2489 let dir = tempfile::tempdir().unwrap();
2490
2491 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2493 locked.insert(
2494 "github/skill/other".into(),
2495 LockEntry {
2496 sha: "aabbcc".into(),
2497 raw_url: "https://example.com/other.md".into(),
2498 },
2499 );
2500 write_lock_fixture(dir.path(), &locked);
2501
2502 let entry = make_skill_entry("test");
2503 let manifest = Manifest {
2504 entries: vec![entry.clone()],
2505 install_targets: vec![make_target("claude-code", Scope::Local)],
2506 };
2507
2508 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2509
2510 assert!(!patch_fixture_path(dir.path(), &entry).exists());
2511 }
2512
2513 #[test]
2514 fn auto_pin_entry_writes_patch_when_installed_differs() {
2515 let dir = tempfile::tempdir().unwrap();
2516 let name = "my-skill";
2517
2518 let cache_content = "# My Skill\n\nOriginal content.\n";
2519 let installed_content = "# My Skill\n\nUser-modified content.\n";
2520
2521 setup_github_skill_repo(dir.path(), name, cache_content);
2522
2523 let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2525 std::fs::create_dir_all(&installed_dir).unwrap();
2526 std::fs::write(installed_dir.join("SKILL.md"), installed_content).unwrap();
2527
2528 let entry = make_skill_entry(name);
2529 let manifest = Manifest {
2530 entries: vec![entry.clone()],
2531 install_targets: vec![make_target("claude-code", Scope::Local)],
2532 };
2533
2534 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2535
2536 assert!(
2537 patch_fixture_path(dir.path(), &entry).exists(),
2538 "patch should be written when installed differs from cache"
2539 );
2540
2541 std::fs::write(installed_dir.join("SKILL.md"), cache_content).unwrap();
2544 let target = make_target("claude-code", Scope::Local);
2545 install_entry(
2546 &entry,
2547 &target,
2548 &InstallCtx {
2549 repo_root: dir.path(),
2550 opts: None,
2551 },
2552 )
2553 .unwrap();
2554 assert_eq!(
2555 std::fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
2556 installed_content,
2557 );
2558 }
2559
2560 #[test]
2561 fn auto_pin_entry_uses_second_target_when_first_is_clean() {
2562 let dir = tempfile::tempdir().unwrap();
2563 let name = "my-skill";
2564
2565 let cache_content = "# My Skill\n\nOriginal content.\n";
2566 let installed_content = "# My Skill\n\nUser-modified content.\n";
2567
2568 setup_github_skill_repo(dir.path(), name, cache_content);
2569
2570 let first_installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2571 std::fs::create_dir_all(&first_installed_dir).unwrap();
2572 std::fs::write(first_installed_dir.join("SKILL.md"), cache_content).unwrap();
2573
2574 let second_installed_dir = dir.path().join(format!(".cursor/skills/{name}"));
2575 std::fs::create_dir_all(&second_installed_dir).unwrap();
2576 std::fs::write(second_installed_dir.join("SKILL.md"), installed_content).unwrap();
2577
2578 let entry = make_skill_entry(name);
2579 let manifest = Manifest {
2580 entries: vec![entry.clone()],
2581 install_targets: vec![
2582 make_target("claude-code", Scope::Local),
2583 make_target("cursor", Scope::Local),
2584 ],
2585 };
2586
2587 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2588
2589 std::fs::write(first_installed_dir.join("SKILL.md"), cache_content).unwrap();
2590 install_entry(
2591 &entry,
2592 &make_target("claude-code", Scope::Local),
2593 &InstallCtx {
2594 repo_root: dir.path(),
2595 opts: None,
2596 },
2597 )
2598 .unwrap();
2599 assert_eq!(
2600 std::fs::read_to_string(first_installed_dir.join("SKILL.md")).unwrap(),
2601 installed_content,
2602 "auto-pin must preserve edits from a modified secondary target"
2603 );
2604 }
2605
2606 #[test]
2607 fn auto_pin_entry_errors_on_divergent_multi_target_edits() {
2608 let dir = tempfile::tempdir().unwrap();
2609 let name = "my-skill";
2610
2611 let cache_content = "# My Skill\n\nOriginal content.\n";
2612 setup_github_skill_repo(dir.path(), name, cache_content);
2613
2614 let first_installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2615 std::fs::create_dir_all(&first_installed_dir).unwrap();
2616 std::fs::write(
2617 first_installed_dir.join("SKILL.md"),
2618 "# My Skill\n\nClaude edit.\n",
2619 )
2620 .unwrap();
2621
2622 let second_installed_dir = dir.path().join(format!(".cursor/skills/{name}"));
2623 std::fs::create_dir_all(&second_installed_dir).unwrap();
2624 std::fs::write(
2625 second_installed_dir.join("SKILL.md"),
2626 "# My Skill\n\nCursor edit.\n",
2627 )
2628 .unwrap();
2629
2630 let entry = make_skill_entry(name);
2631 let manifest = Manifest {
2632 entries: vec![entry.clone()],
2633 install_targets: vec![
2634 make_target("claude-code", Scope::Local),
2635 make_target("cursor", Scope::Local),
2636 ],
2637 };
2638
2639 let error = auto_pin_entry(&entry, &manifest, dir.path()).unwrap_err();
2640 assert!(
2641 error
2642 .to_string()
2643 .contains("divergent edits across install targets"),
2644 "unexpected error: {error}"
2645 );
2646 }
2647
2648 #[test]
2649 fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
2650 let dir = tempfile::tempdir().unwrap();
2651 let name = "my-skill";
2652
2653 let cache_content = "# My Skill\n\nOriginal.\n";
2654 let installed_content = "# My Skill\n\nModified.\n";
2655
2656 setup_github_skill_repo(dir.path(), name, cache_content);
2657
2658 let entry = make_skill_entry(name);
2659 let manifest = Manifest {
2660 entries: vec![entry.clone()],
2661 install_targets: vec![make_target("claude-code", Scope::Local)],
2662 };
2663
2664 let patch_text = "--- a/my-skill.md\n+++ b/my-skill.md\n@@ -1,3 +1,3 @@\n # My Skill\n \n-Original.\n+Modified.\n";
2667 write_patch_fixture(dir.path(), &entry, patch_text);
2668
2669 let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2671 std::fs::create_dir_all(&installed_dir).unwrap();
2672 std::fs::write(installed_dir.join("SKILL.md"), installed_content).unwrap();
2673
2674 let patch_path = patch_fixture_path(dir.path(), &entry);
2676 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
2677
2678 std::thread::sleep(std::time::Duration::from_millis(20));
2680
2681 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2682
2683 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
2684
2685 assert_eq!(
2686 mtime_before, mtime_after,
2687 "patch must not be rewritten when already up to date"
2688 );
2689 }
2690
2691 #[test]
2692 fn auto_pin_entry_repins_when_installed_has_additional_edits() {
2693 let dir = tempfile::tempdir().unwrap();
2694 let name = "my-skill";
2695
2696 let cache_content = "# My Skill\n\nOriginal.\n";
2697 let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
2698
2699 setup_github_skill_repo(dir.path(), name, cache_content);
2700
2701 let entry = make_skill_entry(name);
2702 let manifest = Manifest {
2703 entries: vec![entry.clone()],
2704 install_targets: vec![make_target("claude-code", Scope::Local)],
2705 };
2706
2707 let old_patch = "--- a/my-skill.md\n+++ b/my-skill.md\n@@ -1,3 +1,3 @@\n # My Skill\n \n-Original.\n+First edit.\n";
2709 write_patch_fixture(dir.path(), &entry, old_patch);
2710
2711 let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
2713 std::fs::create_dir_all(&installed_dir).unwrap();
2714 std::fs::write(installed_dir.join("SKILL.md"), new_installed).unwrap();
2715
2716 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2717
2718 std::fs::write(installed_dir.join("SKILL.md"), cache_content).unwrap();
2721 let target = make_target("claude-code", Scope::Local);
2722 install_entry(
2723 &entry,
2724 &target,
2725 &InstallCtx {
2726 repo_root: dir.path(),
2727 opts: None,
2728 },
2729 )
2730 .unwrap();
2731 assert_eq!(
2732 std::fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
2733 new_installed,
2734 "updated patch must describe the latest installed content"
2735 );
2736 }
2737
2738 #[test]
2743 fn auto_pin_dir_entry_writes_per_file_patches() {
2744 let dir = tempfile::tempdir().unwrap();
2745 let name = "lang-pro";
2746
2747 std::fs::write(
2749 dir.path().join("Skillfile"),
2750 format!(
2751 "install claude-code local\ngithub skill {name} owner/repo skills/{name}\n"
2752 ),
2753 )
2754 .unwrap();
2755 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2756 locked.insert(
2757 format!("github/skill/{name}"),
2758 LockEntry {
2759 sha: "deadbeefdeadbeefdeadbeef".into(),
2760 raw_url: format!("https://example.com/{name}"),
2761 },
2762 );
2763 write_lock_fixture(dir.path(), &locked);
2764
2765 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
2767 std::fs::create_dir_all(&vdir).unwrap();
2768 std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
2769 std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
2770
2771 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
2773 std::fs::create_dir_all(&inst_dir).unwrap();
2774 std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
2775 std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
2776
2777 let entry = make_dir_skill_entry(name);
2778 let manifest = Manifest {
2779 entries: vec![entry.clone()],
2780 install_targets: vec![make_target("claude-code", Scope::Local)],
2781 };
2782
2783 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2784
2785 let skill_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
2787 assert!(skill_patch.exists(), "patch for SKILL.md must be written");
2788
2789 let examples_patch = dir_patch_fixture_path(dir.path(), &entry, "examples.md");
2791 assert!(
2792 !examples_patch.exists(),
2793 "patch for examples.md must not be written (content unchanged)"
2794 );
2795 }
2796
2797 #[test]
2798 fn auto_pin_dir_entry_uses_second_target_when_first_is_clean() {
2799 let dir = tempfile::tempdir().unwrap();
2800 let name = "lang-pro";
2801
2802 std::fs::write(
2803 dir.path().join("Skillfile"),
2804 format!(
2805 "install claude-code local\ninstall cursor local\ngithub skill {name} owner/repo skills/{name}\n"
2806 ),
2807 )
2808 .unwrap();
2809 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2810 locked.insert(
2811 format!("github/skill/{name}"),
2812 LockEntry {
2813 sha: "deadbeefdeadbeefdeadbeef".into(),
2814 raw_url: format!("https://example.com/{name}"),
2815 },
2816 );
2817 write_lock_fixture(dir.path(), &locked);
2818
2819 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
2820 std::fs::create_dir_all(&vdir).unwrap();
2821 std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
2822
2823 let first_inst_dir = dir.path().join(format!(".claude/skills/{name}"));
2824 std::fs::create_dir_all(&first_inst_dir).unwrap();
2825 std::fs::write(first_inst_dir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
2826
2827 let second_inst_dir = dir.path().join(format!(".cursor/skills/{name}"));
2828 std::fs::create_dir_all(&second_inst_dir).unwrap();
2829 std::fs::write(
2830 second_inst_dir.join("SKILL.md"),
2831 "# Lang Pro\n\nModified.\n",
2832 )
2833 .unwrap();
2834
2835 let entry = make_dir_skill_entry(name);
2836 let manifest = Manifest {
2837 entries: vec![entry.clone()],
2838 install_targets: vec![
2839 make_target("claude-code", Scope::Local),
2840 make_target("cursor", Scope::Local),
2841 ],
2842 };
2843
2844 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2845
2846 std::fs::write(first_inst_dir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
2847 install_entry(
2848 &entry,
2849 &make_target("claude-code", Scope::Local),
2850 &InstallCtx {
2851 repo_root: dir.path(),
2852 opts: None,
2853 },
2854 )
2855 .unwrap();
2856 assert_eq!(
2857 std::fs::read_to_string(first_inst_dir.join("SKILL.md")).unwrap(),
2858 "# Lang Pro\n\nModified.\n",
2859 "auto-pin must preserve dir-entry edits from a modified secondary target"
2860 );
2861 }
2862
2863 #[test]
2864 fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
2865 let dir = tempfile::tempdir().unwrap();
2866 let name = "lang-pro";
2867
2868 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2870 locked.insert(
2871 format!("github/skill/{name}"),
2872 LockEntry {
2873 sha: "abc".into(),
2874 raw_url: "https://example.com".into(),
2875 },
2876 );
2877 write_lock_fixture(dir.path(), &locked);
2878
2879 let entry = make_dir_skill_entry(name);
2880 let manifest = Manifest {
2881 entries: vec![entry.clone()],
2882 install_targets: vec![make_target("claude-code", Scope::Local)],
2883 };
2884
2885 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2887
2888 assert!(!has_dir_patch_fixture(dir.path(), &entry));
2889 }
2890
2891 #[test]
2892 fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
2893 let dir = tempfile::tempdir().unwrap();
2894 let name = "lang-pro";
2895
2896 let cache_content = "# Lang Pro\n\nOriginal.\n";
2897 let modified = "# Lang Pro\n\nModified.\n";
2898
2899 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
2901 locked.insert(
2902 format!("github/skill/{name}"),
2903 LockEntry {
2904 sha: "abc".into(),
2905 raw_url: "https://example.com".into(),
2906 },
2907 );
2908 write_lock_fixture(dir.path(), &locked);
2909
2910 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
2912 std::fs::create_dir_all(&vdir).unwrap();
2913 std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
2914
2915 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
2917 std::fs::create_dir_all(&inst_dir).unwrap();
2918 std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
2919
2920 let entry = make_dir_skill_entry(name);
2921 let manifest = Manifest {
2922 entries: vec![entry.clone()],
2923 install_targets: vec![make_target("claude-code", Scope::Local)],
2924 };
2925
2926 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Lang Pro\n \n-Original.\n+Modified.\n";
2928 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
2929 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
2930 std::fs::write(&dp, patch_text).unwrap();
2931
2932 let patch_path = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
2933 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
2934
2935 std::thread::sleep(std::time::Duration::from_millis(20));
2936
2937 auto_pin_entry(&entry, &manifest, dir.path()).unwrap();
2938
2939 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
2940
2941 assert_eq!(
2942 mtime_before, mtime_after,
2943 "dir patch must not be rewritten when already up to date"
2944 );
2945 }
2946
2947 #[test]
2952 fn apply_dir_patches_applies_patch_and_rebases() {
2953 let dir = tempfile::tempdir().unwrap();
2954
2955 let cache_content = "# Skill\n\nOriginal.\n";
2957 let installed_content = "# Skill\n\nModified.\n";
2958 let new_cache_content = "# Skill\n\nOriginal v2.\n";
2960 let expected_rebased_to_new_cache = installed_content;
2963
2964 let entry = make_dir_skill_entry("lang-pro");
2965
2966 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
2968 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
2969 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
2970 std::fs::write(&dp, patch_text).unwrap();
2971
2972 let inst_dir = dir.path().join(".claude/skills/lang-pro");
2974 std::fs::create_dir_all(&inst_dir).unwrap();
2975 std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
2976
2977 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
2979 std::fs::create_dir_all(&new_cache_dir).unwrap();
2980 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
2981
2982 let mut installed_files = std::collections::HashMap::new();
2984 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
2985
2986 apply_dir_patches(
2987 &PatchCtx {
2988 entry: &entry,
2989 repo_root: dir.path(),
2990 },
2991 &installed_files,
2992 &new_cache_dir,
2993 )
2994 .unwrap();
2995
2996 let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
2998 assert_eq!(installed_after, installed_content);
2999
3000 std::fs::write(inst_dir.join("SKILL.md"), new_cache_content).unwrap();
3004 let mut reinstall_files = std::collections::HashMap::new();
3005 reinstall_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
3006 apply_dir_patches(
3007 &PatchCtx {
3008 entry: &entry,
3009 repo_root: dir.path(),
3010 },
3011 &reinstall_files,
3012 &new_cache_dir,
3013 )
3014 .unwrap();
3015 assert_eq!(
3016 std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap(),
3017 expected_rebased_to_new_cache,
3018 "rebased patch applied to new_cache must reproduce installed_content"
3019 );
3020 }
3021
3022 #[test]
3023 fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
3024 let dir = tempfile::tempdir().unwrap();
3025
3026 let original = "# Skill\n\nOriginal.\n";
3028 let modified = "# Skill\n\nModified.\n";
3029 let new_cache = modified; let entry = make_dir_skill_entry("lang-pro");
3033
3034 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
3036 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
3037 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
3038 std::fs::write(&dp, patch_text).unwrap();
3039
3040 let inst_dir = dir.path().join(".claude/skills/lang-pro");
3042 std::fs::create_dir_all(&inst_dir).unwrap();
3043 std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
3044
3045 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
3046 std::fs::create_dir_all(&new_cache_dir).unwrap();
3047 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
3048
3049 let mut installed_files = std::collections::HashMap::new();
3050 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
3051
3052 apply_dir_patches(
3053 &PatchCtx {
3054 entry: &entry,
3055 repo_root: dir.path(),
3056 },
3057 &installed_files,
3058 &new_cache_dir,
3059 )
3060 .unwrap();
3061
3062 let removed_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
3064 assert!(
3065 !removed_patch.exists(),
3066 "patch file must be removed when rebase yields empty diff"
3067 );
3068 }
3069
3070 #[test]
3071 fn apply_dir_patches_no_op_when_no_patches_dir() {
3072 let dir = tempfile::tempdir().unwrap();
3073
3074 let entry = make_dir_skill_entry("lang-pro");
3076 let installed_files = std::collections::HashMap::new();
3077 let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
3078 std::fs::create_dir_all(&source_dir).unwrap();
3079
3080 apply_dir_patches(
3082 &PatchCtx {
3083 entry: &entry,
3084 repo_root: dir.path(),
3085 },
3086 &installed_files,
3087 &source_dir,
3088 )
3089 .unwrap();
3090 }
3091
3092 #[test]
3097 fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
3098 let dir = tempfile::tempdir().unwrap();
3099
3100 let original = "# Skill\n\nOriginal.\n";
3101 let modified = "# Skill\n\nModified.\n";
3102 let new_cache = modified;
3104
3105 let entry = make_skill_entry("test");
3106
3107 let patch_text =
3109 "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
3110 write_patch_fixture(dir.path(), &entry, patch_text);
3111
3112 let vdir = dir.path().join(".skillfile/cache/skills/test");
3114 std::fs::create_dir_all(&vdir).unwrap();
3115 let source = vdir.join("test.md");
3116 std::fs::write(&source, new_cache).unwrap();
3117
3118 let installed_dir = dir.path().join(".claude/skills");
3120 std::fs::create_dir_all(&installed_dir).unwrap();
3121 let dest = installed_dir.join("test.md");
3122 std::fs::write(&dest, original).unwrap();
3123
3124 apply_single_file_patch(
3125 &PatchCtx {
3126 entry: &entry,
3127 repo_root: dir.path(),
3128 },
3129 &dest,
3130 &source,
3131 )
3132 .unwrap();
3133
3134 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
3136
3137 assert!(
3139 !patch_fixture_path(dir.path(), &entry).exists(),
3140 "patch must be removed when new cache already matches patched content"
3141 );
3142 }
3143
3144 #[test]
3145 fn apply_single_file_patch_rewrites_patch_after_rebase() {
3146 let dir = tempfile::tempdir().unwrap();
3147
3148 let original = "# Skill\n\nOriginal.\n";
3150 let modified = "# Skill\n\nModified.\n";
3151 let new_cache = "# Skill\n\nOriginal v2.\n";
3152 let expected_rebased_result = modified;
3155
3156 let entry = make_skill_entry("test");
3157
3158 let patch_text =
3160 "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
3161 write_patch_fixture(dir.path(), &entry, patch_text);
3162
3163 let vdir = dir.path().join(".skillfile/cache/skills/test");
3165 std::fs::create_dir_all(&vdir).unwrap();
3166 let source = vdir.join("test.md");
3167 std::fs::write(&source, new_cache).unwrap();
3168
3169 let installed_dir = dir.path().join(".claude/skills");
3171 std::fs::create_dir_all(&installed_dir).unwrap();
3172 let dest = installed_dir.join("test.md");
3173 std::fs::write(&dest, original).unwrap();
3174
3175 apply_single_file_patch(
3176 &PatchCtx {
3177 entry: &entry,
3178 repo_root: dir.path(),
3179 },
3180 &dest,
3181 &source,
3182 )
3183 .unwrap();
3184
3185 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
3187
3188 assert!(
3190 patch_fixture_path(dir.path(), &entry).exists(),
3191 "rebased patch must still exist (new_cache != modified)"
3192 );
3193 std::fs::write(&dest, new_cache).unwrap();
3196 std::fs::write(&source, new_cache).unwrap();
3197 apply_single_file_patch(
3198 &PatchCtx {
3199 entry: &entry,
3200 repo_root: dir.path(),
3201 },
3202 &dest,
3203 &source,
3204 )
3205 .unwrap();
3206 assert_eq!(
3207 std::fs::read_to_string(&dest).unwrap(),
3208 expected_rebased_result,
3209 "rebased patch applied to new_cache must reproduce installed content"
3210 );
3211 }
3212
3213 #[test]
3218 fn check_preconditions_no_targets_returns_error() {
3219 let dir = tempfile::tempdir().unwrap();
3220 let manifest = Manifest {
3221 entries: vec![],
3222 install_targets: vec![],
3223 };
3224 let result = check_preconditions(&manifest, dir.path());
3225 assert!(result.is_err());
3226 assert!(result
3227 .unwrap_err()
3228 .to_string()
3229 .contains("No install targets"));
3230 }
3231
3232 #[test]
3233 fn check_preconditions_pending_conflict_returns_error() {
3234 let dir = tempfile::tempdir().unwrap();
3235 let manifest = Manifest {
3236 entries: vec![],
3237 install_targets: vec![make_target("claude-code", Scope::Local)],
3238 };
3239
3240 write_conflict_fixture(
3241 dir.path(),
3242 &ConflictState {
3243 entry: "my-skill".into(),
3244 entity_type: EntityType::Skill,
3245 old_sha: "aaa".into(),
3246 new_sha: "bbb".into(),
3247 },
3248 );
3249
3250 let result = check_preconditions(&manifest, dir.path());
3251 assert!(result.is_err());
3252 assert!(result.unwrap_err().to_string().contains("pending conflict"));
3253 }
3254
3255 #[test]
3256 fn check_preconditions_ok_with_target_and_no_conflict() {
3257 let dir = tempfile::tempdir().unwrap();
3258 let manifest = Manifest {
3259 entries: vec![],
3260 install_targets: vec![make_target("claude-code", Scope::Local)],
3261 };
3262 check_preconditions(&manifest, dir.path()).unwrap();
3263 }
3264}