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