Skip to main content

skillfile_deploy/
adapter.rs

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// ---------------------------------------------------------------------------
12// PlatformAdapter trait — the core abstraction for tool-specific deployment
13// ---------------------------------------------------------------------------
14
15/// How a directory entry is deployed to a platform's target directory.
16///
17/// - `Flat`: each `.md` placed individually in `target_dir/` (e.g. claude-code agents)
18/// - `Nested`: directory placed as `target_dir/<name>/` (e.g. all skill adapters)
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DirInstallMode {
21    Flat,
22    Nested,
23}
24
25/// The deployment result: a map of `{patch_key: installed_path}`.
26///
27/// Keys match the relative paths used in `.skillfile/patches/` so patch lookups
28/// work correctly:
29/// - Single-file entries: key is `"{name}.md"`
30/// - Directory entries: keys are paths relative to the source directory
31pub type DeployResult = HashMap<String, PathBuf>;
32
33/// Contract for deploying skill/agent files to a specific AI tool's directory.
34///
35/// Each AI tool (Claude Code, Gemini CLI, Codex, etc.) has its own convention
36/// for where skills and agents live on disk. A `PlatformAdapter` encapsulates
37/// that knowledge.
38///
39/// The trait is object-safe so adapters can be stored in a heterogeneous registry.
40pub trait PlatformAdapter: Send + Sync + fmt::Debug {
41    /// The adapter identifier (e.g. `"claude-code"`, `"gemini-cli"`).
42    fn name(&self) -> &str;
43
44    /// Whether this platform supports the given entity type (e.g. `"skill"`, `"agent"`).
45    fn supports(&self, entity_type: &str) -> bool;
46
47    /// Resolve the absolute target directory for an entity type + scope.
48    fn target_dir(&self, entity_type: &str, scope: Scope, repo_root: &Path) -> PathBuf;
49
50    /// The install mode for directory entries of this entity type.
51    fn dir_mode(&self, entity_type: &str) -> Option<DirInstallMode>;
52
53    /// Deploy a single entry from `source` to its platform-specific location.
54    ///
55    /// Returns `{patch_key: installed_path}` for every file that was placed.
56    /// Returns an empty map for dry-run or when deployment is skipped.
57    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    /// The installed path for a single-file entry.
67    fn installed_path(&self, entry: &Entry, scope: Scope, repo_root: &Path) -> PathBuf;
68
69    /// Map of `{relative_path: absolute_path}` for all installed files of a directory entry.
70    fn installed_dir_files(
71        &self,
72        entry: &Entry,
73        scope: Scope,
74        repo_root: &Path,
75    ) -> HashMap<String, PathBuf>;
76}
77
78// ---------------------------------------------------------------------------
79// EntityConfig — per-entity-type path configuration
80// ---------------------------------------------------------------------------
81
82/// Paths and install mode for one entity type within a platform.
83#[derive(Debug, Clone)]
84pub struct EntityConfig {
85    pub global_path: String,
86    pub local_path: String,
87    pub dir_mode: DirInstallMode,
88}
89
90// ---------------------------------------------------------------------------
91// FileSystemAdapter — the concrete implementation of PlatformAdapter
92// ---------------------------------------------------------------------------
93
94/// Filesystem-based platform adapter.
95///
96/// Each instance is configured with a name and a map of `EntityConfig`s.
97/// All three built-in adapters (claude-code, gemini-cli, codex) are instances
98/// of this struct with different configurations — the `PlatformAdapter` trait
99/// allows alternative implementations if needed.
100#[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        // Use filesystem truth: source.is_dir() catches local directory entries
158        // that is_dir_entry() misses (it only inspects GitHub path_in_repo).
159        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            // Flat: keys are relative-from-vdir so they match patch lookup keys
228            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
253// ---------------------------------------------------------------------------
254// Deployment helpers (used by FileSystemAdapter)
255// ---------------------------------------------------------------------------
256
257/// Deploy each `.md` in `source_dir` as an individual file in `target_dir` (flat mode).
258fn 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
301/// Copy `source` to `dest`. Returns `true` if placed, `false` if skipped.
302fn 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    // Remove existing
328    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
346/// Recursively copy a directory tree.
347fn 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
362// ---------------------------------------------------------------------------
363// AdapterRegistry — injectable, testable collection of platform adapters
364// ---------------------------------------------------------------------------
365
366/// A collection of platform adapters, indexed by name.
367///
368/// The registry owns the adapters and provides lookup by name. It can be
369/// constructed with the built-in adapters via [`AdapterRegistry::builtin()`],
370/// or built manually for testing.
371pub struct AdapterRegistry {
372    adapters: HashMap<String, Box<dyn PlatformAdapter>>,
373}
374
375impl AdapterRegistry {
376    /// Create a registry from a vec of boxed adapters.
377    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    /// Create the built-in registry with all known platform adapters.
386    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    /// Look up an adapter by name.
399    pub fn get(&self, name: &str) -> Option<&dyn PlatformAdapter> {
400        self.adapters.get(name).map(|b| &**b)
401    }
402
403    /// Check if an adapter with this name exists.
404    pub fn contains(&self, name: &str) -> bool {
405        self.adapters.contains_key(name)
406    }
407
408    /// Sorted list of all adapter names.
409    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
424// ---------------------------------------------------------------------------
425// Built-in adapters
426// ---------------------------------------------------------------------------
427
428fn 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
490/// Cursor adapter.
491///
492/// Cursor reads skills from `.cursor/skills/<name>/SKILL.md` (nested) and
493/// agents from `.cursor/agents/<name>.md` (flat). Same pattern as Claude Code.
494fn 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
518/// Windsurf adapter.
519///
520/// Windsurf reads skills from `.windsurf/skills/<name>/SKILL.md` (nested).
521/// It does not support agent markdown files in a dedicated directory — agents
522/// are defined via `AGENTS.md` files scattered in the project tree instead.
523fn 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
537/// OpenCode adapter.
538///
539/// OpenCode reads skills from `.opencode/skills/<name>/SKILL.md` (nested) and
540/// agents from `.opencode/agents/<name>.md` (flat). Global paths follow XDG:
541/// `~/.config/opencode/`.
542fn 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
566/// GitHub Copilot adapter.
567///
568/// Copilot reads skills from `.github/skills/<name>/SKILL.md` (nested) and
569/// agents from `.github/agents/<name>.md` (flat). Note: Copilot natively
570/// expects `.agent.md` extension for agent files, but skillfile deploys
571/// standard `.md` files which Copilot also reads.
572fn 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// ---------------------------------------------------------------------------
597// Global registry accessor (backward-compatible convenience)
598// ---------------------------------------------------------------------------
599
600/// Get the global adapter registry (lazily initialized).
601#[must_use]
602pub fn adapters() -> &'static AdapterRegistry {
603    static REGISTRY: OnceLock<AdapterRegistry> = OnceLock::new();
604    REGISTRY.get_or_init(AdapterRegistry::builtin)
605}
606
607/// Sorted list of known adapter names.
608#[must_use]
609pub fn known_adapters() -> Vec<&'static str> {
610    adapters().names()
611}
612
613// ---------------------------------------------------------------------------
614// Tests
615// ---------------------------------------------------------------------------
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    // -- Trait compliance: every registered adapter satisfies PlatformAdapter --
622
623    #[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    // -- supports() --
663
664    #[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    // -- target_dir() --
687
688    #[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    // -- supports() for new adapters --
751
752    #[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    // -- target_dir() for new adapters --
784
785    #[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    // -- dir_mode for new adapters --
888
889    #[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    // -- dir_mode --
918
919    #[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    // -- Custom adapter extensibility --
940
941    #[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    // -- deploy_entry key contract --
962
963    #[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    // -- deploy_flat --
998
999    #[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        // Set up vendor cache dir with .md files and a .meta
1005        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        // Flat mode: keys are relative paths from source dir
1032        assert!(result.contains_key("backend.md"));
1033        assert!(result.contains_key("frontend.md"));
1034        assert!(!result.contains_key(".meta"));
1035        // Files actually exist
1036        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        // Pre-create the target file
1084        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        // Should skip the existing file
1109        assert!(result.is_empty());
1110        // Original content preserved
1111        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    // -- place_file skip logic --
1158
1159    #[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        // Pre-create the destination dir
1169        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        // Should skip — dir already exists
1194        assert!(result.is_empty());
1195        // Old file still there
1196        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    // -- installed_dir_files flat mode --
1235
1236    #[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        // Set up vendor cache dir
1242        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        // Set up installed flat files
1249        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        // No vendor cache dir
1276        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        // Only deploy one file
1301        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        // frontend.md NOT deployed
1305
1306        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}