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[entity_type];
126 let raw = match 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 repo_root.join(raw)
135 }
136 }
137
138 fn dir_mode(&self, entity_type: &str) -> Option<DirInstallMode> {
139 self.entities.get(entity_type).map(|c| c.dir_mode)
140 }
141
142 fn deploy_entry(
143 &self,
144 entry: &Entry,
145 source: &Path,
146 scope: Scope,
147 repo_root: &Path,
148 opts: &InstallOptions,
149 ) -> DeployResult {
150 let target_dir = self.target_dir(entry.entity_type.as_str(), scope, repo_root);
151 let is_dir = is_dir_entry(entry);
152
153 if is_dir
154 && self
155 .entities
156 .get(entry.entity_type.as_str())
157 .is_some_and(|c| c.dir_mode == DirInstallMode::Flat)
158 {
159 return deploy_flat(source, &target_dir, opts);
160 }
161
162 let dest = if is_dir {
163 target_dir.join(&entry.name)
164 } else {
165 target_dir.join(format!("{}.md", entry.name))
166 };
167
168 if !place_file(source, &dest, is_dir, opts) || opts.dry_run {
169 return HashMap::new();
170 }
171
172 if is_dir {
173 let mut result = HashMap::new();
174 for file in walkdir(source) {
175 if file.file_name().is_none_or(|n| n == ".meta") {
176 continue;
177 }
178 if let Ok(rel) = file.strip_prefix(source) {
179 result.insert(rel.to_string_lossy().to_string(), dest.join(rel));
180 }
181 }
182 result
183 } else {
184 HashMap::from([(format!("{}.md", entry.name), dest)])
185 }
186 }
187
188 fn installed_path(&self, entry: &Entry, scope: Scope, repo_root: &Path) -> PathBuf {
189 self.target_dir(entry.entity_type.as_str(), scope, repo_root)
190 .join(format!("{}.md", entry.name))
191 }
192
193 fn installed_dir_files(
194 &self,
195 entry: &Entry,
196 scope: Scope,
197 repo_root: &Path,
198 ) -> HashMap<String, PathBuf> {
199 let target_dir = self.target_dir(entry.entity_type.as_str(), scope, repo_root);
200 let mode = self
201 .entities
202 .get(entry.entity_type.as_str())
203 .map(|c| c.dir_mode)
204 .unwrap_or(DirInstallMode::Nested);
205
206 if mode == DirInstallMode::Nested {
207 let installed_dir = target_dir.join(&entry.name);
208 if !installed_dir.is_dir() {
209 return HashMap::new();
210 }
211 let mut result = HashMap::new();
212 for file in walkdir(&installed_dir) {
213 if let Ok(rel) = file.strip_prefix(&installed_dir) {
214 result.insert(rel.to_string_lossy().to_string(), file);
215 }
216 }
217 result
218 } else {
219 let vdir = skillfile_sources::sync::vendor_dir_for(entry, repo_root);
221 if !vdir.is_dir() {
222 return HashMap::new();
223 }
224 let mut result = HashMap::new();
225 for file in walkdir(&vdir) {
226 if file
227 .extension()
228 .is_none_or(|ext| ext.to_string_lossy() != "md")
229 {
230 continue;
231 }
232 if let Ok(rel) = file.strip_prefix(&vdir) {
233 let dest = target_dir.join(file.file_name().unwrap_or_default());
234 if dest.exists() {
235 result.insert(rel.to_string_lossy().to_string(), dest);
236 }
237 }
238 }
239 result
240 }
241 }
242}
243
244fn deploy_flat(source_dir: &Path, target_dir: &Path, opts: &InstallOptions) -> DeployResult {
250 let mut md_files: Vec<PathBuf> = walkdir(source_dir)
251 .into_iter()
252 .filter(|f| f.extension().is_some_and(|ext| ext == "md"))
253 .collect();
254 md_files.sort();
255
256 if opts.dry_run {
257 for src in &md_files {
258 if let Some(name) = src.file_name() {
259 progress!(
260 " {} -> {} [copy, dry-run]",
261 name.to_string_lossy(),
262 target_dir.join(name).display()
263 );
264 }
265 }
266 return HashMap::new();
267 }
268
269 std::fs::create_dir_all(target_dir).ok();
270 let mut result = HashMap::new();
271 for src in &md_files {
272 let Some(name) = src.file_name() else {
273 continue;
274 };
275 let dest = target_dir.join(name);
276 if !opts.overwrite && dest.is_file() {
277 continue;
278 }
279 if dest.exists() {
280 std::fs::remove_file(&dest).ok();
281 }
282 if std::fs::copy(src, &dest).is_ok() {
283 progress!(" {} -> {}", name.to_string_lossy(), dest.display());
284 if let Ok(rel) = src.strip_prefix(source_dir) {
285 result.insert(rel.to_string_lossy().to_string(), dest);
286 }
287 }
288 }
289 result
290}
291
292fn place_file(source: &Path, dest: &Path, is_dir: bool, opts: &InstallOptions) -> bool {
294 if !opts.overwrite && !opts.dry_run {
295 if is_dir && dest.is_dir() {
296 return false;
297 }
298 if !is_dir && dest.is_file() {
299 return false;
300 }
301 }
302
303 let label = format!(
304 " {} -> {}",
305 source.file_name().unwrap_or_default().to_string_lossy(),
306 dest.display()
307 );
308
309 if opts.dry_run {
310 progress!("{label} [copy, dry-run]");
311 return true;
312 }
313
314 if let Some(parent) = dest.parent() {
315 std::fs::create_dir_all(parent).ok();
316 }
317
318 if dest.exists() || dest.is_symlink() {
320 if dest.is_dir() {
321 std::fs::remove_dir_all(dest).ok();
322 } else {
323 std::fs::remove_file(dest).ok();
324 }
325 }
326
327 if is_dir {
328 copy_dir_recursive(source, dest).ok();
329 } else {
330 std::fs::copy(source, dest).ok();
331 }
332
333 progress!("{label}");
334 true
335}
336
337fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
339 std::fs::create_dir_all(dst)?;
340 for entry in std::fs::read_dir(src)? {
341 let entry = entry?;
342 let ty = entry.file_type()?;
343 let dest_path = dst.join(entry.file_name());
344 if ty.is_dir() {
345 copy_dir_recursive(&entry.path(), &dest_path)?;
346 } else {
347 std::fs::copy(entry.path(), &dest_path)?;
348 }
349 }
350 Ok(())
351}
352
353pub struct AdapterRegistry {
363 adapters: HashMap<String, Box<dyn PlatformAdapter>>,
364}
365
366impl AdapterRegistry {
367 pub fn new(adapters: Vec<Box<dyn PlatformAdapter>>) -> Self {
369 let map = adapters
370 .into_iter()
371 .map(|a| (a.name().to_string(), a))
372 .collect();
373 Self { adapters: map }
374 }
375
376 pub fn builtin() -> Self {
378 Self::new(vec![
379 Box::new(claude_code_adapter()),
380 Box::new(gemini_cli_adapter()),
381 Box::new(codex_adapter()),
382 ])
383 }
384
385 pub fn get(&self, name: &str) -> Option<&dyn PlatformAdapter> {
387 self.adapters.get(name).map(|b| &**b)
388 }
389
390 pub fn contains(&self, name: &str) -> bool {
392 self.adapters.contains_key(name)
393 }
394
395 pub fn names(&self) -> Vec<&str> {
397 let mut names: Vec<&str> = self.adapters.keys().map(|s| s.as_str()).collect();
398 names.sort();
399 names
400 }
401}
402
403impl fmt::Debug for AdapterRegistry {
404 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405 f.debug_struct("AdapterRegistry")
406 .field("adapters", &self.names())
407 .finish()
408 }
409}
410
411fn claude_code_adapter() -> FileSystemAdapter {
416 FileSystemAdapter::new(
417 "claude-code",
418 HashMap::from([
419 (
420 "agent".to_string(),
421 EntityConfig {
422 global_path: "~/.claude/agents".into(),
423 local_path: ".claude/agents".into(),
424 dir_mode: DirInstallMode::Flat,
425 },
426 ),
427 (
428 "skill".to_string(),
429 EntityConfig {
430 global_path: "~/.claude/skills".into(),
431 local_path: ".claude/skills".into(),
432 dir_mode: DirInstallMode::Nested,
433 },
434 ),
435 ]),
436 )
437}
438
439fn gemini_cli_adapter() -> FileSystemAdapter {
440 FileSystemAdapter::new(
441 "gemini-cli",
442 HashMap::from([
443 (
444 "agent".to_string(),
445 EntityConfig {
446 global_path: "~/.gemini/agents".into(),
447 local_path: ".gemini/agents".into(),
448 dir_mode: DirInstallMode::Flat,
449 },
450 ),
451 (
452 "skill".to_string(),
453 EntityConfig {
454 global_path: "~/.gemini/skills".into(),
455 local_path: ".gemini/skills".into(),
456 dir_mode: DirInstallMode::Nested,
457 },
458 ),
459 ]),
460 )
461}
462
463fn codex_adapter() -> FileSystemAdapter {
464 FileSystemAdapter::new(
465 "codex",
466 HashMap::from([(
467 "skill".to_string(),
468 EntityConfig {
469 global_path: "~/.codex/skills".into(),
470 local_path: ".codex/skills".into(),
471 dir_mode: DirInstallMode::Nested,
472 },
473 )]),
474 )
475}
476
477#[must_use]
483pub fn adapters() -> &'static AdapterRegistry {
484 static REGISTRY: OnceLock<AdapterRegistry> = OnceLock::new();
485 REGISTRY.get_or_init(AdapterRegistry::builtin)
486}
487
488#[must_use]
490pub fn known_adapters() -> Vec<&'static str> {
491 adapters().names()
492}
493
494#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
505 fn all_builtin_adapters_in_registry() {
506 let reg = adapters();
507 assert!(reg.contains("claude-code"));
508 assert!(reg.contains("gemini-cli"));
509 assert!(reg.contains("codex"));
510 }
511
512 #[test]
513 fn known_adapters_contains_all() {
514 let names = known_adapters();
515 assert!(names.contains(&"claude-code"));
516 assert!(names.contains(&"gemini-cli"));
517 assert!(names.contains(&"codex"));
518 assert_eq!(names.len(), 3);
519 }
520
521 #[test]
522 fn adapter_name_matches_registry_key() {
523 let reg = adapters();
524 for name in reg.names() {
525 let adapter = reg.get(name).unwrap();
526 assert_eq!(adapter.name(), name);
527 }
528 }
529
530 #[test]
531 fn registry_get_unknown_returns_none() {
532 assert!(adapters().get("unknown-tool").is_none());
533 }
534
535 #[test]
538 fn claude_code_supports_agent_and_skill() {
539 let a = adapters().get("claude-code").unwrap();
540 assert!(a.supports("agent"));
541 assert!(a.supports("skill"));
542 assert!(!a.supports("hook"));
543 }
544
545 #[test]
546 fn gemini_cli_supports_agent_and_skill() {
547 let a = adapters().get("gemini-cli").unwrap();
548 assert!(a.supports("agent"));
549 assert!(a.supports("skill"));
550 }
551
552 #[test]
553 fn codex_supports_skill_not_agent() {
554 let a = adapters().get("codex").unwrap();
555 assert!(a.supports("skill"));
556 assert!(!a.supports("agent"));
557 }
558
559 #[test]
562 fn local_target_dir_claude_code() {
563 let tmp = PathBuf::from("/tmp/test");
564 let a = adapters().get("claude-code").unwrap();
565 assert_eq!(
566 a.target_dir("agent", Scope::Local, &tmp),
567 tmp.join(".claude/agents")
568 );
569 assert_eq!(
570 a.target_dir("skill", Scope::Local, &tmp),
571 tmp.join(".claude/skills")
572 );
573 }
574
575 #[test]
576 fn local_target_dir_gemini_cli() {
577 let tmp = PathBuf::from("/tmp/test");
578 let a = adapters().get("gemini-cli").unwrap();
579 assert_eq!(
580 a.target_dir("agent", Scope::Local, &tmp),
581 tmp.join(".gemini/agents")
582 );
583 assert_eq!(
584 a.target_dir("skill", Scope::Local, &tmp),
585 tmp.join(".gemini/skills")
586 );
587 }
588
589 #[test]
590 fn local_target_dir_codex() {
591 let tmp = PathBuf::from("/tmp/test");
592 let a = adapters().get("codex").unwrap();
593 assert_eq!(
594 a.target_dir("skill", Scope::Local, &tmp),
595 tmp.join(".codex/skills")
596 );
597 }
598
599 #[test]
600 fn global_target_dir_is_absolute() {
601 let a = adapters().get("claude-code").unwrap();
602 let result = a.target_dir("agent", Scope::Global, Path::new("/tmp"));
603 assert!(result.is_absolute());
604 assert!(result.to_string_lossy().ends_with(".claude/agents"));
605 }
606
607 #[test]
608 fn global_target_dir_gemini_cli_skill() {
609 let a = adapters().get("gemini-cli").unwrap();
610 let result = a.target_dir("skill", Scope::Global, Path::new("/tmp"));
611 assert!(result.is_absolute());
612 assert!(result.to_string_lossy().ends_with(".gemini/skills"));
613 }
614
615 #[test]
616 fn global_target_dir_codex_skill() {
617 let a = adapters().get("codex").unwrap();
618 let result = a.target_dir("skill", Scope::Global, Path::new("/tmp"));
619 assert!(result.is_absolute());
620 assert!(result.to_string_lossy().ends_with(".codex/skills"));
621 }
622
623 #[test]
626 fn claude_code_dir_modes() {
627 let a = adapters().get("claude-code").unwrap();
628 assert_eq!(a.dir_mode("agent"), Some(DirInstallMode::Flat));
629 assert_eq!(a.dir_mode("skill"), Some(DirInstallMode::Nested));
630 }
631
632 #[test]
633 fn gemini_cli_dir_modes() {
634 let a = adapters().get("gemini-cli").unwrap();
635 assert_eq!(a.dir_mode("agent"), Some(DirInstallMode::Flat));
636 assert_eq!(a.dir_mode("skill"), Some(DirInstallMode::Nested));
637 }
638
639 #[test]
640 fn codex_dir_mode() {
641 let a = adapters().get("codex").unwrap();
642 assert_eq!(a.dir_mode("skill"), Some(DirInstallMode::Nested));
643 }
644
645 #[test]
648 fn custom_adapter_via_registry() {
649 let custom = FileSystemAdapter::new(
650 "my-tool",
651 HashMap::from([(
652 "skill".to_string(),
653 EntityConfig {
654 global_path: "~/.my-tool/skills".into(),
655 local_path: ".my-tool/skills".into(),
656 dir_mode: DirInstallMode::Nested,
657 },
658 )]),
659 );
660 let registry = AdapterRegistry::new(vec![Box::new(custom)]);
661 let a = registry.get("my-tool").unwrap();
662 assert!(a.supports("skill"));
663 assert!(!a.supports("agent"));
664 assert_eq!(registry.names(), vec!["my-tool"]);
665 }
666
667 #[test]
670 fn deploy_entry_single_file_key_matches_patch_convention() {
671 use skillfile_core::models::{EntityType, SourceFields};
672
673 let dir = tempfile::tempdir().unwrap();
674 let source_dir = dir.path().join(".skillfile/cache/agents/test");
675 std::fs::create_dir_all(&source_dir).unwrap();
676 std::fs::write(source_dir.join("agent.md"), "# Agent\n").unwrap();
677 let source = source_dir.join("agent.md");
678
679 let entry = Entry {
680 entity_type: EntityType::Agent,
681 name: "test".into(),
682 source: SourceFields::Github {
683 owner_repo: "o/r".into(),
684 path_in_repo: "agents/agent.md".into(),
685 ref_: "main".into(),
686 },
687 };
688 let a = adapters().get("claude-code").unwrap();
689 let result = a.deploy_entry(
690 &entry,
691 &source,
692 Scope::Local,
693 dir.path(),
694 &InstallOptions::default(),
695 );
696 assert!(
697 result.contains_key("test.md"),
698 "Single-file key must be 'test.md', got {:?}",
699 result.keys().collect::<Vec<_>>()
700 );
701 }
702
703 #[test]
706 fn deploy_flat_copies_md_files_to_target_dir() {
707 use skillfile_core::models::{EntityType, SourceFields};
708
709 let dir = tempfile::tempdir().unwrap();
710 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
712 std::fs::create_dir_all(&source_dir).unwrap();
713 std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
714 std::fs::write(source_dir.join("frontend.md"), "# Frontend").unwrap();
715 std::fs::write(source_dir.join(".meta"), "{}").unwrap();
716
717 let entry = Entry {
718 entity_type: EntityType::Agent,
719 name: "core-dev".into(),
720 source: SourceFields::Github {
721 owner_repo: "o/r".into(),
722 path_in_repo: "agents/core-dev".into(),
723 ref_: "main".into(),
724 },
725 };
726 let a = adapters().get("claude-code").unwrap();
727 let result = a.deploy_entry(
728 &entry,
729 &source_dir,
730 Scope::Local,
731 dir.path(),
732 &InstallOptions {
733 dry_run: false,
734 overwrite: true,
735 },
736 );
737 assert!(result.contains_key("backend.md"));
739 assert!(result.contains_key("frontend.md"));
740 assert!(!result.contains_key(".meta"));
741 let target = dir.path().join(".claude/agents");
743 assert!(target.join("backend.md").exists());
744 assert!(target.join("frontend.md").exists());
745 }
746
747 #[test]
748 fn deploy_flat_dry_run_returns_empty() {
749 use skillfile_core::models::{EntityType, SourceFields};
750
751 let dir = tempfile::tempdir().unwrap();
752 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
753 std::fs::create_dir_all(&source_dir).unwrap();
754 std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
755
756 let entry = Entry {
757 entity_type: EntityType::Agent,
758 name: "core-dev".into(),
759 source: SourceFields::Github {
760 owner_repo: "o/r".into(),
761 path_in_repo: "agents/core-dev".into(),
762 ref_: "main".into(),
763 },
764 };
765 let a = adapters().get("claude-code").unwrap();
766 let result = a.deploy_entry(
767 &entry,
768 &source_dir,
769 Scope::Local,
770 dir.path(),
771 &InstallOptions {
772 dry_run: true,
773 overwrite: false,
774 },
775 );
776 assert!(result.is_empty());
777 assert!(!dir.path().join(".claude/agents/backend.md").exists());
778 }
779
780 #[test]
781 fn deploy_flat_skips_existing_when_no_overwrite() {
782 use skillfile_core::models::{EntityType, SourceFields};
783
784 let dir = tempfile::tempdir().unwrap();
785 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
786 std::fs::create_dir_all(&source_dir).unwrap();
787 std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
788
789 let target = dir.path().join(".claude/agents");
791 std::fs::create_dir_all(&target).unwrap();
792 std::fs::write(target.join("backend.md"), "# Old").unwrap();
793
794 let entry = Entry {
795 entity_type: EntityType::Agent,
796 name: "core-dev".into(),
797 source: SourceFields::Github {
798 owner_repo: "o/r".into(),
799 path_in_repo: "agents/core-dev".into(),
800 ref_: "main".into(),
801 },
802 };
803 let a = adapters().get("claude-code").unwrap();
804 let result = a.deploy_entry(
805 &entry,
806 &source_dir,
807 Scope::Local,
808 dir.path(),
809 &InstallOptions {
810 dry_run: false,
811 overwrite: false,
812 },
813 );
814 assert!(result.is_empty());
816 assert_eq!(
818 std::fs::read_to_string(target.join("backend.md")).unwrap(),
819 "# Old"
820 );
821 }
822
823 #[test]
824 fn deploy_flat_overwrites_existing_when_overwrite_true() {
825 use skillfile_core::models::{EntityType, SourceFields};
826
827 let dir = tempfile::tempdir().unwrap();
828 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
829 std::fs::create_dir_all(&source_dir).unwrap();
830 std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
831
832 let target = dir.path().join(".claude/agents");
833 std::fs::create_dir_all(&target).unwrap();
834 std::fs::write(target.join("backend.md"), "# Old").unwrap();
835
836 let entry = Entry {
837 entity_type: EntityType::Agent,
838 name: "core-dev".into(),
839 source: SourceFields::Github {
840 owner_repo: "o/r".into(),
841 path_in_repo: "agents/core-dev".into(),
842 ref_: "main".into(),
843 },
844 };
845 let a = adapters().get("claude-code").unwrap();
846 let result = a.deploy_entry(
847 &entry,
848 &source_dir,
849 Scope::Local,
850 dir.path(),
851 &InstallOptions {
852 dry_run: false,
853 overwrite: true,
854 },
855 );
856 assert!(result.contains_key("backend.md"));
857 assert_eq!(
858 std::fs::read_to_string(target.join("backend.md")).unwrap(),
859 "# New"
860 );
861 }
862
863 #[test]
866 fn place_file_skips_existing_dir_when_no_overwrite() {
867 use skillfile_core::models::{EntityType, SourceFields};
868
869 let dir = tempfile::tempdir().unwrap();
870 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
871 std::fs::create_dir_all(&source_dir).unwrap();
872 std::fs::write(source_dir.join("SKILL.md"), "# Skill").unwrap();
873
874 let dest = dir.path().join(".claude/skills/my-skill");
876 std::fs::create_dir_all(&dest).unwrap();
877 std::fs::write(dest.join("OLD.md"), "# Old").unwrap();
878
879 let entry = Entry {
880 entity_type: EntityType::Skill,
881 name: "my-skill".into(),
882 source: SourceFields::Github {
883 owner_repo: "o/r".into(),
884 path_in_repo: "skills/my-skill".into(),
885 ref_: "main".into(),
886 },
887 };
888 let a = adapters().get("claude-code").unwrap();
889 let result = a.deploy_entry(
890 &entry,
891 &source_dir,
892 Scope::Local,
893 dir.path(),
894 &InstallOptions {
895 dry_run: false,
896 overwrite: false,
897 },
898 );
899 assert!(result.is_empty());
901 assert!(dest.join("OLD.md").exists());
903 }
904
905 #[test]
906 fn place_file_skips_existing_single_file_when_no_overwrite() {
907 use skillfile_core::models::{EntityType, SourceFields};
908
909 let dir = tempfile::tempdir().unwrap();
910 let source_file = dir.path().join("skills/my-skill.md");
911 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
912 std::fs::write(&source_file, "# New").unwrap();
913
914 let dest = dir.path().join(".claude/skills/my-skill.md");
915 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
916 std::fs::write(&dest, "# Old").unwrap();
917
918 let entry = Entry {
919 entity_type: EntityType::Skill,
920 name: "my-skill".into(),
921 source: SourceFields::Local {
922 path: "skills/my-skill.md".into(),
923 },
924 };
925 let a = adapters().get("claude-code").unwrap();
926 let result = a.deploy_entry(
927 &entry,
928 &source_file,
929 Scope::Local,
930 dir.path(),
931 &InstallOptions {
932 dry_run: false,
933 overwrite: false,
934 },
935 );
936 assert!(result.is_empty());
937 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old");
938 }
939
940 #[test]
943 fn installed_dir_files_flat_mode_returns_deployed_files() {
944 use skillfile_core::models::{EntityType, SourceFields};
945
946 let dir = tempfile::tempdir().unwrap();
947 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
949 std::fs::create_dir_all(&vdir).unwrap();
950 std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
951 std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
952 std::fs::write(vdir.join(".meta"), "{}").unwrap();
953
954 let target = dir.path().join(".claude/agents");
956 std::fs::create_dir_all(&target).unwrap();
957 std::fs::write(target.join("backend.md"), "# Backend").unwrap();
958 std::fs::write(target.join("frontend.md"), "# Frontend").unwrap();
959
960 let entry = Entry {
961 entity_type: EntityType::Agent,
962 name: "core-dev".into(),
963 source: SourceFields::Github {
964 owner_repo: "o/r".into(),
965 path_in_repo: "agents/core-dev".into(),
966 ref_: "main".into(),
967 },
968 };
969 let a = adapters().get("claude-code").unwrap();
970 let files = a.installed_dir_files(&entry, Scope::Local, dir.path());
971 assert!(files.contains_key("backend.md"));
972 assert!(files.contains_key("frontend.md"));
973 assert!(!files.contains_key(".meta"));
974 }
975
976 #[test]
977 fn installed_dir_files_flat_mode_no_vdir_returns_empty() {
978 use skillfile_core::models::{EntityType, SourceFields};
979
980 let dir = tempfile::tempdir().unwrap();
981 let entry = Entry {
983 entity_type: EntityType::Agent,
984 name: "core-dev".into(),
985 source: SourceFields::Github {
986 owner_repo: "o/r".into(),
987 path_in_repo: "agents/core-dev".into(),
988 ref_: "main".into(),
989 },
990 };
991 let a = adapters().get("claude-code").unwrap();
992 let files = a.installed_dir_files(&entry, Scope::Local, dir.path());
993 assert!(files.is_empty());
994 }
995
996 #[test]
997 fn installed_dir_files_flat_mode_skips_non_deployed_files() {
998 use skillfile_core::models::{EntityType, SourceFields};
999
1000 let dir = tempfile::tempdir().unwrap();
1001 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1002 std::fs::create_dir_all(&vdir).unwrap();
1003 std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1004 std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1005
1006 let target = dir.path().join(".claude/agents");
1008 std::fs::create_dir_all(&target).unwrap();
1009 std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1010 let entry = Entry {
1013 entity_type: EntityType::Agent,
1014 name: "core-dev".into(),
1015 source: SourceFields::Github {
1016 owner_repo: "o/r".into(),
1017 path_in_repo: "agents/core-dev".into(),
1018 ref_: "main".into(),
1019 },
1020 };
1021 let a = adapters().get("claude-code").unwrap();
1022 let files = a.installed_dir_files(&entry, Scope::Local, dir.path());
1023 assert!(files.contains_key("backend.md"));
1024 assert!(!files.contains_key("frontend.md"));
1025 }
1026
1027 #[test]
1028 fn deploy_entry_dir_keys_match_source_relative_paths() {
1029 use skillfile_core::models::{EntityType, SourceFields};
1030
1031 let dir = tempfile::tempdir().unwrap();
1032 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1033 std::fs::create_dir_all(&source_dir).unwrap();
1034 std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1035 std::fs::write(source_dir.join("examples.md"), "# Examples\n").unwrap();
1036
1037 let entry = Entry {
1038 entity_type: EntityType::Skill,
1039 name: "my-skill".into(),
1040 source: SourceFields::Github {
1041 owner_repo: "o/r".into(),
1042 path_in_repo: "skills/my-skill".into(),
1043 ref_: "main".into(),
1044 },
1045 };
1046 let a = adapters().get("claude-code").unwrap();
1047 let result = a.deploy_entry(
1048 &entry,
1049 &source_dir,
1050 Scope::Local,
1051 dir.path(),
1052 &InstallOptions::default(),
1053 );
1054 assert!(result.contains_key("SKILL.md"));
1055 assert!(result.contains_key("examples.md"));
1056 }
1057}