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