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 collect_dir_deploy_result(source: &Path, dest: &Path) -> DeployResult {
231 let mut result = HashMap::new();
232 for file in walkdir(source) {
233 if file.file_name().is_none_or(|n| n == ".meta") {
234 continue;
235 }
236 let Ok(rel) = file.strip_prefix(source) else {
237 continue;
238 };
239 result.insert(rel.to_string_lossy().to_string(), dest.join(rel));
240 }
241 result
242}
243
244fn collect_nested_installed(entry: &Entry, target_dir: &Path) -> HashMap<String, PathBuf> {
247 let installed_dir = target_dir.join(&entry.name);
248 if !installed_dir.is_dir() {
249 return HashMap::new();
250 }
251 collect_walkdir_relative(&installed_dir)
252}
253
254fn collect_flat_installed_checked(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
257 if !vdir.is_dir() {
258 return HashMap::new();
259 }
260 collect_flat_installed(vdir, target_dir)
261}
262
263fn collect_walkdir_relative(base: &Path) -> HashMap<String, PathBuf> {
265 let mut result = HashMap::new();
266 for file in walkdir(base) {
267 let Ok(rel) = file.strip_prefix(base) else {
268 continue;
269 };
270 result.insert(rel.to_string_lossy().to_string(), file);
271 }
272 result
273}
274
275fn collect_flat_installed(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
278 let mut result = HashMap::new();
279 for file in walkdir(vdir) {
280 if file
281 .extension()
282 .is_none_or(|ext| ext.to_string_lossy() != "md")
283 {
284 continue;
285 }
286 let Ok(rel) = file.strip_prefix(vdir) else {
287 continue;
288 };
289 let dest = target_dir.join(file.file_name().unwrap_or_default());
290 if dest.exists() {
291 result.insert(rel.to_string_lossy().to_string(), dest);
292 }
293 }
294 result
295}
296
297fn deploy_flat(source_dir: &Path, target_dir: &Path, opts: &InstallOptions) -> DeployResult {
299 let mut md_files: Vec<PathBuf> = walkdir(source_dir)
300 .into_iter()
301 .filter(|f| f.extension().is_some_and(|ext| ext == "md"))
302 .collect();
303 md_files.sort();
304
305 if opts.dry_run {
306 for src in md_files.iter().filter(|s| s.file_name().is_some()) {
307 let name = src.file_name().unwrap_or_default();
308 progress!(
309 " {} -> {} [copy, dry-run]",
310 name.to_string_lossy(),
311 target_dir.join(name).display()
312 );
313 }
314 return HashMap::new();
315 }
316
317 std::fs::create_dir_all(target_dir).ok();
318 let mut result = HashMap::new();
319 for src in &md_files {
320 let Some(name) = src.file_name() else {
321 continue;
322 };
323 let dest = target_dir.join(name);
324 if !opts.overwrite && dest.is_file() {
325 continue;
326 }
327 if dest.exists() {
328 std::fs::remove_file(&dest).ok();
329 }
330 if std::fs::copy(src, &dest).is_err() {
331 continue;
332 }
333 progress!(" {} -> {}", name.to_string_lossy(), dest.display());
334 if let Ok(rel) = src.strip_prefix(source_dir) {
335 result.insert(rel.to_string_lossy().to_string(), dest);
336 }
337 }
338 result
339}
340
341struct PlaceOp<'a> {
342 source: &'a Path,
343 dest: &'a Path,
344 is_dir: bool,
345}
346
347fn place_file(op: &PlaceOp<'_>, opts: &InstallOptions) -> bool {
349 if !opts.overwrite && !opts.dry_run {
350 if op.is_dir && op.dest.is_dir() {
351 return false;
352 }
353 if !op.is_dir && op.dest.is_file() {
354 return false;
355 }
356 }
357
358 let label = format!(
359 " {} -> {}",
360 op.source.file_name().unwrap_or_default().to_string_lossy(),
361 op.dest.display()
362 );
363
364 if opts.dry_run {
365 progress!("{label} [copy, dry-run]");
366 return true;
367 }
368
369 if let Some(parent) = op.dest.parent() {
370 std::fs::create_dir_all(parent).ok();
371 }
372
373 if op.dest.exists() || op.dest.is_symlink() {
375 if op.dest.is_dir() {
376 std::fs::remove_dir_all(op.dest).ok();
377 } else {
378 std::fs::remove_file(op.dest).ok();
379 }
380 }
381
382 if op.is_dir {
383 copy_dir_recursive(op.source, op.dest).ok();
384 } else {
385 std::fs::copy(op.source, op.dest).ok();
386 }
387
388 progress!("{label}");
389 true
390}
391
392#[allow(clippy::cognitive_complexity)]
396fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
397 std::fs::create_dir_all(dst)?;
398 for entry in std::fs::read_dir(src)? {
399 let entry = entry?;
400 let ty = entry.file_type()?;
401 let dest_path = dst.join(entry.file_name());
402 if ty.is_dir() {
403 copy_dir_recursive(&entry.path(), &dest_path)?;
404 } else {
405 std::fs::copy(entry.path(), &dest_path)?;
406 }
407 }
408 Ok(())
409}
410
411pub struct AdapterRegistry {
421 adapters: HashMap<String, Box<dyn PlatformAdapter>>,
422}
423
424impl AdapterRegistry {
425 pub fn new(adapters: Vec<Box<dyn PlatformAdapter>>) -> Self {
427 let map = adapters
428 .into_iter()
429 .map(|a| (a.name().to_string(), a))
430 .collect();
431 Self { adapters: map }
432 }
433
434 pub fn builtin() -> Self {
436 Self::new(
437 BUILTIN_ADAPTERS
438 .iter()
439 .map(|spec| Box::new(build_adapter(spec)) as Box<dyn PlatformAdapter>)
440 .collect(),
441 )
442 }
443
444 pub fn get(&self, name: &str) -> Option<&dyn PlatformAdapter> {
446 self.adapters.get(name).map(|b| &**b)
447 }
448
449 pub fn contains(&self, name: &str) -> bool {
451 self.adapters.contains_key(name)
452 }
453
454 pub fn names(&self) -> Vec<&str> {
456 let mut names: Vec<&str> = self
457 .adapters
458 .keys()
459 .map(std::string::String::as_str)
460 .collect();
461 names.sort_unstable();
462 names
463 }
464}
465
466impl fmt::Debug for AdapterRegistry {
467 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
468 f.debug_struct("AdapterRegistry")
469 .field("adapters", &self.names())
470 .finish()
471 }
472}
473
474struct EntitySpec {
480 entity_type: EntityType,
481 global_path: &'static str,
482 local_path: &'static str,
483 dir_mode: DirInstallMode,
484}
485
486struct AdapterSpec {
488 name: &'static str,
489 entities: &'static [EntitySpec],
490}
491
492const BUILTIN_ADAPTERS: &[AdapterSpec] = &[
504 AdapterSpec {
505 name: "claude-code",
506 entities: &[
507 EntitySpec {
508 entity_type: EntityType::Skill,
509 global_path: "~/.claude/skills",
510 local_path: ".claude/skills",
511 dir_mode: DirInstallMode::Nested,
512 },
513 EntitySpec {
514 entity_type: EntityType::Agent,
515 global_path: "~/.claude/agents",
516 local_path: ".claude/agents",
517 dir_mode: DirInstallMode::Flat,
518 },
519 ],
520 },
521 AdapterSpec {
522 name: "gemini-cli",
523 entities: &[
524 EntitySpec {
525 entity_type: EntityType::Skill,
526 global_path: "~/.gemini/skills",
527 local_path: ".gemini/skills",
528 dir_mode: DirInstallMode::Nested,
529 },
530 EntitySpec {
531 entity_type: EntityType::Agent,
532 global_path: "~/.gemini/agents",
533 local_path: ".gemini/agents",
534 dir_mode: DirInstallMode::Flat,
535 },
536 ],
537 },
538 AdapterSpec {
539 name: "codex",
540 entities: &[EntitySpec {
541 entity_type: EntityType::Skill,
542 global_path: "~/.codex/skills",
543 local_path: ".codex/skills",
544 dir_mode: DirInstallMode::Nested,
545 }],
546 },
547 AdapterSpec {
548 name: "cursor",
549 entities: &[
550 EntitySpec {
551 entity_type: EntityType::Skill,
552 global_path: "~/.cursor/skills",
553 local_path: ".cursor/skills",
554 dir_mode: DirInstallMode::Nested,
555 },
556 EntitySpec {
557 entity_type: EntityType::Agent,
558 global_path: "~/.cursor/agents",
559 local_path: ".cursor/agents",
560 dir_mode: DirInstallMode::Flat,
561 },
562 ],
563 },
564 AdapterSpec {
565 name: "windsurf",
566 entities: &[EntitySpec {
567 entity_type: EntityType::Skill,
568 global_path: "~/.codeium/windsurf/skills",
569 local_path: ".windsurf/skills",
570 dir_mode: DirInstallMode::Nested,
571 }],
572 },
573 AdapterSpec {
574 name: "opencode",
575 entities: &[
576 EntitySpec {
577 entity_type: EntityType::Skill,
578 global_path: "~/.config/opencode/skills",
579 local_path: ".opencode/skills",
580 dir_mode: DirInstallMode::Nested,
581 },
582 EntitySpec {
583 entity_type: EntityType::Agent,
584 global_path: "~/.config/opencode/agents",
585 local_path: ".opencode/agents",
586 dir_mode: DirInstallMode::Flat,
587 },
588 ],
589 },
590 AdapterSpec {
591 name: "copilot",
592 entities: &[
593 EntitySpec {
594 entity_type: EntityType::Skill,
595 global_path: "~/.copilot/skills",
596 local_path: ".github/skills",
597 dir_mode: DirInstallMode::Nested,
598 },
599 EntitySpec {
600 entity_type: EntityType::Agent,
601 global_path: "~/.copilot/agents",
602 local_path: ".github/agents",
603 dir_mode: DirInstallMode::Flat,
604 },
605 ],
606 },
607];
608
609fn build_adapter(spec: &AdapterSpec) -> FileSystemAdapter {
611 let entities = spec
612 .entities
613 .iter()
614 .map(|e| {
615 (
616 e.entity_type,
617 EntityConfig {
618 global_path: e.global_path.into(),
619 local_path: e.local_path.into(),
620 dir_mode: e.dir_mode,
621 },
622 )
623 })
624 .collect();
625 FileSystemAdapter::new(spec.name, entities)
626}
627
628#[must_use]
634pub fn adapters() -> &'static AdapterRegistry {
635 static REGISTRY: OnceLock<AdapterRegistry> = OnceLock::new();
636 REGISTRY.get_or_init(AdapterRegistry::builtin)
637}
638
639#[must_use]
641pub fn known_adapters() -> Vec<&'static str> {
642 adapters().names()
643}
644
645#[cfg(test)]
650mod tests {
651 use super::*;
652
653 fn local(root: &Path) -> AdapterScope<'_> {
654 AdapterScope {
655 scope: Scope::Local,
656 repo_root: root,
657 }
658 }
659
660 fn global(root: &Path) -> AdapterScope<'_> {
661 AdapterScope {
662 scope: Scope::Global,
663 repo_root: root,
664 }
665 }
666
667 #[test]
670 fn all_builtin_adapters_in_registry() {
671 let reg = adapters();
672 assert!(reg.contains("claude-code"));
673 assert!(reg.contains("gemini-cli"));
674 assert!(reg.contains("codex"));
675 assert!(reg.contains("cursor"));
676 assert!(reg.contains("windsurf"));
677 assert!(reg.contains("opencode"));
678 assert!(reg.contains("copilot"));
679 }
680
681 #[test]
682 fn known_adapters_contains_all() {
683 let names = known_adapters();
684 assert!(names.contains(&"claude-code"));
685 assert!(names.contains(&"gemini-cli"));
686 assert!(names.contains(&"codex"));
687 assert!(names.contains(&"cursor"));
688 assert!(names.contains(&"windsurf"));
689 assert!(names.contains(&"opencode"));
690 assert!(names.contains(&"copilot"));
691 assert_eq!(names.len(), 7);
692 }
693
694 #[test]
695 fn adapter_name_matches_registry_key() {
696 let reg = adapters();
697 for name in reg.names() {
698 let adapter = reg.get(name).unwrap();
699 assert_eq!(adapter.name(), name);
700 }
701 }
702
703 #[test]
704 fn registry_get_unknown_returns_none() {
705 assert!(adapters().get("unknown-tool").is_none());
706 }
707
708 #[test]
711 fn claude_code_supports_agent_and_skill() {
712 let a = adapters().get("claude-code").unwrap();
713 assert!(a.supports(EntityType::Agent));
714 assert!(a.supports(EntityType::Skill));
715 }
717
718 #[test]
719 fn gemini_cli_supports_agent_and_skill() {
720 let a = adapters().get("gemini-cli").unwrap();
721 assert!(a.supports(EntityType::Agent));
722 assert!(a.supports(EntityType::Skill));
723 }
724
725 #[test]
726 fn codex_supports_skill_not_agent() {
727 let a = adapters().get("codex").unwrap();
728 assert!(a.supports(EntityType::Skill));
729 assert!(!a.supports(EntityType::Agent));
730 }
731
732 #[test]
735 fn local_target_dir_claude_code() {
736 let tmp = PathBuf::from("/tmp/test");
737 let a = adapters().get("claude-code").unwrap();
738 assert_eq!(
739 a.target_dir(EntityType::Agent, &local(&tmp)),
740 tmp.join(".claude/agents")
741 );
742 assert_eq!(
743 a.target_dir(EntityType::Skill, &local(&tmp)),
744 tmp.join(".claude/skills")
745 );
746 }
747
748 #[test]
749 fn local_target_dir_gemini_cli() {
750 let tmp = PathBuf::from("/tmp/test");
751 let a = adapters().get("gemini-cli").unwrap();
752 assert_eq!(
753 a.target_dir(EntityType::Agent, &local(&tmp)),
754 tmp.join(".gemini/agents")
755 );
756 assert_eq!(
757 a.target_dir(EntityType::Skill, &local(&tmp)),
758 tmp.join(".gemini/skills")
759 );
760 }
761
762 #[test]
763 fn local_target_dir_codex() {
764 let tmp = PathBuf::from("/tmp/test");
765 let a = adapters().get("codex").unwrap();
766 assert_eq!(
767 a.target_dir(EntityType::Skill, &local(&tmp)),
768 tmp.join(".codex/skills")
769 );
770 }
771
772 #[test]
773 fn global_target_dir_is_absolute() {
774 let a = adapters().get("claude-code").unwrap();
775 let result = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
776 assert!(result.is_absolute());
777 assert!(result.to_string_lossy().ends_with(".claude/agents"));
778 }
779
780 #[test]
781 fn global_target_dir_gemini_cli_skill() {
782 let a = adapters().get("gemini-cli").unwrap();
783 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
784 assert!(result.is_absolute());
785 assert!(result.to_string_lossy().ends_with(".gemini/skills"));
786 }
787
788 #[test]
789 fn global_target_dir_codex_skill() {
790 let a = adapters().get("codex").unwrap();
791 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
792 assert!(result.is_absolute());
793 assert!(result.to_string_lossy().ends_with(".codex/skills"));
794 }
795
796 #[test]
799 fn cursor_supports_agent_and_skill() {
800 let a = adapters().get("cursor").unwrap();
801 assert!(a.supports(EntityType::Agent));
802 assert!(a.supports(EntityType::Skill));
803 }
805
806 #[test]
807 fn windsurf_supports_skill_not_agent() {
808 let a = adapters().get("windsurf").unwrap();
809 assert!(a.supports(EntityType::Skill));
810 assert!(!a.supports(EntityType::Agent));
811 }
812
813 #[test]
814 fn opencode_supports_agent_and_skill() {
815 let a = adapters().get("opencode").unwrap();
816 assert!(a.supports(EntityType::Agent));
817 assert!(a.supports(EntityType::Skill));
818 }
820
821 #[test]
822 fn copilot_supports_agent_and_skill() {
823 let a = adapters().get("copilot").unwrap();
824 assert!(a.supports(EntityType::Agent));
825 assert!(a.supports(EntityType::Skill));
826 }
828
829 #[test]
832 fn local_target_dir_cursor() {
833 let tmp = PathBuf::from("/tmp/test");
834 let a = adapters().get("cursor").unwrap();
835 assert_eq!(
836 a.target_dir(EntityType::Agent, &local(&tmp)),
837 tmp.join(".cursor/agents")
838 );
839 assert_eq!(
840 a.target_dir(EntityType::Skill, &local(&tmp)),
841 tmp.join(".cursor/skills")
842 );
843 }
844
845 #[test]
846 fn local_target_dir_windsurf() {
847 let tmp = PathBuf::from("/tmp/test");
848 let a = adapters().get("windsurf").unwrap();
849 assert_eq!(
850 a.target_dir(EntityType::Skill, &local(&tmp)),
851 tmp.join(".windsurf/skills")
852 );
853 }
854
855 #[test]
856 fn local_target_dir_opencode() {
857 let tmp = PathBuf::from("/tmp/test");
858 let a = adapters().get("opencode").unwrap();
859 assert_eq!(
860 a.target_dir(EntityType::Agent, &local(&tmp)),
861 tmp.join(".opencode/agents")
862 );
863 assert_eq!(
864 a.target_dir(EntityType::Skill, &local(&tmp)),
865 tmp.join(".opencode/skills")
866 );
867 }
868
869 #[test]
870 fn local_target_dir_copilot() {
871 let tmp = PathBuf::from("/tmp/test");
872 let a = adapters().get("copilot").unwrap();
873 assert_eq!(
874 a.target_dir(EntityType::Agent, &local(&tmp)),
875 tmp.join(".github/agents")
876 );
877 assert_eq!(
878 a.target_dir(EntityType::Skill, &local(&tmp)),
879 tmp.join(".github/skills")
880 );
881 }
882
883 #[test]
884 fn global_target_dir_cursor() {
885 let a = adapters().get("cursor").unwrap();
886 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
887 assert!(skill.is_absolute());
888 assert!(skill.to_string_lossy().ends_with(".cursor/skills"));
889 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
890 assert!(agent.is_absolute());
891 assert!(agent.to_string_lossy().ends_with(".cursor/agents"));
892 }
893
894 #[test]
895 fn global_target_dir_windsurf() {
896 let a = adapters().get("windsurf").unwrap();
897 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
898 assert!(result.is_absolute());
899 assert!(
900 result.to_string_lossy().ends_with("windsurf/skills"),
901 "unexpected: {result:?}"
902 );
903 }
904
905 #[test]
906 fn global_target_dir_opencode() {
907 let a = adapters().get("opencode").unwrap();
908 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
909 assert!(skill.is_absolute());
910 assert!(
911 skill.to_string_lossy().ends_with("opencode/skills"),
912 "unexpected: {skill:?}"
913 );
914 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
915 assert!(agent.is_absolute());
916 assert!(
917 agent.to_string_lossy().ends_with("opencode/agents"),
918 "unexpected: {agent:?}"
919 );
920 }
921
922 #[test]
923 fn global_target_dir_copilot() {
924 let a = adapters().get("copilot").unwrap();
925 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
926 assert!(skill.is_absolute());
927 assert!(skill.to_string_lossy().ends_with(".copilot/skills"));
928 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
929 assert!(agent.is_absolute());
930 assert!(agent.to_string_lossy().ends_with(".copilot/agents"));
931 }
932
933 #[test]
936 fn cursor_dir_modes() {
937 let a = adapters().get("cursor").unwrap();
938 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
939 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
940 }
941
942 #[test]
943 fn windsurf_dir_mode() {
944 let a = adapters().get("windsurf").unwrap();
945 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
946 assert_eq!(a.dir_mode(EntityType::Agent), None);
947 }
948
949 #[test]
950 fn opencode_dir_modes() {
951 let a = adapters().get("opencode").unwrap();
952 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
953 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
954 }
955
956 #[test]
957 fn copilot_dir_modes() {
958 let a = adapters().get("copilot").unwrap();
959 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
960 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
961 }
962
963 #[test]
966 fn claude_code_dir_modes() {
967 let a = adapters().get("claude-code").unwrap();
968 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
969 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
970 }
971
972 #[test]
973 fn gemini_cli_dir_modes() {
974 let a = adapters().get("gemini-cli").unwrap();
975 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
976 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
977 }
978
979 #[test]
980 fn codex_dir_mode() {
981 let a = adapters().get("codex").unwrap();
982 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
983 }
984
985 #[test]
988 fn custom_adapter_via_registry() {
989 let custom = FileSystemAdapter::new(
990 "my-tool",
991 HashMap::from([(
992 EntityType::Skill,
993 EntityConfig {
994 global_path: "~/.my-tool/skills".into(),
995 local_path: ".my-tool/skills".into(),
996 dir_mode: DirInstallMode::Nested,
997 },
998 )]),
999 );
1000 let registry = AdapterRegistry::new(vec![Box::new(custom)]);
1001 let a = registry.get("my-tool").unwrap();
1002 assert!(a.supports(EntityType::Skill));
1003 assert!(!a.supports(EntityType::Agent));
1004 assert_eq!(registry.names(), vec!["my-tool"]);
1005 }
1006
1007 #[test]
1010 fn deploy_entry_single_file_key_matches_patch_convention() {
1011 use skillfile_core::models::{EntityType, SourceFields};
1012
1013 let dir = tempfile::tempdir().unwrap();
1014 let source_dir = dir.path().join(".skillfile/cache/agents/test");
1015 std::fs::create_dir_all(&source_dir).unwrap();
1016 std::fs::write(source_dir.join("agent.md"), "# Agent\n").unwrap();
1017 let source = source_dir.join("agent.md");
1018
1019 let entry = Entry {
1020 entity_type: EntityType::Agent,
1021 name: "test".into(),
1022 source: SourceFields::Github {
1023 owner_repo: "o/r".into(),
1024 path_in_repo: "agents/agent.md".into(),
1025 ref_: "main".into(),
1026 },
1027 };
1028 let a = adapters().get("claude-code").unwrap();
1029 let result = a.deploy_entry(&DeployRequest {
1030 entry: &entry,
1031 source: &source,
1032 scope: Scope::Local,
1033 repo_root: dir.path(),
1034 opts: &InstallOptions::default(),
1035 });
1036 assert!(
1037 result.contains_key("test.md"),
1038 "Single-file key must be 'test.md', got {:?}",
1039 result.keys().collect::<Vec<_>>()
1040 );
1041 }
1042
1043 #[test]
1046 fn deploy_flat_copies_md_files_to_target_dir() {
1047 use skillfile_core::models::{EntityType, SourceFields};
1048
1049 let dir = tempfile::tempdir().unwrap();
1050 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1052 std::fs::create_dir_all(&source_dir).unwrap();
1053 std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1054 std::fs::write(source_dir.join("frontend.md"), "# Frontend").unwrap();
1055 std::fs::write(source_dir.join(".meta"), "{}").unwrap();
1056
1057 let entry = Entry {
1058 entity_type: EntityType::Agent,
1059 name: "core-dev".into(),
1060 source: SourceFields::Github {
1061 owner_repo: "o/r".into(),
1062 path_in_repo: "agents/core-dev".into(),
1063 ref_: "main".into(),
1064 },
1065 };
1066 let a = adapters().get("claude-code").unwrap();
1067 let result = a.deploy_entry(&DeployRequest {
1068 entry: &entry,
1069 source: &source_dir,
1070 scope: Scope::Local,
1071 repo_root: dir.path(),
1072 opts: &InstallOptions {
1073 dry_run: false,
1074 overwrite: true,
1075 },
1076 });
1077 assert!(result.contains_key("backend.md"));
1079 assert!(result.contains_key("frontend.md"));
1080 assert!(!result.contains_key(".meta"));
1081 let target = dir.path().join(".claude/agents");
1083 assert!(target.join("backend.md").exists());
1084 assert!(target.join("frontend.md").exists());
1085 }
1086
1087 #[test]
1088 fn deploy_flat_dry_run_returns_empty() {
1089 use skillfile_core::models::{EntityType, SourceFields};
1090
1091 let dir = tempfile::tempdir().unwrap();
1092 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1093 std::fs::create_dir_all(&source_dir).unwrap();
1094 std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1095
1096 let entry = Entry {
1097 entity_type: EntityType::Agent,
1098 name: "core-dev".into(),
1099 source: SourceFields::Github {
1100 owner_repo: "o/r".into(),
1101 path_in_repo: "agents/core-dev".into(),
1102 ref_: "main".into(),
1103 },
1104 };
1105 let a = adapters().get("claude-code").unwrap();
1106 let result = a.deploy_entry(&DeployRequest {
1107 entry: &entry,
1108 source: &source_dir,
1109 scope: Scope::Local,
1110 repo_root: dir.path(),
1111 opts: &InstallOptions {
1112 dry_run: true,
1113 overwrite: false,
1114 },
1115 });
1116 assert!(result.is_empty());
1117 assert!(!dir.path().join(".claude/agents/backend.md").exists());
1118 }
1119
1120 #[test]
1121 fn deploy_flat_skips_existing_when_no_overwrite() {
1122 use skillfile_core::models::{EntityType, SourceFields};
1123
1124 let dir = tempfile::tempdir().unwrap();
1125 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1126 std::fs::create_dir_all(&source_dir).unwrap();
1127 std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1128
1129 let target = dir.path().join(".claude/agents");
1131 std::fs::create_dir_all(&target).unwrap();
1132 std::fs::write(target.join("backend.md"), "# Old").unwrap();
1133
1134 let entry = Entry {
1135 entity_type: EntityType::Agent,
1136 name: "core-dev".into(),
1137 source: SourceFields::Github {
1138 owner_repo: "o/r".into(),
1139 path_in_repo: "agents/core-dev".into(),
1140 ref_: "main".into(),
1141 },
1142 };
1143 let a = adapters().get("claude-code").unwrap();
1144 let result = a.deploy_entry(&DeployRequest {
1145 entry: &entry,
1146 source: &source_dir,
1147 scope: Scope::Local,
1148 repo_root: dir.path(),
1149 opts: &InstallOptions {
1150 dry_run: false,
1151 overwrite: false,
1152 },
1153 });
1154 assert!(result.is_empty());
1156 assert_eq!(
1158 std::fs::read_to_string(target.join("backend.md")).unwrap(),
1159 "# Old"
1160 );
1161 }
1162
1163 #[test]
1164 fn deploy_flat_overwrites_existing_when_overwrite_true() {
1165 use skillfile_core::models::{EntityType, SourceFields};
1166
1167 let dir = tempfile::tempdir().unwrap();
1168 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1169 std::fs::create_dir_all(&source_dir).unwrap();
1170 std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1171
1172 let target = dir.path().join(".claude/agents");
1173 std::fs::create_dir_all(&target).unwrap();
1174 std::fs::write(target.join("backend.md"), "# Old").unwrap();
1175
1176 let entry = Entry {
1177 entity_type: EntityType::Agent,
1178 name: "core-dev".into(),
1179 source: SourceFields::Github {
1180 owner_repo: "o/r".into(),
1181 path_in_repo: "agents/core-dev".into(),
1182 ref_: "main".into(),
1183 },
1184 };
1185 let a = adapters().get("claude-code").unwrap();
1186 let result = a.deploy_entry(&DeployRequest {
1187 entry: &entry,
1188 source: &source_dir,
1189 scope: Scope::Local,
1190 repo_root: dir.path(),
1191 opts: &InstallOptions {
1192 dry_run: false,
1193 overwrite: true,
1194 },
1195 });
1196 assert!(result.contains_key("backend.md"));
1197 assert_eq!(
1198 std::fs::read_to_string(target.join("backend.md")).unwrap(),
1199 "# New"
1200 );
1201 }
1202
1203 #[test]
1206 fn place_file_skips_existing_dir_when_no_overwrite() {
1207 use skillfile_core::models::{EntityType, SourceFields};
1208
1209 let dir = tempfile::tempdir().unwrap();
1210 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1211 std::fs::create_dir_all(&source_dir).unwrap();
1212 std::fs::write(source_dir.join("SKILL.md"), "# Skill").unwrap();
1213
1214 let dest = dir.path().join(".claude/skills/my-skill");
1216 std::fs::create_dir_all(&dest).unwrap();
1217 std::fs::write(dest.join("OLD.md"), "# Old").unwrap();
1218
1219 let entry = Entry {
1220 entity_type: EntityType::Skill,
1221 name: "my-skill".into(),
1222 source: SourceFields::Github {
1223 owner_repo: "o/r".into(),
1224 path_in_repo: "skills/my-skill".into(),
1225 ref_: "main".into(),
1226 },
1227 };
1228 let a = adapters().get("claude-code").unwrap();
1229 let result = a.deploy_entry(&DeployRequest {
1230 entry: &entry,
1231 source: &source_dir,
1232 scope: Scope::Local,
1233 repo_root: dir.path(),
1234 opts: &InstallOptions {
1235 dry_run: false,
1236 overwrite: false,
1237 },
1238 });
1239 assert!(result.is_empty());
1241 assert!(dest.join("OLD.md").exists());
1243 }
1244
1245 #[test]
1246 fn place_file_skips_existing_single_file_when_no_overwrite() {
1247 use skillfile_core::models::{EntityType, SourceFields};
1248
1249 let dir = tempfile::tempdir().unwrap();
1250 let source_file = dir.path().join("skills/my-skill.md");
1251 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1252 std::fs::write(&source_file, "# New").unwrap();
1253
1254 let dest = dir.path().join(".claude/skills/my-skill.md");
1255 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1256 std::fs::write(&dest, "# Old").unwrap();
1257
1258 let entry = Entry {
1259 entity_type: EntityType::Skill,
1260 name: "my-skill".into(),
1261 source: SourceFields::Local {
1262 path: "skills/my-skill.md".into(),
1263 },
1264 };
1265 let a = adapters().get("claude-code").unwrap();
1266 let result = a.deploy_entry(&DeployRequest {
1267 entry: &entry,
1268 source: &source_file,
1269 scope: Scope::Local,
1270 repo_root: dir.path(),
1271 opts: &InstallOptions {
1272 dry_run: false,
1273 overwrite: false,
1274 },
1275 });
1276 assert!(result.is_empty());
1277 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old");
1278 }
1279
1280 #[test]
1283 fn installed_dir_files_flat_mode_returns_deployed_files() {
1284 use skillfile_core::models::{EntityType, SourceFields};
1285
1286 let dir = tempfile::tempdir().unwrap();
1287 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1289 std::fs::create_dir_all(&vdir).unwrap();
1290 std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1291 std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1292 std::fs::write(vdir.join(".meta"), "{}").unwrap();
1293
1294 let target = dir.path().join(".claude/agents");
1296 std::fs::create_dir_all(&target).unwrap();
1297 std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1298 std::fs::write(target.join("frontend.md"), "# Frontend").unwrap();
1299
1300 let entry = Entry {
1301 entity_type: EntityType::Agent,
1302 name: "core-dev".into(),
1303 source: SourceFields::Github {
1304 owner_repo: "o/r".into(),
1305 path_in_repo: "agents/core-dev".into(),
1306 ref_: "main".into(),
1307 },
1308 };
1309 let a = adapters().get("claude-code").unwrap();
1310 let files = a.installed_dir_files(&entry, &local(dir.path()));
1311 assert!(files.contains_key("backend.md"));
1312 assert!(files.contains_key("frontend.md"));
1313 assert!(!files.contains_key(".meta"));
1314 }
1315
1316 #[test]
1317 fn installed_dir_files_flat_mode_no_vdir_returns_empty() {
1318 use skillfile_core::models::{EntityType, SourceFields};
1319
1320 let dir = tempfile::tempdir().unwrap();
1321 let entry = Entry {
1323 entity_type: EntityType::Agent,
1324 name: "core-dev".into(),
1325 source: SourceFields::Github {
1326 owner_repo: "o/r".into(),
1327 path_in_repo: "agents/core-dev".into(),
1328 ref_: "main".into(),
1329 },
1330 };
1331 let a = adapters().get("claude-code").unwrap();
1332 let files = a.installed_dir_files(&entry, &local(dir.path()));
1333 assert!(files.is_empty());
1334 }
1335
1336 #[test]
1337 fn installed_dir_files_flat_mode_skips_non_deployed_files() {
1338 use skillfile_core::models::{EntityType, SourceFields};
1339
1340 let dir = tempfile::tempdir().unwrap();
1341 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1342 std::fs::create_dir_all(&vdir).unwrap();
1343 std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1344 std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1345
1346 let target = dir.path().join(".claude/agents");
1348 std::fs::create_dir_all(&target).unwrap();
1349 std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1350 let entry = Entry {
1353 entity_type: EntityType::Agent,
1354 name: "core-dev".into(),
1355 source: SourceFields::Github {
1356 owner_repo: "o/r".into(),
1357 path_in_repo: "agents/core-dev".into(),
1358 ref_: "main".into(),
1359 },
1360 };
1361 let a = adapters().get("claude-code").unwrap();
1362 let files = a.installed_dir_files(&entry, &local(dir.path()));
1363 assert!(files.contains_key("backend.md"));
1364 assert!(!files.contains_key("frontend.md"));
1365 }
1366
1367 #[test]
1368 fn deploy_entry_dir_keys_match_source_relative_paths() {
1369 use skillfile_core::models::{EntityType, SourceFields};
1370
1371 let dir = tempfile::tempdir().unwrap();
1372 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1373 std::fs::create_dir_all(&source_dir).unwrap();
1374 std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1375 std::fs::write(source_dir.join("examples.md"), "# Examples\n").unwrap();
1376
1377 let entry = Entry {
1378 entity_type: EntityType::Skill,
1379 name: "my-skill".into(),
1380 source: SourceFields::Github {
1381 owner_repo: "o/r".into(),
1382 path_in_repo: "skills/my-skill".into(),
1383 ref_: "main".into(),
1384 },
1385 };
1386 let a = adapters().get("claude-code").unwrap();
1387 let result = a.deploy_entry(&DeployRequest {
1388 entry: &entry,
1389 source: &source_dir,
1390 scope: Scope::Local,
1391 repo_root: dir.path(),
1392 opts: &InstallOptions::default(),
1393 });
1394 assert!(result.contains_key("SKILL.md"));
1395 assert!(result.contains_key("examples.md"));
1396 }
1397}