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