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