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