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