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::{EntityType, 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
33pub struct AdapterScope<'a> {
34    pub scope: Scope,
35    pub repo_root: &'a Path,
36}
37
38pub struct DeployRequest<'a> {
39    pub entry: &'a Entry,
40    pub source: &'a Path,
41    pub scope: Scope,
42    pub repo_root: &'a Path,
43    pub opts: &'a InstallOptions,
44}
45
46/// Contract for deploying skill/agent files to a specific AI tool's directory.
47///
48/// Each AI tool (Claude Code, Gemini CLI, Codex, etc.) has its own convention
49/// for where skills and agents live on disk. A `PlatformAdapter` encapsulates
50/// that knowledge.
51///
52/// The trait is object-safe so adapters can be stored in a heterogeneous registry.
53pub trait PlatformAdapter: Send + Sync + fmt::Debug {
54    fn name(&self) -> &str;
55
56    fn supports(&self, entity_type: EntityType) -> bool;
57
58    fn target_dir(&self, entity_type: EntityType, ctx: &AdapterScope<'_>) -> PathBuf;
59
60    fn dir_mode(&self, entity_type: EntityType) -> Option<DirInstallMode>;
61
62    /// Returns `{patch_key: installed_path}` for every file that was placed.
63    /// Returns an empty map for dry-run or when deployment is skipped.
64    fn deploy_entry(&self, req: &DeployRequest<'_>) -> DeployResult;
65
66    fn installed_path(&self, entry: &Entry, ctx: &AdapterScope<'_>) -> PathBuf;
67
68    fn installed_dir_files(
69        &self,
70        entry: &Entry,
71        ctx: &AdapterScope<'_>,
72    ) -> HashMap<String, PathBuf>;
73}
74
75// ---------------------------------------------------------------------------
76// EntityConfig — per-entity-type path configuration
77// ---------------------------------------------------------------------------
78
79#[derive(Debug, Clone)]
80pub struct EntityConfig {
81    pub global_path: String,
82    pub local_path: String,
83    pub dir_mode: DirInstallMode,
84}
85
86// ---------------------------------------------------------------------------
87// FileSystemAdapter — the concrete implementation of PlatformAdapter
88// ---------------------------------------------------------------------------
89
90/// Each instance is configured with a name and a map of `EntityConfig`s.
91/// All built-in adapters (claude-code, factory, gemini-cli, etc.) are instances
92/// of this struct with different configurations — the `PlatformAdapter` trait
93/// allows alternative implementations if needed.
94#[derive(Debug, Clone)]
95pub struct FileSystemAdapter {
96    name: String,
97    entities: HashMap<EntityType, EntityConfig>,
98}
99
100impl FileSystemAdapter {
101    pub fn new(name: &str, entities: HashMap<EntityType, EntityConfig>) -> Self {
102        Self {
103            name: name.to_string(),
104            entities,
105        }
106    }
107
108    /// Returns true if the entity type uses flat file layout (e.g., agents).
109    fn is_flat_mode(&self, entity_type: EntityType) -> bool {
110        self.entities
111            .get(&entity_type)
112            .is_some_and(|c| c.dir_mode == DirInstallMode::Flat)
113    }
114
115    fn single_file_install_path(&self, entry: &Entry, target_dir: &Path) -> PathBuf {
116        if self.is_flat_mode(entry.entity_type) {
117            target_dir.join(format!("{}.md", entry.name))
118        } else {
119            target_dir.join(&entry.name).join("SKILL.md")
120        }
121    }
122}
123
124fn preferred_home_dir_from(
125    home_override: Option<std::ffi::OsString>,
126    fallback: Option<PathBuf>,
127) -> PathBuf {
128    home_override
129        .filter(|path| !path.is_empty())
130        .map(PathBuf::from)
131        .or(fallback)
132        .unwrap_or_else(|| PathBuf::from("/"))
133}
134
135fn preferred_home_dir() -> PathBuf {
136    preferred_home_dir_from(std::env::var_os("HOME"), dirs::home_dir())
137}
138
139impl PlatformAdapter for FileSystemAdapter {
140    fn name(&self) -> &str {
141        &self.name
142    }
143
144    fn supports(&self, entity_type: EntityType) -> bool {
145        self.entities.contains_key(&entity_type)
146    }
147
148    fn target_dir(&self, entity_type: EntityType, ctx: &AdapterScope<'_>) -> PathBuf {
149        let config = self.entities.get(&entity_type).unwrap_or_else(|| {
150            panic!(
151                "BUG: target_dir called for unsupported entity type '{entity_type}' on adapter '{}'. \
152                 Call supports() first.",
153                self.name
154            )
155        });
156        let raw = match ctx.scope {
157            Scope::Global => &config.global_path,
158            Scope::Local => &config.local_path,
159        };
160        if raw.starts_with('~') {
161            let home = preferred_home_dir();
162            home.join(raw.strip_prefix("~/").unwrap_or(raw))
163        } else {
164            ctx.repo_root.join(raw)
165        }
166    }
167
168    fn dir_mode(&self, entity_type: EntityType) -> Option<DirInstallMode> {
169        self.entities.get(&entity_type).map(|c| c.dir_mode)
170    }
171
172    fn deploy_entry(&self, req: &DeployRequest<'_>) -> DeployResult {
173        let ctx = AdapterScope {
174            scope: req.scope,
175            repo_root: req.repo_root,
176        };
177        let target_dir = self.target_dir(req.entry.entity_type, &ctx);
178        // Use filesystem truth as a backstop for ambiguous manifest paths.
179        let is_dir = is_dir_entry(req.entry) || req.source.is_dir();
180
181        if is_dir
182            && self
183                .entities
184                .get(&req.entry.entity_type)
185                .is_some_and(|c| c.dir_mode == DirInstallMode::Flat)
186        {
187            return deploy_flat(req.source, &target_dir, req.opts);
188        }
189
190        let dest = if is_dir {
191            target_dir.join(&req.entry.name)
192        } else {
193            self.single_file_install_path(req.entry, &target_dir)
194        };
195
196        if !place_file(
197            &PlaceOp {
198                source: req.source,
199                dest: &dest,
200                is_dir,
201            },
202            req.opts,
203        ) || req.opts.dry_run
204        {
205            return HashMap::new();
206        }
207
208        // Migration: nested-mode installs may leave behind a legacy flat {name}.md from an older
209        // layout. Remove it after a successful install to avoid duplicate skill loading.
210        if !self.is_flat_mode(req.entry.entity_type) {
211            remove_orphan_flat_file(&req.entry.name, &target_dir);
212        }
213
214        if is_dir {
215            collect_dir_deploy_result(req.source, &dest)
216        } else {
217            let entry_name = &req.entry.name;
218            HashMap::from([(format!("{entry_name}.md"), dest)])
219        }
220    }
221
222    fn installed_path(&self, entry: &Entry, ctx: &AdapterScope<'_>) -> PathBuf {
223        let target_dir = self.target_dir(entry.entity_type, ctx);
224        self.single_file_install_path(entry, &target_dir)
225    }
226
227    fn installed_dir_files(
228        &self,
229        entry: &Entry,
230        ctx: &AdapterScope<'_>,
231    ) -> HashMap<String, PathBuf> {
232        let target_dir = self.target_dir(entry.entity_type, ctx);
233        let mode = self
234            .entities
235            .get(&entry.entity_type)
236            .map_or(DirInstallMode::Nested, |c| c.dir_mode);
237
238        if mode == DirInstallMode::Nested {
239            collect_nested_installed(entry, &target_dir)
240        } else {
241            // Flat: keys are relative-from-vdir so they match patch lookup keys
242            let vdir = skillfile_sources::sync::vendor_dir_for(entry, ctx.repo_root);
243            collect_flat_installed_checked(&vdir, &target_dir)
244        }
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Deployment helpers (used by FileSystemAdapter)
250// ---------------------------------------------------------------------------
251
252/// Remove legacy flat {name}.md file if it exists, warning on failure.
253/// Used during migration to prevent duplicate skill loading.
254fn remove_orphan_flat_file(entry_name: &str, target_dir: &Path) {
255    let orphan = target_dir.join(format!("{entry_name}.md"));
256    if orphan.is_file() && std::fs::remove_file(&orphan).is_err() {
257        eprintln!("warning: failed to remove {}", orphan.display());
258    }
259}
260
261/// Convert a [`Path`] to a forward-slash string for use as patch/deploy keys.
262///
263/// On Unix this is a no-op. On Windows, `\` separators become `/` so that
264/// patch keys are portable across platforms.
265fn forward_slash(path: &Path) -> String {
266    path.to_string_lossy().replace('\\', "/")
267}
268
269fn collect_dir_deploy_result(source: &Path, dest: &Path) -> DeployResult {
270    let mut result = HashMap::new();
271    for file in walkdir(source) {
272        if file.file_name().is_none_or(|n| n == ".meta") {
273            continue;
274        }
275        let Ok(rel) = file.strip_prefix(source) else {
276            continue;
277        };
278        result.insert(forward_slash(rel), dest.join(rel));
279    }
280    result
281}
282
283/// Returns empty map when the installed directory does not exist.
284fn collect_nested_installed(entry: &Entry, target_dir: &Path) -> HashMap<String, PathBuf> {
285    let installed_dir = target_dir.join(&entry.name);
286    if !installed_dir.is_dir() {
287        return HashMap::new();
288    }
289    collect_walkdir_relative(&installed_dir)
290}
291
292/// Returns empty map when the vendor cache directory does not exist.
293fn collect_flat_installed_checked(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
294    if !vdir.is_dir() {
295        return HashMap::new();
296    }
297    collect_flat_installed(vdir, target_dir)
298}
299
300fn collect_walkdir_relative(base: &Path) -> HashMap<String, PathBuf> {
301    let mut result = HashMap::new();
302    for file in walkdir(base) {
303        let Ok(rel) = file.strip_prefix(base) else {
304            continue;
305        };
306        result.insert(forward_slash(rel), file);
307    }
308    result
309}
310
311fn collect_flat_installed(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
312    let mut result = HashMap::new();
313    for file in walkdir(vdir) {
314        if file
315            .extension()
316            .is_none_or(|ext| ext.to_string_lossy() != "md")
317        {
318            continue;
319        }
320        let Ok(rel) = file.strip_prefix(vdir) else {
321            continue;
322        };
323        let dest = target_dir.join(file.file_name().unwrap_or_default());
324        if dest.exists() {
325            result.insert(forward_slash(rel), dest);
326        }
327    }
328    result
329}
330
331fn deploy_flat(source_dir: &Path, target_dir: &Path, opts: &InstallOptions) -> DeployResult {
332    let mut md_files: Vec<PathBuf> = walkdir(source_dir)
333        .into_iter()
334        .filter(|f| f.extension().is_some_and(|ext| ext == "md"))
335        .collect();
336    md_files.sort();
337
338    if opts.dry_run {
339        for src in md_files.iter().filter(|s| s.file_name().is_some()) {
340            let name = src.file_name().unwrap_or_default();
341            progress!(
342                "  {} -> {} [copy, dry-run]",
343                name.to_string_lossy(),
344                target_dir.join(name).display()
345            );
346        }
347        return HashMap::new();
348    }
349
350    if std::fs::create_dir_all(target_dir).is_err() {
351        return HashMap::new();
352    }
353    let mut result = HashMap::new();
354    for src in &md_files {
355        let Some(name) = src.file_name() else {
356            continue;
357        };
358        let dest = target_dir.join(name);
359        if !place_file(
360            &PlaceOp {
361                source: src,
362                dest: &dest,
363                is_dir: false,
364            },
365            opts,
366        ) {
367            continue;
368        }
369        if let Ok(rel) = src.strip_prefix(source_dir) {
370            result.insert(forward_slash(rel), dest);
371        }
372    }
373    result
374}
375
376struct PlaceOp<'a> {
377    source: &'a Path,
378    dest: &'a Path,
379    is_dir: bool,
380}
381
382fn remove_existing_path(path: &Path) -> std::io::Result<()> {
383    if !(path.exists() || path.is_symlink()) {
384        return Ok(());
385    }
386    if path.is_dir() {
387        std::fs::remove_dir_all(path)
388    } else {
389        std::fs::remove_file(path)
390    }
391}
392
393fn cleanup_failed_path(path: &Path) {
394    let _ = remove_existing_path(path);
395}
396
397fn copy_to_destination(op: &PlaceOp<'_>) -> std::io::Result<()> {
398    if let Some(parent) = op.dest.parent() {
399        std::fs::create_dir_all(parent)?;
400    }
401
402    remove_existing_path(op.dest)?;
403    if op.is_dir {
404        copy_dir_recursive(op.source, op.dest)
405    } else {
406        std::fs::copy(op.source, op.dest).map(|_| ())
407    }
408}
409
410fn place_file(op: &PlaceOp<'_>, opts: &InstallOptions) -> bool {
411    if !opts.overwrite && !opts.dry_run && (op.dest.exists() || op.dest.is_symlink()) {
412        return false;
413    }
414
415    let label = format!(
416        "  {} -> {}",
417        op.source.file_name().unwrap_or_default().to_string_lossy(),
418        op.dest.display()
419    );
420
421    if opts.dry_run {
422        progress!("{label} [copy, dry-run]");
423        return true;
424    }
425
426    if copy_to_destination(op).is_err() {
427        cleanup_failed_path(op.dest);
428        return false;
429    }
430
431    progress!("{label}");
432    true
433}
434
435/// Recursively copy a directory tree.
436// The recursive structure naturally produces multiple `?` operators and
437// branching that triggers cognitive-complexity, but the logic is straightforward.
438#[allow(clippy::cognitive_complexity)]
439fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
440    std::fs::create_dir_all(dst)?;
441    for entry in std::fs::read_dir(src)? {
442        let entry = entry?;
443        let ty = entry.file_type()?;
444        let dest_path = dst.join(entry.file_name());
445        if ty.is_dir() {
446            copy_dir_recursive(&entry.path(), &dest_path)?;
447        } else {
448            std::fs::copy(entry.path(), &dest_path)?;
449        }
450    }
451    Ok(())
452}
453
454// ---------------------------------------------------------------------------
455// AdapterRegistry — injectable, testable collection of platform adapters
456// ---------------------------------------------------------------------------
457
458/// A collection of platform adapters, indexed by name.
459///
460/// The registry owns the adapters and provides lookup by name. It can be
461/// constructed with the built-in adapters via [`AdapterRegistry::builtin()`],
462/// or built manually for testing.
463pub struct AdapterRegistry {
464    adapters: HashMap<String, Box<dyn PlatformAdapter>>,
465}
466
467impl AdapterRegistry {
468    pub fn new(adapters: Vec<Box<dyn PlatformAdapter>>) -> Self {
469        let map = adapters
470            .into_iter()
471            .map(|a| (a.name().to_string(), a))
472            .collect();
473        Self { adapters: map }
474    }
475
476    pub fn builtin() -> Self {
477        Self::new(
478            BUILTIN_ADAPTERS
479                .iter()
480                .map(|spec| Box::new(build_adapter(spec)) as Box<dyn PlatformAdapter>)
481                .collect(),
482        )
483    }
484
485    pub fn get(&self, name: &str) -> Option<&dyn PlatformAdapter> {
486        self.adapters.get(name).map(|b| &**b)
487    }
488
489    pub fn contains(&self, name: &str) -> bool {
490        self.adapters.contains_key(name)
491    }
492
493    pub fn names(&self) -> Vec<&str> {
494        let mut names: Vec<&str> = self.adapters.keys().map(String::as_str).collect();
495        names.sort_unstable();
496        names
497    }
498}
499
500impl fmt::Debug for AdapterRegistry {
501    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502        f.debug_struct("AdapterRegistry")
503            .field("adapters", &self.names())
504            .finish()
505    }
506}
507
508// ---------------------------------------------------------------------------
509// Built-in adapter specifications — declarative configuration table
510// ---------------------------------------------------------------------------
511
512struct EntitySpec {
513    entity_type: EntityType,
514    global_path: &'static str,
515    local_path: &'static str,
516    dir_mode: DirInstallMode,
517}
518
519/// Adding a new platform is one table entry.
520struct AdapterSpec {
521    name: &'static str,
522    entities: &'static [EntitySpec],
523}
524
525/// All built-in platform adapters.
526///
527/// | Platform    | Skills | Agents (Flat) | Global prefix              | Local prefix     |
528/// |-------------|--------|---------------|----------------------------|------------------|
529/// | claude-code | yes    | yes           | `~/.claude/`               | `.claude/`       |
530/// | factory     | yes    | yes           | `~/.factory/`              | `.factory/`      |
531/// | gemini-cli  | yes    | yes           | `~/.gemini/`               | `.gemini/`       |
532/// | codex       | yes    | no            | `~/.codex/`                | `.codex/`        |
533/// | cursor      | yes    | yes           | `~/.cursor/`               | `.cursor/`       |
534/// | windsurf    | yes    | no            | `~/.codeium/windsurf/`     | `.windsurf/`     |
535/// | opencode    | yes    | yes           | `~/.config/opencode/`      | `.opencode/`     |
536/// | copilot     | yes    | yes           | `~/.copilot/`              | `.github/`       |
537/// | junie       | yes    | yes           | `~/.junie/`                | `.junie/`        |
538/// | antigravity | yes    | no            | `~/.gemini/antigravity/`   | `.agents/`       |
539const BUILTIN_ADAPTERS: &[AdapterSpec] = &[
540    AdapterSpec {
541        name: "claude-code",
542        entities: &[
543            EntitySpec {
544                entity_type: EntityType::Skill,
545                global_path: "~/.claude/skills",
546                local_path: ".claude/skills",
547                dir_mode: DirInstallMode::Nested,
548            },
549            EntitySpec {
550                entity_type: EntityType::Agent,
551                global_path: "~/.claude/agents",
552                local_path: ".claude/agents",
553                dir_mode: DirInstallMode::Flat,
554            },
555        ],
556    },
557    AdapterSpec {
558        name: "factory",
559        entities: &[
560            EntitySpec {
561                entity_type: EntityType::Skill,
562                global_path: "~/.factory/skills",
563                local_path: ".factory/skills",
564                dir_mode: DirInstallMode::Nested,
565            },
566            EntitySpec {
567                entity_type: EntityType::Agent,
568                global_path: "~/.factory/droids",
569                local_path: ".factory/droids",
570                dir_mode: DirInstallMode::Flat,
571            },
572        ],
573    },
574    AdapterSpec {
575        name: "gemini-cli",
576        entities: &[
577            EntitySpec {
578                entity_type: EntityType::Skill,
579                global_path: "~/.gemini/skills",
580                local_path: ".gemini/skills",
581                dir_mode: DirInstallMode::Nested,
582            },
583            EntitySpec {
584                entity_type: EntityType::Agent,
585                global_path: "~/.gemini/agents",
586                local_path: ".gemini/agents",
587                dir_mode: DirInstallMode::Flat,
588            },
589        ],
590    },
591    AdapterSpec {
592        name: "codex",
593        entities: &[EntitySpec {
594            entity_type: EntityType::Skill,
595            global_path: "~/.codex/skills",
596            local_path: ".codex/skills",
597            dir_mode: DirInstallMode::Nested,
598        }],
599    },
600    AdapterSpec {
601        name: "cursor",
602        entities: &[
603            EntitySpec {
604                entity_type: EntityType::Skill,
605                global_path: "~/.cursor/skills",
606                local_path: ".cursor/skills",
607                dir_mode: DirInstallMode::Nested,
608            },
609            EntitySpec {
610                entity_type: EntityType::Agent,
611                global_path: "~/.cursor/agents",
612                local_path: ".cursor/agents",
613                dir_mode: DirInstallMode::Flat,
614            },
615        ],
616    },
617    AdapterSpec {
618        name: "windsurf",
619        entities: &[EntitySpec {
620            entity_type: EntityType::Skill,
621            global_path: "~/.codeium/windsurf/skills",
622            local_path: ".windsurf/skills",
623            dir_mode: DirInstallMode::Nested,
624        }],
625    },
626    AdapterSpec {
627        name: "opencode",
628        entities: &[
629            EntitySpec {
630                entity_type: EntityType::Skill,
631                global_path: "~/.config/opencode/skills",
632                local_path: ".opencode/skills",
633                dir_mode: DirInstallMode::Nested,
634            },
635            EntitySpec {
636                entity_type: EntityType::Agent,
637                global_path: "~/.config/opencode/agents",
638                local_path: ".opencode/agents",
639                dir_mode: DirInstallMode::Flat,
640            },
641        ],
642    },
643    AdapterSpec {
644        name: "copilot",
645        entities: &[
646            EntitySpec {
647                entity_type: EntityType::Skill,
648                global_path: "~/.copilot/skills",
649                local_path: ".github/skills",
650                dir_mode: DirInstallMode::Nested,
651            },
652            EntitySpec {
653                entity_type: EntityType::Agent,
654                global_path: "~/.copilot/agents",
655                local_path: ".github/agents",
656                dir_mode: DirInstallMode::Flat,
657            },
658        ],
659    },
660    AdapterSpec {
661        name: "junie",
662        entities: &[
663            EntitySpec {
664                entity_type: EntityType::Skill,
665                global_path: "~/.junie/skills",
666                local_path: ".junie/skills",
667                dir_mode: DirInstallMode::Nested,
668            },
669            EntitySpec {
670                entity_type: EntityType::Agent,
671                global_path: "~/.junie/agents",
672                local_path: ".junie/agents",
673                dir_mode: DirInstallMode::Flat,
674            },
675        ],
676    },
677    AdapterSpec {
678        name: "antigravity",
679        entities: &[EntitySpec {
680            entity_type: EntityType::Skill,
681            global_path: "~/.gemini/antigravity/skills",
682            local_path: ".agents/skills",
683            dir_mode: DirInstallMode::Nested,
684        }],
685    },
686];
687
688fn build_adapter(spec: &AdapterSpec) -> FileSystemAdapter {
689    let entities = spec
690        .entities
691        .iter()
692        .map(|e| {
693            (
694                e.entity_type,
695                EntityConfig {
696                    global_path: e.global_path.into(),
697                    local_path: e.local_path.into(),
698                    dir_mode: e.dir_mode,
699                },
700            )
701        })
702        .collect();
703    FileSystemAdapter::new(spec.name, entities)
704}
705
706// ---------------------------------------------------------------------------
707// Global registry accessor (backward-compatible convenience)
708// ---------------------------------------------------------------------------
709
710#[must_use]
711pub fn adapters() -> &'static AdapterRegistry {
712    static REGISTRY: OnceLock<AdapterRegistry> = OnceLock::new();
713    REGISTRY.get_or_init(AdapterRegistry::builtin)
714}
715
716#[must_use]
717pub fn known_adapters() -> Vec<&'static str> {
718    adapters().names()
719}
720
721// ---------------------------------------------------------------------------
722// Tests
723// ---------------------------------------------------------------------------
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    fn local(root: &Path) -> AdapterScope<'_> {
730        AdapterScope {
731            scope: Scope::Local,
732            repo_root: root,
733        }
734    }
735
736    fn global(root: &Path) -> AdapterScope<'_> {
737        AdapterScope {
738            scope: Scope::Global,
739            repo_root: root,
740        }
741    }
742
743    // -- Trait compliance: every registered adapter satisfies PlatformAdapter --
744
745    #[test]
746    fn all_builtin_adapters_in_registry() {
747        let reg = adapters();
748        for spec in BUILTIN_ADAPTERS {
749            assert!(reg.contains(spec.name), "missing adapter: {}", spec.name);
750        }
751    }
752
753    #[test]
754    fn known_adapters_contains_all() {
755        let names = known_adapters();
756        for spec in BUILTIN_ADAPTERS {
757            assert!(names.contains(&spec.name), "missing adapter: {}", spec.name);
758        }
759        assert_eq!(names.len(), BUILTIN_ADAPTERS.len());
760    }
761
762    #[test]
763    fn adapter_name_matches_registry_key() {
764        let reg = adapters();
765        for name in reg.names() {
766            let adapter = reg.get(name).unwrap();
767            assert_eq!(adapter.name(), name);
768        }
769    }
770
771    #[test]
772    fn registry_get_unknown_returns_none() {
773        assert!(adapters().get("unknown-tool").is_none());
774    }
775
776    // -- supports() --
777
778    #[test]
779    fn claude_code_supports_agent_and_skill() {
780        let a = adapters().get("claude-code").unwrap();
781        assert!(a.supports(EntityType::Agent));
782        assert!(a.supports(EntityType::Skill));
783        // No need to test unsupported string types — `EntityType` makes invalid calls unrepresentable.
784    }
785
786    #[test]
787    fn factory_supports_agent_and_skill() {
788        let a = adapters().get("factory").unwrap();
789        assert!(a.supports(EntityType::Agent));
790        assert!(a.supports(EntityType::Skill));
791    }
792
793    #[test]
794    fn gemini_cli_supports_agent_and_skill() {
795        let a = adapters().get("gemini-cli").unwrap();
796        assert!(a.supports(EntityType::Agent));
797        assert!(a.supports(EntityType::Skill));
798    }
799
800    #[test]
801    fn codex_supports_skill_not_agent() {
802        let a = adapters().get("codex").unwrap();
803        assert!(a.supports(EntityType::Skill));
804        assert!(!a.supports(EntityType::Agent));
805    }
806
807    // -- target_dir() --
808
809    #[test]
810    fn local_target_dir_claude_code() {
811        let tmp = PathBuf::from("/tmp/test");
812        let a = adapters().get("claude-code").unwrap();
813        assert_eq!(
814            a.target_dir(EntityType::Agent, &local(&tmp)),
815            tmp.join(".claude/agents")
816        );
817        assert_eq!(
818            a.target_dir(EntityType::Skill, &local(&tmp)),
819            tmp.join(".claude/skills")
820        );
821    }
822
823    #[test]
824    fn local_target_dir_factory() {
825        let tmp = PathBuf::from("/tmp/test");
826        let a = adapters().get("factory").unwrap();
827        assert_eq!(
828            a.target_dir(EntityType::Agent, &local(&tmp)),
829            tmp.join(".factory/droids")
830        );
831        assert_eq!(
832            a.target_dir(EntityType::Skill, &local(&tmp)),
833            tmp.join(".factory/skills")
834        );
835    }
836
837    #[test]
838    fn local_target_dir_gemini_cli() {
839        let tmp = PathBuf::from("/tmp/test");
840        let a = adapters().get("gemini-cli").unwrap();
841        assert_eq!(
842            a.target_dir(EntityType::Agent, &local(&tmp)),
843            tmp.join(".gemini/agents")
844        );
845        assert_eq!(
846            a.target_dir(EntityType::Skill, &local(&tmp)),
847            tmp.join(".gemini/skills")
848        );
849    }
850
851    #[test]
852    fn local_target_dir_codex() {
853        let tmp = PathBuf::from("/tmp/test");
854        let a = adapters().get("codex").unwrap();
855        assert_eq!(
856            a.target_dir(EntityType::Skill, &local(&tmp)),
857            tmp.join(".codex/skills")
858        );
859    }
860
861    #[test]
862    fn global_target_dir_is_absolute() {
863        let a = adapters().get("claude-code").unwrap();
864        let result = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
865        assert!(result.is_absolute());
866        assert!(result.to_string_lossy().ends_with(".claude/agents"));
867    }
868
869    #[test]
870    fn global_target_dir_gemini_cli_skill() {
871        let a = adapters().get("gemini-cli").unwrap();
872        let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
873        assert!(result.is_absolute());
874        assert!(result.to_string_lossy().ends_with(".gemini/skills"));
875    }
876
877    #[test]
878    fn global_target_dir_codex_skill() {
879        let a = adapters().get("codex").unwrap();
880        let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
881        assert!(result.is_absolute());
882        assert!(result.to_string_lossy().ends_with(".codex/skills"));
883    }
884
885    // -- supports() for new adapters --
886
887    #[test]
888    fn cursor_supports_agent_and_skill() {
889        let a = adapters().get("cursor").unwrap();
890        assert!(a.supports(EntityType::Agent));
891        assert!(a.supports(EntityType::Skill));
892        // No need to test unsupported string types — `EntityType` makes invalid calls unrepresentable.
893    }
894
895    #[test]
896    fn windsurf_supports_skill_not_agent() {
897        let a = adapters().get("windsurf").unwrap();
898        assert!(a.supports(EntityType::Skill));
899        assert!(!a.supports(EntityType::Agent));
900    }
901
902    #[test]
903    fn opencode_supports_agent_and_skill() {
904        let a = adapters().get("opencode").unwrap();
905        assert!(a.supports(EntityType::Agent));
906        assert!(a.supports(EntityType::Skill));
907        // No need to test unsupported string types — `EntityType` makes invalid calls unrepresentable.
908    }
909
910    #[test]
911    fn copilot_supports_agent_and_skill() {
912        let a = adapters().get("copilot").unwrap();
913        assert!(a.supports(EntityType::Agent));
914        assert!(a.supports(EntityType::Skill));
915        // No need to test unsupported string types — `EntityType` makes invalid calls unrepresentable.
916    }
917
918    // -- target_dir() for new adapters --
919
920    #[test]
921    fn local_target_dir_cursor() {
922        let tmp = PathBuf::from("/tmp/test");
923        let a = adapters().get("cursor").unwrap();
924        assert_eq!(
925            a.target_dir(EntityType::Agent, &local(&tmp)),
926            tmp.join(".cursor/agents")
927        );
928        assert_eq!(
929            a.target_dir(EntityType::Skill, &local(&tmp)),
930            tmp.join(".cursor/skills")
931        );
932    }
933
934    #[test]
935    fn local_target_dir_windsurf() {
936        let tmp = PathBuf::from("/tmp/test");
937        let a = adapters().get("windsurf").unwrap();
938        assert_eq!(
939            a.target_dir(EntityType::Skill, &local(&tmp)),
940            tmp.join(".windsurf/skills")
941        );
942    }
943
944    #[test]
945    fn local_target_dir_opencode() {
946        let tmp = PathBuf::from("/tmp/test");
947        let a = adapters().get("opencode").unwrap();
948        assert_eq!(
949            a.target_dir(EntityType::Agent, &local(&tmp)),
950            tmp.join(".opencode/agents")
951        );
952        assert_eq!(
953            a.target_dir(EntityType::Skill, &local(&tmp)),
954            tmp.join(".opencode/skills")
955        );
956    }
957
958    #[test]
959    fn local_target_dir_copilot() {
960        let tmp = PathBuf::from("/tmp/test");
961        let a = adapters().get("copilot").unwrap();
962        assert_eq!(
963            a.target_dir(EntityType::Agent, &local(&tmp)),
964            tmp.join(".github/agents")
965        );
966        assert_eq!(
967            a.target_dir(EntityType::Skill, &local(&tmp)),
968            tmp.join(".github/skills")
969        );
970    }
971
972    #[test]
973    fn global_target_dir_cursor() {
974        let a = adapters().get("cursor").unwrap();
975        let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
976        assert!(skill.is_absolute());
977        assert!(skill.to_string_lossy().ends_with(".cursor/skills"));
978        let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
979        assert!(agent.is_absolute());
980        assert!(agent.to_string_lossy().ends_with(".cursor/agents"));
981    }
982
983    #[test]
984    fn global_target_dir_windsurf() {
985        let a = adapters().get("windsurf").unwrap();
986        let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
987        assert!(result.is_absolute());
988        assert!(
989            result.to_string_lossy().ends_with("windsurf/skills"),
990            "unexpected: {result:?}"
991        );
992    }
993
994    #[test]
995    fn global_target_dir_opencode() {
996        let a = adapters().get("opencode").unwrap();
997        let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
998        assert!(skill.is_absolute());
999        assert!(
1000            skill.to_string_lossy().ends_with("opencode/skills"),
1001            "unexpected: {skill:?}"
1002        );
1003        let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
1004        assert!(agent.is_absolute());
1005        assert!(
1006            agent.to_string_lossy().ends_with("opencode/agents"),
1007            "unexpected: {agent:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn global_target_dir_copilot() {
1013        let a = adapters().get("copilot").unwrap();
1014        let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
1015        assert!(skill.is_absolute());
1016        assert!(skill.to_string_lossy().ends_with(".copilot/skills"));
1017        let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
1018        assert!(agent.is_absolute());
1019        assert!(agent.to_string_lossy().ends_with(".copilot/agents"));
1020    }
1021
1022    // -- dir_mode for new adapters --
1023
1024    #[test]
1025    fn cursor_dir_modes() {
1026        let a = adapters().get("cursor").unwrap();
1027        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1028        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1029    }
1030
1031    #[test]
1032    fn windsurf_dir_mode() {
1033        let a = adapters().get("windsurf").unwrap();
1034        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1035        assert_eq!(a.dir_mode(EntityType::Agent), None);
1036    }
1037
1038    #[test]
1039    fn opencode_dir_modes() {
1040        let a = adapters().get("opencode").unwrap();
1041        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1042        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1043    }
1044
1045    #[test]
1046    fn copilot_dir_modes() {
1047        let a = adapters().get("copilot").unwrap();
1048        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1049        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1050    }
1051
1052    // -- dir_mode --
1053
1054    #[test]
1055    fn claude_code_dir_modes() {
1056        let a = adapters().get("claude-code").unwrap();
1057        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1058        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1059    }
1060
1061    #[test]
1062    fn gemini_cli_dir_modes() {
1063        let a = adapters().get("gemini-cli").unwrap();
1064        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1065        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1066    }
1067
1068    #[test]
1069    fn codex_dir_mode() {
1070        let a = adapters().get("codex").unwrap();
1071        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1072    }
1073
1074    // -- Custom adapter extensibility --
1075
1076    #[test]
1077    fn custom_adapter_via_registry() {
1078        let custom = FileSystemAdapter::new(
1079            "my-tool",
1080            HashMap::from([(
1081                EntityType::Skill,
1082                EntityConfig {
1083                    global_path: "~/.my-tool/skills".into(),
1084                    local_path: ".my-tool/skills".into(),
1085                    dir_mode: DirInstallMode::Nested,
1086                },
1087            )]),
1088        );
1089        let registry = AdapterRegistry::new(vec![Box::new(custom)]);
1090        let a = registry.get("my-tool").unwrap();
1091        assert!(a.supports(EntityType::Skill));
1092        assert!(!a.supports(EntityType::Agent));
1093        assert_eq!(registry.names(), vec!["my-tool"]);
1094    }
1095
1096    // -- deploy_entry key contract --
1097
1098    #[test]
1099    fn deploy_entry_single_file_key_matches_patch_convention() {
1100        use skillfile_core::models::{EntityType, SourceFields};
1101
1102        let dir = tempfile::tempdir().unwrap();
1103        let source_dir = dir.path().join(".skillfile/cache/agents/test");
1104        std::fs::create_dir_all(&source_dir).unwrap();
1105        std::fs::write(source_dir.join("agent.md"), "# Agent\n").unwrap();
1106        let source = source_dir.join("agent.md");
1107
1108        let entry = Entry {
1109            entity_type: EntityType::Agent,
1110            name: "test".into(),
1111            source: SourceFields::Github {
1112                owner_repo: "o/r".into(),
1113                path_in_repo: "agents/agent.md".into(),
1114                ref_: "main".into(),
1115            },
1116        };
1117        let a = adapters().get("claude-code").unwrap();
1118        let result = a.deploy_entry(&DeployRequest {
1119            entry: &entry,
1120            source: &source,
1121            scope: Scope::Local,
1122            repo_root: dir.path(),
1123            opts: &InstallOptions::default(),
1124        });
1125        assert!(
1126            result.contains_key("test.md"),
1127            "Single-file key must be 'test.md', got {:?}",
1128            result.keys().collect::<Vec<_>>()
1129        );
1130    }
1131
1132    // -- deploy_flat --
1133
1134    #[test]
1135    fn deploy_flat_copies_md_files_to_target_dir() {
1136        use skillfile_core::models::{EntityType, SourceFields};
1137
1138        let dir = tempfile::tempdir().unwrap();
1139        // Set up vendor cache dir with .md files and a .meta
1140        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1141        std::fs::create_dir_all(&source_dir).unwrap();
1142        std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1143        std::fs::write(source_dir.join("frontend.md"), "# Frontend").unwrap();
1144        std::fs::write(source_dir.join(".meta"), "{}").unwrap();
1145
1146        let entry = Entry {
1147            entity_type: EntityType::Agent,
1148            name: "core-dev".into(),
1149            source: SourceFields::Github {
1150                owner_repo: "o/r".into(),
1151                path_in_repo: "agents/core-dev".into(),
1152                ref_: "main".into(),
1153            },
1154        };
1155        let a = adapters().get("claude-code").unwrap();
1156        let result = a.deploy_entry(&DeployRequest {
1157            entry: &entry,
1158            source: &source_dir,
1159            scope: Scope::Local,
1160            repo_root: dir.path(),
1161            opts: &InstallOptions {
1162                dry_run: false,
1163                overwrite: true,
1164            },
1165        });
1166        // Flat mode: keys are relative paths from source dir
1167        assert!(result.contains_key("backend.md"));
1168        assert!(result.contains_key("frontend.md"));
1169        assert!(!result.contains_key(".meta"));
1170        // Files actually exist
1171        let target = dir.path().join(".claude/agents");
1172        assert!(target.join("backend.md").exists());
1173        assert!(target.join("frontend.md").exists());
1174    }
1175
1176    #[test]
1177    fn deploy_flat_dry_run_returns_empty() {
1178        use skillfile_core::models::{EntityType, SourceFields};
1179
1180        let dir = tempfile::tempdir().unwrap();
1181        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1182        std::fs::create_dir_all(&source_dir).unwrap();
1183        std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1184
1185        let entry = Entry {
1186            entity_type: EntityType::Agent,
1187            name: "core-dev".into(),
1188            source: SourceFields::Github {
1189                owner_repo: "o/r".into(),
1190                path_in_repo: "agents/core-dev".into(),
1191                ref_: "main".into(),
1192            },
1193        };
1194        let a = adapters().get("claude-code").unwrap();
1195        let result = a.deploy_entry(&DeployRequest {
1196            entry: &entry,
1197            source: &source_dir,
1198            scope: Scope::Local,
1199            repo_root: dir.path(),
1200            opts: &InstallOptions {
1201                dry_run: true,
1202                overwrite: false,
1203            },
1204        });
1205        assert!(result.is_empty());
1206        assert!(!dir.path().join(".claude/agents/backend.md").exists());
1207    }
1208
1209    #[test]
1210    fn deploy_flat_skips_existing_when_no_overwrite() {
1211        use skillfile_core::models::{EntityType, SourceFields};
1212
1213        let dir = tempfile::tempdir().unwrap();
1214        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1215        std::fs::create_dir_all(&source_dir).unwrap();
1216        std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1217
1218        // Pre-create the target file
1219        let target = dir.path().join(".claude/agents");
1220        std::fs::create_dir_all(&target).unwrap();
1221        std::fs::write(target.join("backend.md"), "# Old").unwrap();
1222
1223        let entry = Entry {
1224            entity_type: EntityType::Agent,
1225            name: "core-dev".into(),
1226            source: SourceFields::Github {
1227                owner_repo: "o/r".into(),
1228                path_in_repo: "agents/core-dev".into(),
1229                ref_: "main".into(),
1230            },
1231        };
1232        let a = adapters().get("claude-code").unwrap();
1233        let result = a.deploy_entry(&DeployRequest {
1234            entry: &entry,
1235            source: &source_dir,
1236            scope: Scope::Local,
1237            repo_root: dir.path(),
1238            opts: &InstallOptions {
1239                dry_run: false,
1240                overwrite: false,
1241            },
1242        });
1243        // Should skip the existing file
1244        assert!(result.is_empty());
1245        // Original content preserved
1246        assert_eq!(
1247            std::fs::read_to_string(target.join("backend.md")).unwrap(),
1248            "# Old"
1249        );
1250    }
1251
1252    #[test]
1253    fn deploy_flat_overwrites_existing_when_overwrite_true() {
1254        use skillfile_core::models::{EntityType, SourceFields};
1255
1256        let dir = tempfile::tempdir().unwrap();
1257        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1258        std::fs::create_dir_all(&source_dir).unwrap();
1259        std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1260
1261        let target = dir.path().join(".claude/agents");
1262        std::fs::create_dir_all(&target).unwrap();
1263        std::fs::write(target.join("backend.md"), "# Old").unwrap();
1264
1265        let entry = Entry {
1266            entity_type: EntityType::Agent,
1267            name: "core-dev".into(),
1268            source: SourceFields::Github {
1269                owner_repo: "o/r".into(),
1270                path_in_repo: "agents/core-dev".into(),
1271                ref_: "main".into(),
1272            },
1273        };
1274        let a = adapters().get("claude-code").unwrap();
1275        let result = a.deploy_entry(&DeployRequest {
1276            entry: &entry,
1277            source: &source_dir,
1278            scope: Scope::Local,
1279            repo_root: dir.path(),
1280            opts: &InstallOptions {
1281                dry_run: false,
1282                overwrite: true,
1283            },
1284        });
1285        assert!(result.contains_key("backend.md"));
1286        assert_eq!(
1287            std::fs::read_to_string(target.join("backend.md")).unwrap(),
1288            "# New"
1289        );
1290    }
1291
1292    // -- place_file skip logic --
1293
1294    #[test]
1295    fn place_file_skips_existing_dir_when_no_overwrite() {
1296        use skillfile_core::models::{EntityType, SourceFields};
1297
1298        let dir = tempfile::tempdir().unwrap();
1299        let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1300        std::fs::create_dir_all(&source_dir).unwrap();
1301        std::fs::write(source_dir.join("SKILL.md"), "# Skill").unwrap();
1302
1303        // Pre-create the destination dir
1304        let dest = dir.path().join(".claude/skills/my-skill");
1305        std::fs::create_dir_all(&dest).unwrap();
1306        std::fs::write(dest.join("OLD.md"), "# Old").unwrap();
1307
1308        let entry = Entry {
1309            entity_type: EntityType::Skill,
1310            name: "my-skill".into(),
1311            source: SourceFields::Github {
1312                owner_repo: "o/r".into(),
1313                path_in_repo: "skills/my-skill".into(),
1314                ref_: "main".into(),
1315            },
1316        };
1317        let a = adapters().get("claude-code").unwrap();
1318        let result = a.deploy_entry(&DeployRequest {
1319            entry: &entry,
1320            source: &source_dir,
1321            scope: Scope::Local,
1322            repo_root: dir.path(),
1323            opts: &InstallOptions {
1324                dry_run: false,
1325                overwrite: false,
1326            },
1327        });
1328        // Should skip — dir already exists
1329        assert!(result.is_empty());
1330        // Old file still there
1331        assert!(dest.join("OLD.md").exists());
1332    }
1333
1334    #[test]
1335    fn place_file_skips_existing_single_file_when_no_overwrite() {
1336        use skillfile_core::models::{EntityType, SourceFields};
1337
1338        let dir = tempfile::tempdir().unwrap();
1339        let source_file = dir.path().join("skills/my-skill.md");
1340        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1341        std::fs::write(&source_file, "# New").unwrap();
1342
1343        let dest = dir.path().join(".claude/skills/my-skill/SKILL.md");
1344        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1345        std::fs::write(&dest, "# Old").unwrap();
1346
1347        let entry = Entry {
1348            entity_type: EntityType::Skill,
1349            name: "my-skill".into(),
1350            source: SourceFields::Local {
1351                path: "skills/my-skill.md".into(),
1352            },
1353        };
1354        let a = adapters().get("claude-code").unwrap();
1355        let result = a.deploy_entry(&DeployRequest {
1356            entry: &entry,
1357            source: &source_file,
1358            scope: Scope::Local,
1359            repo_root: dir.path(),
1360            opts: &InstallOptions {
1361                dry_run: false,
1362                overwrite: false,
1363            },
1364        });
1365        assert!(result.is_empty());
1366        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old");
1367    }
1368
1369    // -- installed_dir_files flat mode --
1370
1371    #[test]
1372    fn installed_dir_files_flat_mode_returns_deployed_files() {
1373        use skillfile_core::models::{EntityType, SourceFields};
1374
1375        let dir = tempfile::tempdir().unwrap();
1376        // Set up vendor cache dir
1377        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1378        std::fs::create_dir_all(&vdir).unwrap();
1379        std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1380        std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1381        std::fs::write(vdir.join(".meta"), "{}").unwrap();
1382
1383        // Set up installed flat files
1384        let target = dir.path().join(".claude/agents");
1385        std::fs::create_dir_all(&target).unwrap();
1386        std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1387        std::fs::write(target.join("frontend.md"), "# Frontend").unwrap();
1388
1389        let entry = Entry {
1390            entity_type: EntityType::Agent,
1391            name: "core-dev".into(),
1392            source: SourceFields::Github {
1393                owner_repo: "o/r".into(),
1394                path_in_repo: "agents/core-dev".into(),
1395                ref_: "main".into(),
1396            },
1397        };
1398        let a = adapters().get("claude-code").unwrap();
1399        let files = a.installed_dir_files(&entry, &local(dir.path()));
1400        assert!(files.contains_key("backend.md"));
1401        assert!(files.contains_key("frontend.md"));
1402        assert!(!files.contains_key(".meta"));
1403    }
1404
1405    #[test]
1406    fn installed_dir_files_flat_mode_no_vdir_returns_empty() {
1407        use skillfile_core::models::{EntityType, SourceFields};
1408
1409        let dir = tempfile::tempdir().unwrap();
1410        // No vendor cache dir
1411        let entry = Entry {
1412            entity_type: EntityType::Agent,
1413            name: "core-dev".into(),
1414            source: SourceFields::Github {
1415                owner_repo: "o/r".into(),
1416                path_in_repo: "agents/core-dev".into(),
1417                ref_: "main".into(),
1418            },
1419        };
1420        let a = adapters().get("claude-code").unwrap();
1421        let files = a.installed_dir_files(&entry, &local(dir.path()));
1422        assert!(files.is_empty());
1423    }
1424
1425    #[test]
1426    fn installed_dir_files_flat_mode_skips_non_deployed_files() {
1427        use skillfile_core::models::{EntityType, SourceFields};
1428
1429        let dir = tempfile::tempdir().unwrap();
1430        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1431        std::fs::create_dir_all(&vdir).unwrap();
1432        std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1433        std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1434
1435        // Only deploy one file
1436        let target = dir.path().join(".claude/agents");
1437        std::fs::create_dir_all(&target).unwrap();
1438        std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1439        // frontend.md NOT deployed
1440
1441        let entry = Entry {
1442            entity_type: EntityType::Agent,
1443            name: "core-dev".into(),
1444            source: SourceFields::Github {
1445                owner_repo: "o/r".into(),
1446                path_in_repo: "agents/core-dev".into(),
1447                ref_: "main".into(),
1448            },
1449        };
1450        let a = adapters().get("claude-code").unwrap();
1451        let files = a.installed_dir_files(&entry, &local(dir.path()));
1452        assert!(files.contains_key("backend.md"));
1453        assert!(!files.contains_key("frontend.md"));
1454    }
1455
1456    #[test]
1457    fn forward_slash_converts_backslashes() {
1458        assert_eq!(forward_slash(Path::new("a/b/c")), "a/b/c");
1459        assert_eq!(forward_slash(Path::new("simple.md")), "simple.md");
1460    }
1461
1462    #[cfg(windows)]
1463    #[test]
1464    fn forward_slash_converts_windows_separators() {
1465        assert_eq!(forward_slash(Path::new(r"a\b\c.md")), "a/b/c.md");
1466    }
1467
1468    #[test]
1469    fn deploy_entry_dir_keys_match_source_relative_paths() {
1470        use skillfile_core::models::{EntityType, SourceFields};
1471
1472        let dir = tempfile::tempdir().unwrap();
1473        let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1474        std::fs::create_dir_all(&source_dir).unwrap();
1475        std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1476        std::fs::write(source_dir.join("examples.md"), "# Examples\n").unwrap();
1477
1478        let entry = Entry {
1479            entity_type: EntityType::Skill,
1480            name: "my-skill".into(),
1481            source: SourceFields::Github {
1482                owner_repo: "o/r".into(),
1483                path_in_repo: "skills/my-skill".into(),
1484                ref_: "main".into(),
1485            },
1486        };
1487        let a = adapters().get("claude-code").unwrap();
1488        let result = a.deploy_entry(&DeployRequest {
1489            entry: &entry,
1490            source: &source_dir,
1491            scope: Scope::Local,
1492            repo_root: dir.path(),
1493            opts: &InstallOptions::default(),
1494        });
1495        assert!(result.contains_key("SKILL.md"));
1496        assert!(result.contains_key("examples.md"));
1497    }
1498
1499    #[test]
1500    fn deploy_entry_nested_mode_removes_legacy_flat_file_for_directory_sources() {
1501        use skillfile_core::models::{EntityType, SourceFields};
1502
1503        let dir = tempfile::tempdir().unwrap();
1504        let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1505        std::fs::create_dir_all(&source_dir).unwrap();
1506        std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1507
1508        let target_dir = dir.path().join(".claude/skills");
1509        std::fs::create_dir_all(&target_dir).unwrap();
1510        std::fs::write(target_dir.join("my-skill.md"), "# Legacy flat\n").unwrap();
1511
1512        let entry = Entry {
1513            entity_type: EntityType::Skill,
1514            name: "my-skill".into(),
1515            source: SourceFields::Github {
1516                owner_repo: "o/r".into(),
1517                path_in_repo: "skills/my-skill".into(),
1518                ref_: "main".into(),
1519            },
1520        };
1521        let a = adapters().get("claude-code").unwrap();
1522        let result = a.deploy_entry(&DeployRequest {
1523            entry: &entry,
1524            source: &source_dir,
1525            scope: Scope::Local,
1526            repo_root: dir.path(),
1527            opts: &InstallOptions::default(),
1528        });
1529
1530        assert!(result.contains_key("SKILL.md"));
1531        assert!(target_dir.join("my-skill/SKILL.md").exists());
1532        assert!(!target_dir.join("my-skill.md").exists());
1533    }
1534
1535    // -- antigravity adapter --
1536
1537    #[test]
1538    fn antigravity_supports_skill_not_agent() {
1539        let a = adapters().get("antigravity").unwrap();
1540        assert!(a.supports(EntityType::Skill));
1541        assert!(!a.supports(EntityType::Agent));
1542    }
1543
1544    #[test]
1545    fn local_target_dir_antigravity() {
1546        let tmp = PathBuf::from("/tmp/test");
1547        let a = adapters().get("antigravity").unwrap();
1548        assert_eq!(
1549            a.target_dir(EntityType::Skill, &local(&tmp)),
1550            tmp.join(".agents/skills")
1551        );
1552    }
1553
1554    #[test]
1555    fn global_target_dir_antigravity() {
1556        let a = adapters().get("antigravity").unwrap();
1557        let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
1558        assert!(skill.is_absolute());
1559        assert!(
1560            skill.to_string_lossy().ends_with("antigravity/skills"),
1561            "unexpected: {skill:?}"
1562        );
1563    }
1564
1565    #[test]
1566    fn global_target_dir_prefers_home_env_override() {
1567        let dir = tempfile::tempdir().unwrap();
1568        let home = preferred_home_dir_from(Some(dir.path().as_os_str().to_owned()), None);
1569        assert_eq!(home, dir.path());
1570    }
1571
1572    #[test]
1573    fn antigravity_dir_mode() {
1574        let a = adapters().get("antigravity").unwrap();
1575        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1576        assert_eq!(a.dir_mode(EntityType::Agent), None);
1577    }
1578}