1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5
6use skillfile_core::models::{EntityType, Entry, InstallOptions, Scope};
7use skillfile_core::patch::walkdir;
8use skillfile_core::progress;
9use skillfile_sources::strategy::is_dir_entry;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DirInstallMode {
21 Flat,
22 Nested,
23}
24
25pub type DeployResult = HashMap<String, PathBuf>;
32
33pub struct AdapterScope<'a> {
35 pub scope: Scope,
36 pub repo_root: &'a Path,
37}
38
39pub struct DeployRequest<'a> {
41 pub entry: &'a Entry,
42 pub source: &'a Path,
43 pub scope: Scope,
44 pub repo_root: &'a Path,
45 pub opts: &'a InstallOptions,
46}
47
48pub trait PlatformAdapter: Send + Sync + fmt::Debug {
56 fn name(&self) -> &str;
58
59 fn supports(&self, entity_type: EntityType) -> bool;
61
62 fn target_dir(&self, entity_type: EntityType, ctx: &AdapterScope<'_>) -> PathBuf;
64
65 fn dir_mode(&self, entity_type: EntityType) -> Option<DirInstallMode>;
67
68 fn deploy_entry(&self, req: &DeployRequest<'_>) -> DeployResult;
73
74 fn installed_path(&self, entry: &Entry, ctx: &AdapterScope<'_>) -> PathBuf;
76
77 fn installed_dir_files(
79 &self,
80 entry: &Entry,
81 ctx: &AdapterScope<'_>,
82 ) -> HashMap<String, PathBuf>;
83}
84
85#[derive(Debug, Clone)]
91pub struct EntityConfig {
92 pub global_path: String,
93 pub local_path: String,
94 pub dir_mode: DirInstallMode,
95}
96
97#[derive(Debug, Clone)]
108pub struct FileSystemAdapter {
109 name: String,
110 entities: HashMap<EntityType, EntityConfig>,
111}
112
113impl FileSystemAdapter {
114 pub fn new(name: &str, entities: HashMap<EntityType, EntityConfig>) -> Self {
115 Self {
116 name: name.to_string(),
117 entities,
118 }
119 }
120}
121
122impl PlatformAdapter for FileSystemAdapter {
123 fn name(&self) -> &str {
124 &self.name
125 }
126
127 fn supports(&self, entity_type: EntityType) -> bool {
128 self.entities.contains_key(&entity_type)
129 }
130
131 fn target_dir(&self, entity_type: EntityType, ctx: &AdapterScope<'_>) -> PathBuf {
132 let config = self.entities.get(&entity_type).unwrap_or_else(|| {
133 panic!(
134 "BUG: target_dir called for unsupported entity type '{entity_type}' on adapter '{}'. \
135 Call supports() first.",
136 self.name
137 )
138 });
139 let raw = match ctx.scope {
140 Scope::Global => &config.global_path,
141 Scope::Local => &config.local_path,
142 };
143 if raw.starts_with('~') {
144 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
145 home.join(raw.strip_prefix("~/").unwrap_or(raw))
146 } else {
147 ctx.repo_root.join(raw)
148 }
149 }
150
151 fn dir_mode(&self, entity_type: EntityType) -> Option<DirInstallMode> {
152 self.entities.get(&entity_type).map(|c| c.dir_mode)
153 }
154
155 fn deploy_entry(&self, req: &DeployRequest<'_>) -> DeployResult {
156 let ctx = AdapterScope {
157 scope: req.scope,
158 repo_root: req.repo_root,
159 };
160 let target_dir = self.target_dir(req.entry.entity_type, &ctx);
161 let is_dir = is_dir_entry(req.entry) || req.source.is_dir();
164
165 if is_dir
166 && self
167 .entities
168 .get(&req.entry.entity_type)
169 .is_some_and(|c| c.dir_mode == DirInstallMode::Flat)
170 {
171 return deploy_flat(req.source, &target_dir, req.opts);
172 }
173
174 let dest = if is_dir {
175 target_dir.join(&req.entry.name)
176 } else {
177 target_dir.join(format!("{}.md", req.entry.name))
178 };
179
180 if !place_file(
181 &PlaceOp {
182 source: req.source,
183 dest: &dest,
184 is_dir,
185 },
186 req.opts,
187 ) || req.opts.dry_run
188 {
189 return HashMap::new();
190 }
191
192 if is_dir {
193 collect_dir_deploy_result(req.source, &dest)
194 } else {
195 HashMap::from([(format!("{}.md", req.entry.name), dest)])
196 }
197 }
198
199 fn installed_path(&self, entry: &Entry, ctx: &AdapterScope<'_>) -> PathBuf {
200 self.target_dir(entry.entity_type, ctx)
201 .join(format!("{}.md", entry.name))
202 }
203
204 fn installed_dir_files(
205 &self,
206 entry: &Entry,
207 ctx: &AdapterScope<'_>,
208 ) -> HashMap<String, PathBuf> {
209 let target_dir = self.target_dir(entry.entity_type, ctx);
210 let mode = self
211 .entities
212 .get(&entry.entity_type)
213 .map_or(DirInstallMode::Nested, |c| c.dir_mode);
214
215 if mode == DirInstallMode::Nested {
216 collect_nested_installed(entry, &target_dir)
217 } else {
218 let vdir = skillfile_sources::sync::vendor_dir_for(entry, ctx.repo_root);
220 collect_flat_installed_checked(&vdir, &target_dir)
221 }
222 }
223}
224
225fn forward_slash(path: &Path) -> String {
234 path.to_string_lossy().replace('\\', "/")
235}
236
237fn collect_dir_deploy_result(source: &Path, dest: &Path) -> DeployResult {
239 let mut result = HashMap::new();
240 for file in walkdir(source) {
241 if file.file_name().is_none_or(|n| n == ".meta") {
242 continue;
243 }
244 let Ok(rel) = file.strip_prefix(source) else {
245 continue;
246 };
247 result.insert(forward_slash(rel), dest.join(rel));
248 }
249 result
250}
251
252fn collect_nested_installed(entry: &Entry, target_dir: &Path) -> HashMap<String, PathBuf> {
255 let installed_dir = target_dir.join(&entry.name);
256 if !installed_dir.is_dir() {
257 return HashMap::new();
258 }
259 collect_walkdir_relative(&installed_dir)
260}
261
262fn collect_flat_installed_checked(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
265 if !vdir.is_dir() {
266 return HashMap::new();
267 }
268 collect_flat_installed(vdir, target_dir)
269}
270
271fn collect_walkdir_relative(base: &Path) -> HashMap<String, PathBuf> {
273 let mut result = HashMap::new();
274 for file in walkdir(base) {
275 let Ok(rel) = file.strip_prefix(base) else {
276 continue;
277 };
278 result.insert(forward_slash(rel), file);
279 }
280 result
281}
282
283fn collect_flat_installed(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
286 let mut result = HashMap::new();
287 for file in walkdir(vdir) {
288 if file
289 .extension()
290 .is_none_or(|ext| ext.to_string_lossy() != "md")
291 {
292 continue;
293 }
294 let Ok(rel) = file.strip_prefix(vdir) else {
295 continue;
296 };
297 let dest = target_dir.join(file.file_name().unwrap_or_default());
298 if dest.exists() {
299 result.insert(forward_slash(rel), dest);
300 }
301 }
302 result
303}
304
305fn deploy_flat(source_dir: &Path, target_dir: &Path, opts: &InstallOptions) -> DeployResult {
307 let mut md_files: Vec<PathBuf> = walkdir(source_dir)
308 .into_iter()
309 .filter(|f| f.extension().is_some_and(|ext| ext == "md"))
310 .collect();
311 md_files.sort();
312
313 if opts.dry_run {
314 for src in md_files.iter().filter(|s| s.file_name().is_some()) {
315 let name = src.file_name().unwrap_or_default();
316 progress!(
317 " {} -> {} [copy, dry-run]",
318 name.to_string_lossy(),
319 target_dir.join(name).display()
320 );
321 }
322 return HashMap::new();
323 }
324
325 std::fs::create_dir_all(target_dir).ok();
326 let mut result = HashMap::new();
327 for src in &md_files {
328 let Some(name) = src.file_name() else {
329 continue;
330 };
331 let dest = target_dir.join(name);
332 if !opts.overwrite && dest.is_file() {
333 continue;
334 }
335 if dest.exists() {
336 std::fs::remove_file(&dest).ok();
337 }
338 if std::fs::copy(src, &dest).is_err() {
339 continue;
340 }
341 progress!(" {} -> {}", name.to_string_lossy(), dest.display());
342 if let Ok(rel) = src.strip_prefix(source_dir) {
343 result.insert(forward_slash(rel), dest);
344 }
345 }
346 result
347}
348
349struct PlaceOp<'a> {
350 source: &'a Path,
351 dest: &'a Path,
352 is_dir: bool,
353}
354
355fn place_file(op: &PlaceOp<'_>, opts: &InstallOptions) -> bool {
357 if !opts.overwrite && !opts.dry_run {
358 if op.is_dir && op.dest.is_dir() {
359 return false;
360 }
361 if !op.is_dir && op.dest.is_file() {
362 return false;
363 }
364 }
365
366 let label = format!(
367 " {} -> {}",
368 op.source.file_name().unwrap_or_default().to_string_lossy(),
369 op.dest.display()
370 );
371
372 if opts.dry_run {
373 progress!("{label} [copy, dry-run]");
374 return true;
375 }
376
377 if let Some(parent) = op.dest.parent() {
378 std::fs::create_dir_all(parent).ok();
379 }
380
381 if op.dest.exists() || op.dest.is_symlink() {
383 if op.dest.is_dir() {
384 std::fs::remove_dir_all(op.dest).ok();
385 } else {
386 std::fs::remove_file(op.dest).ok();
387 }
388 }
389
390 if op.is_dir {
391 copy_dir_recursive(op.source, op.dest).ok();
392 } else {
393 std::fs::copy(op.source, op.dest).ok();
394 }
395
396 progress!("{label}");
397 true
398}
399
400#[allow(clippy::cognitive_complexity)]
404fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
405 std::fs::create_dir_all(dst)?;
406 for entry in std::fs::read_dir(src)? {
407 let entry = entry?;
408 let ty = entry.file_type()?;
409 let dest_path = dst.join(entry.file_name());
410 if ty.is_dir() {
411 copy_dir_recursive(&entry.path(), &dest_path)?;
412 } else {
413 std::fs::copy(entry.path(), &dest_path)?;
414 }
415 }
416 Ok(())
417}
418
419pub struct AdapterRegistry {
429 adapters: HashMap<String, Box<dyn PlatformAdapter>>,
430}
431
432impl AdapterRegistry {
433 pub fn new(adapters: Vec<Box<dyn PlatformAdapter>>) -> Self {
435 let map = adapters
436 .into_iter()
437 .map(|a| (a.name().to_string(), a))
438 .collect();
439 Self { adapters: map }
440 }
441
442 pub fn builtin() -> Self {
444 Self::new(
445 BUILTIN_ADAPTERS
446 .iter()
447 .map(|spec| Box::new(build_adapter(spec)) as Box<dyn PlatformAdapter>)
448 .collect(),
449 )
450 }
451
452 pub fn get(&self, name: &str) -> Option<&dyn PlatformAdapter> {
454 self.adapters.get(name).map(|b| &**b)
455 }
456
457 pub fn contains(&self, name: &str) -> bool {
459 self.adapters.contains_key(name)
460 }
461
462 pub fn names(&self) -> Vec<&str> {
464 let mut names: Vec<&str> = self.adapters.keys().map(String::as_str).collect();
465 names.sort_unstable();
466 names
467 }
468}
469
470impl fmt::Debug for AdapterRegistry {
471 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472 f.debug_struct("AdapterRegistry")
473 .field("adapters", &self.names())
474 .finish()
475 }
476}
477
478struct EntitySpec {
484 entity_type: EntityType,
485 global_path: &'static str,
486 local_path: &'static str,
487 dir_mode: DirInstallMode,
488}
489
490struct AdapterSpec {
492 name: &'static str,
493 entities: &'static [EntitySpec],
494}
495
496const BUILTIN_ADAPTERS: &[AdapterSpec] = &[
509 AdapterSpec {
510 name: "claude-code",
511 entities: &[
512 EntitySpec {
513 entity_type: EntityType::Skill,
514 global_path: "~/.claude/skills",
515 local_path: ".claude/skills",
516 dir_mode: DirInstallMode::Nested,
517 },
518 EntitySpec {
519 entity_type: EntityType::Agent,
520 global_path: "~/.claude/agents",
521 local_path: ".claude/agents",
522 dir_mode: DirInstallMode::Flat,
523 },
524 ],
525 },
526 AdapterSpec {
527 name: "factory",
528 entities: &[
529 EntitySpec {
530 entity_type: EntityType::Skill,
531 global_path: "~/.factory/skills",
532 local_path: ".factory/skills",
533 dir_mode: DirInstallMode::Nested,
534 },
535 EntitySpec {
536 entity_type: EntityType::Agent,
537 global_path: "~/.factory/droids",
538 local_path: ".factory/droids",
539 dir_mode: DirInstallMode::Flat,
540 },
541 ],
542 },
543 AdapterSpec {
544 name: "gemini-cli",
545 entities: &[
546 EntitySpec {
547 entity_type: EntityType::Skill,
548 global_path: "~/.gemini/skills",
549 local_path: ".gemini/skills",
550 dir_mode: DirInstallMode::Nested,
551 },
552 EntitySpec {
553 entity_type: EntityType::Agent,
554 global_path: "~/.gemini/agents",
555 local_path: ".gemini/agents",
556 dir_mode: DirInstallMode::Flat,
557 },
558 ],
559 },
560 AdapterSpec {
561 name: "codex",
562 entities: &[EntitySpec {
563 entity_type: EntityType::Skill,
564 global_path: "~/.codex/skills",
565 local_path: ".codex/skills",
566 dir_mode: DirInstallMode::Nested,
567 }],
568 },
569 AdapterSpec {
570 name: "cursor",
571 entities: &[
572 EntitySpec {
573 entity_type: EntityType::Skill,
574 global_path: "~/.cursor/skills",
575 local_path: ".cursor/skills",
576 dir_mode: DirInstallMode::Nested,
577 },
578 EntitySpec {
579 entity_type: EntityType::Agent,
580 global_path: "~/.cursor/agents",
581 local_path: ".cursor/agents",
582 dir_mode: DirInstallMode::Flat,
583 },
584 ],
585 },
586 AdapterSpec {
587 name: "windsurf",
588 entities: &[EntitySpec {
589 entity_type: EntityType::Skill,
590 global_path: "~/.codeium/windsurf/skills",
591 local_path: ".windsurf/skills",
592 dir_mode: DirInstallMode::Nested,
593 }],
594 },
595 AdapterSpec {
596 name: "opencode",
597 entities: &[
598 EntitySpec {
599 entity_type: EntityType::Skill,
600 global_path: "~/.config/opencode/skills",
601 local_path: ".opencode/skills",
602 dir_mode: DirInstallMode::Nested,
603 },
604 EntitySpec {
605 entity_type: EntityType::Agent,
606 global_path: "~/.config/opencode/agents",
607 local_path: ".opencode/agents",
608 dir_mode: DirInstallMode::Flat,
609 },
610 ],
611 },
612 AdapterSpec {
613 name: "copilot",
614 entities: &[
615 EntitySpec {
616 entity_type: EntityType::Skill,
617 global_path: "~/.copilot/skills",
618 local_path: ".github/skills",
619 dir_mode: DirInstallMode::Nested,
620 },
621 EntitySpec {
622 entity_type: EntityType::Agent,
623 global_path: "~/.copilot/agents",
624 local_path: ".github/agents",
625 dir_mode: DirInstallMode::Flat,
626 },
627 ],
628 },
629];
630
631fn build_adapter(spec: &AdapterSpec) -> FileSystemAdapter {
633 let entities = spec
634 .entities
635 .iter()
636 .map(|e| {
637 (
638 e.entity_type,
639 EntityConfig {
640 global_path: e.global_path.into(),
641 local_path: e.local_path.into(),
642 dir_mode: e.dir_mode,
643 },
644 )
645 })
646 .collect();
647 FileSystemAdapter::new(spec.name, entities)
648}
649
650#[must_use]
656pub fn adapters() -> &'static AdapterRegistry {
657 static REGISTRY: OnceLock<AdapterRegistry> = OnceLock::new();
658 REGISTRY.get_or_init(AdapterRegistry::builtin)
659}
660
661#[must_use]
663pub fn known_adapters() -> Vec<&'static str> {
664 adapters().names()
665}
666
667#[cfg(test)]
672mod tests {
673 use super::*;
674
675 fn local(root: &Path) -> AdapterScope<'_> {
676 AdapterScope {
677 scope: Scope::Local,
678 repo_root: root,
679 }
680 }
681
682 fn global(root: &Path) -> AdapterScope<'_> {
683 AdapterScope {
684 scope: Scope::Global,
685 repo_root: root,
686 }
687 }
688
689 #[test]
692 fn all_builtin_adapters_in_registry() {
693 let reg = adapters();
694 assert!(reg.contains("claude-code"));
695 assert!(reg.contains("factory"));
696 assert!(reg.contains("gemini-cli"));
697 assert!(reg.contains("codex"));
698 assert!(reg.contains("cursor"));
699 assert!(reg.contains("windsurf"));
700 assert!(reg.contains("opencode"));
701 assert!(reg.contains("copilot"));
702 }
703
704 #[test]
705 fn known_adapters_contains_all() {
706 let names = known_adapters();
707 assert!(names.contains(&"claude-code"));
708 assert!(names.contains(&"factory"));
709 assert!(names.contains(&"gemini-cli"));
710 assert!(names.contains(&"codex"));
711 assert!(names.contains(&"cursor"));
712 assert!(names.contains(&"windsurf"));
713 assert!(names.contains(&"opencode"));
714 assert!(names.contains(&"copilot"));
715 assert_eq!(names.len(), 8);
716 }
717
718 #[test]
719 fn adapter_name_matches_registry_key() {
720 let reg = adapters();
721 for name in reg.names() {
722 let adapter = reg.get(name).unwrap();
723 assert_eq!(adapter.name(), name);
724 }
725 }
726
727 #[test]
728 fn registry_get_unknown_returns_none() {
729 assert!(adapters().get("unknown-tool").is_none());
730 }
731
732 #[test]
735 fn claude_code_supports_agent_and_skill() {
736 let a = adapters().get("claude-code").unwrap();
737 assert!(a.supports(EntityType::Agent));
738 assert!(a.supports(EntityType::Skill));
739 }
741
742 #[test]
743 fn factory_supports_agent_and_skill() {
744 let a = adapters().get("factory").unwrap();
745 assert!(a.supports(EntityType::Agent));
746 assert!(a.supports(EntityType::Skill));
747 }
748
749 #[test]
750 fn gemini_cli_supports_agent_and_skill() {
751 let a = adapters().get("gemini-cli").unwrap();
752 assert!(a.supports(EntityType::Agent));
753 assert!(a.supports(EntityType::Skill));
754 }
755
756 #[test]
757 fn codex_supports_skill_not_agent() {
758 let a = adapters().get("codex").unwrap();
759 assert!(a.supports(EntityType::Skill));
760 assert!(!a.supports(EntityType::Agent));
761 }
762
763 #[test]
766 fn local_target_dir_claude_code() {
767 let tmp = PathBuf::from("/tmp/test");
768 let a = adapters().get("claude-code").unwrap();
769 assert_eq!(
770 a.target_dir(EntityType::Agent, &local(&tmp)),
771 tmp.join(".claude/agents")
772 );
773 assert_eq!(
774 a.target_dir(EntityType::Skill, &local(&tmp)),
775 tmp.join(".claude/skills")
776 );
777 }
778
779 #[test]
780 fn local_target_dir_factory() {
781 let tmp = PathBuf::from("/tmp/test");
782 let a = adapters().get("factory").unwrap();
783 assert_eq!(
784 a.target_dir(EntityType::Agent, &local(&tmp)),
785 tmp.join(".factory/droids")
786 );
787 assert_eq!(
788 a.target_dir(EntityType::Skill, &local(&tmp)),
789 tmp.join(".factory/skills")
790 );
791 }
792
793 #[test]
794 fn local_target_dir_gemini_cli() {
795 let tmp = PathBuf::from("/tmp/test");
796 let a = adapters().get("gemini-cli").unwrap();
797 assert_eq!(
798 a.target_dir(EntityType::Agent, &local(&tmp)),
799 tmp.join(".gemini/agents")
800 );
801 assert_eq!(
802 a.target_dir(EntityType::Skill, &local(&tmp)),
803 tmp.join(".gemini/skills")
804 );
805 }
806
807 #[test]
808 fn local_target_dir_codex() {
809 let tmp = PathBuf::from("/tmp/test");
810 let a = adapters().get("codex").unwrap();
811 assert_eq!(
812 a.target_dir(EntityType::Skill, &local(&tmp)),
813 tmp.join(".codex/skills")
814 );
815 }
816
817 #[test]
818 fn global_target_dir_is_absolute() {
819 let a = adapters().get("claude-code").unwrap();
820 let result = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
821 assert!(result.is_absolute());
822 assert!(result.to_string_lossy().ends_with(".claude/agents"));
823 }
824
825 #[test]
826 fn global_target_dir_gemini_cli_skill() {
827 let a = adapters().get("gemini-cli").unwrap();
828 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
829 assert!(result.is_absolute());
830 assert!(result.to_string_lossy().ends_with(".gemini/skills"));
831 }
832
833 #[test]
834 fn global_target_dir_codex_skill() {
835 let a = adapters().get("codex").unwrap();
836 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
837 assert!(result.is_absolute());
838 assert!(result.to_string_lossy().ends_with(".codex/skills"));
839 }
840
841 #[test]
844 fn cursor_supports_agent_and_skill() {
845 let a = adapters().get("cursor").unwrap();
846 assert!(a.supports(EntityType::Agent));
847 assert!(a.supports(EntityType::Skill));
848 }
850
851 #[test]
852 fn windsurf_supports_skill_not_agent() {
853 let a = adapters().get("windsurf").unwrap();
854 assert!(a.supports(EntityType::Skill));
855 assert!(!a.supports(EntityType::Agent));
856 }
857
858 #[test]
859 fn opencode_supports_agent_and_skill() {
860 let a = adapters().get("opencode").unwrap();
861 assert!(a.supports(EntityType::Agent));
862 assert!(a.supports(EntityType::Skill));
863 }
865
866 #[test]
867 fn copilot_supports_agent_and_skill() {
868 let a = adapters().get("copilot").unwrap();
869 assert!(a.supports(EntityType::Agent));
870 assert!(a.supports(EntityType::Skill));
871 }
873
874 #[test]
877 fn local_target_dir_cursor() {
878 let tmp = PathBuf::from("/tmp/test");
879 let a = adapters().get("cursor").unwrap();
880 assert_eq!(
881 a.target_dir(EntityType::Agent, &local(&tmp)),
882 tmp.join(".cursor/agents")
883 );
884 assert_eq!(
885 a.target_dir(EntityType::Skill, &local(&tmp)),
886 tmp.join(".cursor/skills")
887 );
888 }
889
890 #[test]
891 fn local_target_dir_windsurf() {
892 let tmp = PathBuf::from("/tmp/test");
893 let a = adapters().get("windsurf").unwrap();
894 assert_eq!(
895 a.target_dir(EntityType::Skill, &local(&tmp)),
896 tmp.join(".windsurf/skills")
897 );
898 }
899
900 #[test]
901 fn local_target_dir_opencode() {
902 let tmp = PathBuf::from("/tmp/test");
903 let a = adapters().get("opencode").unwrap();
904 assert_eq!(
905 a.target_dir(EntityType::Agent, &local(&tmp)),
906 tmp.join(".opencode/agents")
907 );
908 assert_eq!(
909 a.target_dir(EntityType::Skill, &local(&tmp)),
910 tmp.join(".opencode/skills")
911 );
912 }
913
914 #[test]
915 fn local_target_dir_copilot() {
916 let tmp = PathBuf::from("/tmp/test");
917 let a = adapters().get("copilot").unwrap();
918 assert_eq!(
919 a.target_dir(EntityType::Agent, &local(&tmp)),
920 tmp.join(".github/agents")
921 );
922 assert_eq!(
923 a.target_dir(EntityType::Skill, &local(&tmp)),
924 tmp.join(".github/skills")
925 );
926 }
927
928 #[test]
929 fn global_target_dir_cursor() {
930 let a = adapters().get("cursor").unwrap();
931 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
932 assert!(skill.is_absolute());
933 assert!(skill.to_string_lossy().ends_with(".cursor/skills"));
934 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
935 assert!(agent.is_absolute());
936 assert!(agent.to_string_lossy().ends_with(".cursor/agents"));
937 }
938
939 #[test]
940 fn global_target_dir_windsurf() {
941 let a = adapters().get("windsurf").unwrap();
942 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
943 assert!(result.is_absolute());
944 assert!(
945 result.to_string_lossy().ends_with("windsurf/skills"),
946 "unexpected: {result:?}"
947 );
948 }
949
950 #[test]
951 fn global_target_dir_opencode() {
952 let a = adapters().get("opencode").unwrap();
953 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
954 assert!(skill.is_absolute());
955 assert!(
956 skill.to_string_lossy().ends_with("opencode/skills"),
957 "unexpected: {skill:?}"
958 );
959 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
960 assert!(agent.is_absolute());
961 assert!(
962 agent.to_string_lossy().ends_with("opencode/agents"),
963 "unexpected: {agent:?}"
964 );
965 }
966
967 #[test]
968 fn global_target_dir_copilot() {
969 let a = adapters().get("copilot").unwrap();
970 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
971 assert!(skill.is_absolute());
972 assert!(skill.to_string_lossy().ends_with(".copilot/skills"));
973 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
974 assert!(agent.is_absolute());
975 assert!(agent.to_string_lossy().ends_with(".copilot/agents"));
976 }
977
978 #[test]
981 fn cursor_dir_modes() {
982 let a = adapters().get("cursor").unwrap();
983 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
984 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
985 }
986
987 #[test]
988 fn windsurf_dir_mode() {
989 let a = adapters().get("windsurf").unwrap();
990 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
991 assert_eq!(a.dir_mode(EntityType::Agent), None);
992 }
993
994 #[test]
995 fn opencode_dir_modes() {
996 let a = adapters().get("opencode").unwrap();
997 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
998 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
999 }
1000
1001 #[test]
1002 fn copilot_dir_modes() {
1003 let a = adapters().get("copilot").unwrap();
1004 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1005 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1006 }
1007
1008 #[test]
1011 fn claude_code_dir_modes() {
1012 let a = adapters().get("claude-code").unwrap();
1013 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1014 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1015 }
1016
1017 #[test]
1018 fn gemini_cli_dir_modes() {
1019 let a = adapters().get("gemini-cli").unwrap();
1020 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1021 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1022 }
1023
1024 #[test]
1025 fn codex_dir_mode() {
1026 let a = adapters().get("codex").unwrap();
1027 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1028 }
1029
1030 #[test]
1033 fn custom_adapter_via_registry() {
1034 let custom = FileSystemAdapter::new(
1035 "my-tool",
1036 HashMap::from([(
1037 EntityType::Skill,
1038 EntityConfig {
1039 global_path: "~/.my-tool/skills".into(),
1040 local_path: ".my-tool/skills".into(),
1041 dir_mode: DirInstallMode::Nested,
1042 },
1043 )]),
1044 );
1045 let registry = AdapterRegistry::new(vec![Box::new(custom)]);
1046 let a = registry.get("my-tool").unwrap();
1047 assert!(a.supports(EntityType::Skill));
1048 assert!(!a.supports(EntityType::Agent));
1049 assert_eq!(registry.names(), vec!["my-tool"]);
1050 }
1051
1052 #[test]
1055 fn deploy_entry_single_file_key_matches_patch_convention() {
1056 use skillfile_core::models::{EntityType, SourceFields};
1057
1058 let dir = tempfile::tempdir().unwrap();
1059 let source_dir = dir.path().join(".skillfile/cache/agents/test");
1060 std::fs::create_dir_all(&source_dir).unwrap();
1061 std::fs::write(source_dir.join("agent.md"), "# Agent\n").unwrap();
1062 let source = source_dir.join("agent.md");
1063
1064 let entry = Entry {
1065 entity_type: EntityType::Agent,
1066 name: "test".into(),
1067 source: SourceFields::Github {
1068 owner_repo: "o/r".into(),
1069 path_in_repo: "agents/agent.md".into(),
1070 ref_: "main".into(),
1071 },
1072 };
1073 let a = adapters().get("claude-code").unwrap();
1074 let result = a.deploy_entry(&DeployRequest {
1075 entry: &entry,
1076 source: &source,
1077 scope: Scope::Local,
1078 repo_root: dir.path(),
1079 opts: &InstallOptions::default(),
1080 });
1081 assert!(
1082 result.contains_key("test.md"),
1083 "Single-file key must be 'test.md', got {:?}",
1084 result.keys().collect::<Vec<_>>()
1085 );
1086 }
1087
1088 #[test]
1091 fn deploy_flat_copies_md_files_to_target_dir() {
1092 use skillfile_core::models::{EntityType, SourceFields};
1093
1094 let dir = tempfile::tempdir().unwrap();
1095 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1097 std::fs::create_dir_all(&source_dir).unwrap();
1098 std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1099 std::fs::write(source_dir.join("frontend.md"), "# Frontend").unwrap();
1100 std::fs::write(source_dir.join(".meta"), "{}").unwrap();
1101
1102 let entry = Entry {
1103 entity_type: EntityType::Agent,
1104 name: "core-dev".into(),
1105 source: SourceFields::Github {
1106 owner_repo: "o/r".into(),
1107 path_in_repo: "agents/core-dev".into(),
1108 ref_: "main".into(),
1109 },
1110 };
1111 let a = adapters().get("claude-code").unwrap();
1112 let result = a.deploy_entry(&DeployRequest {
1113 entry: &entry,
1114 source: &source_dir,
1115 scope: Scope::Local,
1116 repo_root: dir.path(),
1117 opts: &InstallOptions {
1118 dry_run: false,
1119 overwrite: true,
1120 },
1121 });
1122 assert!(result.contains_key("backend.md"));
1124 assert!(result.contains_key("frontend.md"));
1125 assert!(!result.contains_key(".meta"));
1126 let target = dir.path().join(".claude/agents");
1128 assert!(target.join("backend.md").exists());
1129 assert!(target.join("frontend.md").exists());
1130 }
1131
1132 #[test]
1133 fn deploy_flat_dry_run_returns_empty() {
1134 use skillfile_core::models::{EntityType, SourceFields};
1135
1136 let dir = tempfile::tempdir().unwrap();
1137 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1138 std::fs::create_dir_all(&source_dir).unwrap();
1139 std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1140
1141 let entry = Entry {
1142 entity_type: EntityType::Agent,
1143 name: "core-dev".into(),
1144 source: SourceFields::Github {
1145 owner_repo: "o/r".into(),
1146 path_in_repo: "agents/core-dev".into(),
1147 ref_: "main".into(),
1148 },
1149 };
1150 let a = adapters().get("claude-code").unwrap();
1151 let result = a.deploy_entry(&DeployRequest {
1152 entry: &entry,
1153 source: &source_dir,
1154 scope: Scope::Local,
1155 repo_root: dir.path(),
1156 opts: &InstallOptions {
1157 dry_run: true,
1158 overwrite: false,
1159 },
1160 });
1161 assert!(result.is_empty());
1162 assert!(!dir.path().join(".claude/agents/backend.md").exists());
1163 }
1164
1165 #[test]
1166 fn deploy_flat_skips_existing_when_no_overwrite() {
1167 use skillfile_core::models::{EntityType, SourceFields};
1168
1169 let dir = tempfile::tempdir().unwrap();
1170 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1171 std::fs::create_dir_all(&source_dir).unwrap();
1172 std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1173
1174 let target = dir.path().join(".claude/agents");
1176 std::fs::create_dir_all(&target).unwrap();
1177 std::fs::write(target.join("backend.md"), "# Old").unwrap();
1178
1179 let entry = Entry {
1180 entity_type: EntityType::Agent,
1181 name: "core-dev".into(),
1182 source: SourceFields::Github {
1183 owner_repo: "o/r".into(),
1184 path_in_repo: "agents/core-dev".into(),
1185 ref_: "main".into(),
1186 },
1187 };
1188 let a = adapters().get("claude-code").unwrap();
1189 let result = a.deploy_entry(&DeployRequest {
1190 entry: &entry,
1191 source: &source_dir,
1192 scope: Scope::Local,
1193 repo_root: dir.path(),
1194 opts: &InstallOptions {
1195 dry_run: false,
1196 overwrite: false,
1197 },
1198 });
1199 assert!(result.is_empty());
1201 assert_eq!(
1203 std::fs::read_to_string(target.join("backend.md")).unwrap(),
1204 "# Old"
1205 );
1206 }
1207
1208 #[test]
1209 fn deploy_flat_overwrites_existing_when_overwrite_true() {
1210 use skillfile_core::models::{EntityType, SourceFields};
1211
1212 let dir = tempfile::tempdir().unwrap();
1213 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1214 std::fs::create_dir_all(&source_dir).unwrap();
1215 std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1216
1217 let target = dir.path().join(".claude/agents");
1218 std::fs::create_dir_all(&target).unwrap();
1219 std::fs::write(target.join("backend.md"), "# Old").unwrap();
1220
1221 let entry = Entry {
1222 entity_type: EntityType::Agent,
1223 name: "core-dev".into(),
1224 source: SourceFields::Github {
1225 owner_repo: "o/r".into(),
1226 path_in_repo: "agents/core-dev".into(),
1227 ref_: "main".into(),
1228 },
1229 };
1230 let a = adapters().get("claude-code").unwrap();
1231 let result = a.deploy_entry(&DeployRequest {
1232 entry: &entry,
1233 source: &source_dir,
1234 scope: Scope::Local,
1235 repo_root: dir.path(),
1236 opts: &InstallOptions {
1237 dry_run: false,
1238 overwrite: true,
1239 },
1240 });
1241 assert!(result.contains_key("backend.md"));
1242 assert_eq!(
1243 std::fs::read_to_string(target.join("backend.md")).unwrap(),
1244 "# New"
1245 );
1246 }
1247
1248 #[test]
1251 fn place_file_skips_existing_dir_when_no_overwrite() {
1252 use skillfile_core::models::{EntityType, SourceFields};
1253
1254 let dir = tempfile::tempdir().unwrap();
1255 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1256 std::fs::create_dir_all(&source_dir).unwrap();
1257 std::fs::write(source_dir.join("SKILL.md"), "# Skill").unwrap();
1258
1259 let dest = dir.path().join(".claude/skills/my-skill");
1261 std::fs::create_dir_all(&dest).unwrap();
1262 std::fs::write(dest.join("OLD.md"), "# Old").unwrap();
1263
1264 let entry = Entry {
1265 entity_type: EntityType::Skill,
1266 name: "my-skill".into(),
1267 source: SourceFields::Github {
1268 owner_repo: "o/r".into(),
1269 path_in_repo: "skills/my-skill".into(),
1270 ref_: "main".into(),
1271 },
1272 };
1273 let a = adapters().get("claude-code").unwrap();
1274 let result = a.deploy_entry(&DeployRequest {
1275 entry: &entry,
1276 source: &source_dir,
1277 scope: Scope::Local,
1278 repo_root: dir.path(),
1279 opts: &InstallOptions {
1280 dry_run: false,
1281 overwrite: false,
1282 },
1283 });
1284 assert!(result.is_empty());
1286 assert!(dest.join("OLD.md").exists());
1288 }
1289
1290 #[test]
1291 fn place_file_skips_existing_single_file_when_no_overwrite() {
1292 use skillfile_core::models::{EntityType, SourceFields};
1293
1294 let dir = tempfile::tempdir().unwrap();
1295 let source_file = dir.path().join("skills/my-skill.md");
1296 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1297 std::fs::write(&source_file, "# New").unwrap();
1298
1299 let dest = dir.path().join(".claude/skills/my-skill.md");
1300 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1301 std::fs::write(&dest, "# Old").unwrap();
1302
1303 let entry = Entry {
1304 entity_type: EntityType::Skill,
1305 name: "my-skill".into(),
1306 source: SourceFields::Local {
1307 path: "skills/my-skill.md".into(),
1308 },
1309 };
1310 let a = adapters().get("claude-code").unwrap();
1311 let result = a.deploy_entry(&DeployRequest {
1312 entry: &entry,
1313 source: &source_file,
1314 scope: Scope::Local,
1315 repo_root: dir.path(),
1316 opts: &InstallOptions {
1317 dry_run: false,
1318 overwrite: false,
1319 },
1320 });
1321 assert!(result.is_empty());
1322 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old");
1323 }
1324
1325 #[test]
1328 fn installed_dir_files_flat_mode_returns_deployed_files() {
1329 use skillfile_core::models::{EntityType, SourceFields};
1330
1331 let dir = tempfile::tempdir().unwrap();
1332 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1334 std::fs::create_dir_all(&vdir).unwrap();
1335 std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1336 std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1337 std::fs::write(vdir.join(".meta"), "{}").unwrap();
1338
1339 let target = dir.path().join(".claude/agents");
1341 std::fs::create_dir_all(&target).unwrap();
1342 std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1343 std::fs::write(target.join("frontend.md"), "# Frontend").unwrap();
1344
1345 let entry = Entry {
1346 entity_type: EntityType::Agent,
1347 name: "core-dev".into(),
1348 source: SourceFields::Github {
1349 owner_repo: "o/r".into(),
1350 path_in_repo: "agents/core-dev".into(),
1351 ref_: "main".into(),
1352 },
1353 };
1354 let a = adapters().get("claude-code").unwrap();
1355 let files = a.installed_dir_files(&entry, &local(dir.path()));
1356 assert!(files.contains_key("backend.md"));
1357 assert!(files.contains_key("frontend.md"));
1358 assert!(!files.contains_key(".meta"));
1359 }
1360
1361 #[test]
1362 fn installed_dir_files_flat_mode_no_vdir_returns_empty() {
1363 use skillfile_core::models::{EntityType, SourceFields};
1364
1365 let dir = tempfile::tempdir().unwrap();
1366 let entry = Entry {
1368 entity_type: EntityType::Agent,
1369 name: "core-dev".into(),
1370 source: SourceFields::Github {
1371 owner_repo: "o/r".into(),
1372 path_in_repo: "agents/core-dev".into(),
1373 ref_: "main".into(),
1374 },
1375 };
1376 let a = adapters().get("claude-code").unwrap();
1377 let files = a.installed_dir_files(&entry, &local(dir.path()));
1378 assert!(files.is_empty());
1379 }
1380
1381 #[test]
1382 fn installed_dir_files_flat_mode_skips_non_deployed_files() {
1383 use skillfile_core::models::{EntityType, SourceFields};
1384
1385 let dir = tempfile::tempdir().unwrap();
1386 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1387 std::fs::create_dir_all(&vdir).unwrap();
1388 std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1389 std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1390
1391 let target = dir.path().join(".claude/agents");
1393 std::fs::create_dir_all(&target).unwrap();
1394 std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1395 let entry = Entry {
1398 entity_type: EntityType::Agent,
1399 name: "core-dev".into(),
1400 source: SourceFields::Github {
1401 owner_repo: "o/r".into(),
1402 path_in_repo: "agents/core-dev".into(),
1403 ref_: "main".into(),
1404 },
1405 };
1406 let a = adapters().get("claude-code").unwrap();
1407 let files = a.installed_dir_files(&entry, &local(dir.path()));
1408 assert!(files.contains_key("backend.md"));
1409 assert!(!files.contains_key("frontend.md"));
1410 }
1411
1412 #[test]
1413 fn forward_slash_converts_backslashes() {
1414 assert_eq!(forward_slash(Path::new("a/b/c")), "a/b/c");
1415 assert_eq!(forward_slash(Path::new("simple.md")), "simple.md");
1416 }
1417
1418 #[cfg(windows)]
1419 #[test]
1420 fn forward_slash_converts_windows_separators() {
1421 assert_eq!(forward_slash(Path::new(r"a\b\c.md")), "a/b/c.md");
1422 }
1423
1424 #[test]
1425 fn deploy_entry_dir_keys_match_source_relative_paths() {
1426 use skillfile_core::models::{EntityType, SourceFields};
1427
1428 let dir = tempfile::tempdir().unwrap();
1429 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1430 std::fs::create_dir_all(&source_dir).unwrap();
1431 std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1432 std::fs::write(source_dir.join("examples.md"), "# Examples\n").unwrap();
1433
1434 let entry = Entry {
1435 entity_type: EntityType::Skill,
1436 name: "my-skill".into(),
1437 source: SourceFields::Github {
1438 owner_repo: "o/r".into(),
1439 path_in_repo: "skills/my-skill".into(),
1440 ref_: "main".into(),
1441 },
1442 };
1443 let a = adapters().get("claude-code").unwrap();
1444 let result = a.deploy_entry(&DeployRequest {
1445 entry: &entry,
1446 source: &source_dir,
1447 scope: Scope::Local,
1448 repo_root: dir.path(),
1449 opts: &InstallOptions::default(),
1450 });
1451 assert!(result.contains_key("SKILL.md"));
1452 assert!(result.contains_key("examples.md"));
1453 }
1454}