Skip to main content

skillfile_deploy/
adapter.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5
6use skillfile_core::models::{Entry, InstallOptions, Scope};
7use skillfile_core::patch::walkdir;
8use skillfile_core::progress;
9use skillfile_sources::strategy::is_dir_entry;
10
11// ---------------------------------------------------------------------------
12// PlatformAdapter trait — the core abstraction for tool-specific deployment
13// ---------------------------------------------------------------------------
14
15/// How a directory entry is deployed to a platform's target directory.
16///
17/// - `Flat`: each `.md` placed individually in `target_dir/` (e.g. claude-code agents)
18/// - `Nested`: directory placed as `target_dir/<name>/` (e.g. all skill adapters)
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DirInstallMode {
21    Flat,
22    Nested,
23}
24
25/// The deployment result: a map of `{patch_key: installed_path}`.
26///
27/// Keys match the relative paths used in `.skillfile/patches/` so patch lookups
28/// work correctly:
29/// - Single-file entries: key is `"{name}.md"`
30/// - Directory entries: keys are paths relative to the source directory
31pub type DeployResult = HashMap<String, PathBuf>;
32
33/// Contract for deploying skill/agent files to a specific AI tool's directory.
34///
35/// Each AI tool (Claude Code, Gemini CLI, Codex, etc.) has its own convention
36/// for where skills and agents live on disk. A `PlatformAdapter` encapsulates
37/// that knowledge.
38///
39/// The trait is object-safe so adapters can be stored in a heterogeneous registry.
40pub trait PlatformAdapter: Send + Sync + fmt::Debug {
41    /// The adapter identifier (e.g. `"claude-code"`, `"gemini-cli"`).
42    fn name(&self) -> &str;
43
44    /// Whether this platform supports the given entity type (e.g. `"skill"`, `"agent"`).
45    fn supports(&self, entity_type: &str) -> bool;
46
47    /// Resolve the absolute target directory for an entity type + scope.
48    fn target_dir(&self, entity_type: &str, scope: Scope, repo_root: &Path) -> PathBuf;
49
50    /// The install mode for directory entries of this entity type.
51    fn dir_mode(&self, entity_type: &str) -> Option<DirInstallMode>;
52
53    /// Deploy a single entry from `source` to its platform-specific location.
54    ///
55    /// Returns `{patch_key: installed_path}` for every file that was placed.
56    /// Returns an empty map for dry-run or when deployment is skipped.
57    fn deploy_entry(
58        &self,
59        entry: &Entry,
60        source: &Path,
61        scope: Scope,
62        repo_root: &Path,
63        opts: &InstallOptions,
64    ) -> DeployResult;
65
66    /// The installed path for a single-file entry.
67    fn installed_path(&self, entry: &Entry, scope: Scope, repo_root: &Path) -> PathBuf;
68
69    /// Map of `{relative_path: absolute_path}` for all installed files of a directory entry.
70    fn installed_dir_files(
71        &self,
72        entry: &Entry,
73        scope: Scope,
74        repo_root: &Path,
75    ) -> HashMap<String, PathBuf>;
76}
77
78// ---------------------------------------------------------------------------
79// EntityConfig — per-entity-type path configuration
80// ---------------------------------------------------------------------------
81
82/// Paths and install mode for one entity type within a platform.
83#[derive(Debug, Clone)]
84pub struct EntityConfig {
85    pub global_path: String,
86    pub local_path: String,
87    pub dir_mode: DirInstallMode,
88}
89
90// ---------------------------------------------------------------------------
91// FileSystemAdapter — the concrete implementation of PlatformAdapter
92// ---------------------------------------------------------------------------
93
94/// Filesystem-based platform adapter.
95///
96/// Each instance is configured with a name and a map of `EntityConfig`s.
97/// All three built-in adapters (claude-code, gemini-cli, codex) are instances
98/// of this struct with different configurations — the `PlatformAdapter` trait
99/// allows alternative implementations if needed.
100#[derive(Debug, Clone)]
101pub struct FileSystemAdapter {
102    name: String,
103    entities: HashMap<String, EntityConfig>,
104}
105
106impl FileSystemAdapter {
107    pub fn new(name: &str, entities: HashMap<String, EntityConfig>) -> Self {
108        Self {
109            name: name.to_string(),
110            entities,
111        }
112    }
113}
114
115impl PlatformAdapter for FileSystemAdapter {
116    fn name(&self) -> &str {
117        &self.name
118    }
119
120    fn supports(&self, entity_type: &str) -> bool {
121        self.entities.contains_key(entity_type)
122    }
123
124    fn target_dir(&self, entity_type: &str, scope: Scope, repo_root: &Path) -> PathBuf {
125        let config = &self.entities[entity_type];
126        let raw = match 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            repo_root.join(raw)
135        }
136    }
137
138    fn dir_mode(&self, entity_type: &str) -> Option<DirInstallMode> {
139        self.entities.get(entity_type).map(|c| c.dir_mode)
140    }
141
142    fn deploy_entry(
143        &self,
144        entry: &Entry,
145        source: &Path,
146        scope: Scope,
147        repo_root: &Path,
148        opts: &InstallOptions,
149    ) -> DeployResult {
150        let target_dir = self.target_dir(entry.entity_type.as_str(), scope, repo_root);
151        // Use filesystem truth: source.is_dir() catches local directory entries
152        // that is_dir_entry() misses (it only inspects GitHub path_in_repo).
153        let is_dir = is_dir_entry(entry) || source.is_dir();
154
155        if is_dir
156            && self
157                .entities
158                .get(entry.entity_type.as_str())
159                .is_some_and(|c| c.dir_mode == DirInstallMode::Flat)
160        {
161            return deploy_flat(source, &target_dir, opts);
162        }
163
164        let dest = if is_dir {
165            target_dir.join(&entry.name)
166        } else {
167            target_dir.join(format!("{}.md", entry.name))
168        };
169
170        if !place_file(source, &dest, is_dir, opts) || opts.dry_run {
171            return HashMap::new();
172        }
173
174        if is_dir {
175            let mut result = HashMap::new();
176            for file in walkdir(source) {
177                if file.file_name().is_none_or(|n| n == ".meta") {
178                    continue;
179                }
180                if let Ok(rel) = file.strip_prefix(source) {
181                    result.insert(rel.to_string_lossy().to_string(), dest.join(rel));
182                }
183            }
184            result
185        } else {
186            HashMap::from([(format!("{}.md", entry.name), dest)])
187        }
188    }
189
190    fn installed_path(&self, entry: &Entry, scope: Scope, repo_root: &Path) -> PathBuf {
191        self.target_dir(entry.entity_type.as_str(), scope, repo_root)
192            .join(format!("{}.md", entry.name))
193    }
194
195    fn installed_dir_files(
196        &self,
197        entry: &Entry,
198        scope: Scope,
199        repo_root: &Path,
200    ) -> HashMap<String, PathBuf> {
201        let target_dir = self.target_dir(entry.entity_type.as_str(), scope, repo_root);
202        let mode = self
203            .entities
204            .get(entry.entity_type.as_str())
205            .map(|c| c.dir_mode)
206            .unwrap_or(DirInstallMode::Nested);
207
208        if mode == DirInstallMode::Nested {
209            let installed_dir = target_dir.join(&entry.name);
210            if !installed_dir.is_dir() {
211                return HashMap::new();
212            }
213            let mut result = HashMap::new();
214            for file in walkdir(&installed_dir) {
215                if let Ok(rel) = file.strip_prefix(&installed_dir) {
216                    result.insert(rel.to_string_lossy().to_string(), file);
217                }
218            }
219            result
220        } else {
221            // Flat: keys are relative-from-vdir so they match patch lookup keys
222            let vdir = skillfile_sources::sync::vendor_dir_for(entry, repo_root);
223            if !vdir.is_dir() {
224                return HashMap::new();
225            }
226            let mut result = HashMap::new();
227            for file in walkdir(&vdir) {
228                if file
229                    .extension()
230                    .is_none_or(|ext| ext.to_string_lossy() != "md")
231                {
232                    continue;
233                }
234                if let Ok(rel) = file.strip_prefix(&vdir) {
235                    let dest = target_dir.join(file.file_name().unwrap_or_default());
236                    if dest.exists() {
237                        result.insert(rel.to_string_lossy().to_string(), dest);
238                    }
239                }
240            }
241            result
242        }
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Deployment helpers (used by FileSystemAdapter)
248// ---------------------------------------------------------------------------
249
250/// Deploy each `.md` in `source_dir` as an individual file in `target_dir` (flat mode).
251fn deploy_flat(source_dir: &Path, target_dir: &Path, opts: &InstallOptions) -> DeployResult {
252    let mut md_files: Vec<PathBuf> = walkdir(source_dir)
253        .into_iter()
254        .filter(|f| f.extension().is_some_and(|ext| ext == "md"))
255        .collect();
256    md_files.sort();
257
258    if opts.dry_run {
259        for src in &md_files {
260            if let Some(name) = src.file_name() {
261                progress!(
262                    "  {} -> {} [copy, dry-run]",
263                    name.to_string_lossy(),
264                    target_dir.join(name).display()
265                );
266            }
267        }
268        return HashMap::new();
269    }
270
271    std::fs::create_dir_all(target_dir).ok();
272    let mut result = HashMap::new();
273    for src in &md_files {
274        let Some(name) = src.file_name() else {
275            continue;
276        };
277        let dest = target_dir.join(name);
278        if !opts.overwrite && dest.is_file() {
279            continue;
280        }
281        if dest.exists() {
282            std::fs::remove_file(&dest).ok();
283        }
284        if std::fs::copy(src, &dest).is_ok() {
285            progress!("  {} -> {}", name.to_string_lossy(), dest.display());
286            if let Ok(rel) = src.strip_prefix(source_dir) {
287                result.insert(rel.to_string_lossy().to_string(), dest);
288            }
289        }
290    }
291    result
292}
293
294/// Copy `source` to `dest`. Returns `true` if placed, `false` if skipped.
295fn place_file(source: &Path, dest: &Path, is_dir: bool, opts: &InstallOptions) -> bool {
296    if !opts.overwrite && !opts.dry_run {
297        if is_dir && dest.is_dir() {
298            return false;
299        }
300        if !is_dir && dest.is_file() {
301            return false;
302        }
303    }
304
305    let label = format!(
306        "  {} -> {}",
307        source.file_name().unwrap_or_default().to_string_lossy(),
308        dest.display()
309    );
310
311    if opts.dry_run {
312        progress!("{label} [copy, dry-run]");
313        return true;
314    }
315
316    if let Some(parent) = dest.parent() {
317        std::fs::create_dir_all(parent).ok();
318    }
319
320    // Remove existing
321    if dest.exists() || dest.is_symlink() {
322        if dest.is_dir() {
323            std::fs::remove_dir_all(dest).ok();
324        } else {
325            std::fs::remove_file(dest).ok();
326        }
327    }
328
329    if is_dir {
330        copy_dir_recursive(source, dest).ok();
331    } else {
332        std::fs::copy(source, dest).ok();
333    }
334
335    progress!("{label}");
336    true
337}
338
339/// Recursively copy a directory tree.
340fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
341    std::fs::create_dir_all(dst)?;
342    for entry in std::fs::read_dir(src)? {
343        let entry = entry?;
344        let ty = entry.file_type()?;
345        let dest_path = dst.join(entry.file_name());
346        if ty.is_dir() {
347            copy_dir_recursive(&entry.path(), &dest_path)?;
348        } else {
349            std::fs::copy(entry.path(), &dest_path)?;
350        }
351    }
352    Ok(())
353}
354
355// ---------------------------------------------------------------------------
356// AdapterRegistry — injectable, testable collection of platform adapters
357// ---------------------------------------------------------------------------
358
359/// A collection of platform adapters, indexed by name.
360///
361/// The registry owns the adapters and provides lookup by name. It can be
362/// constructed with the built-in adapters via [`AdapterRegistry::builtin()`],
363/// or built manually for testing.
364pub struct AdapterRegistry {
365    adapters: HashMap<String, Box<dyn PlatformAdapter>>,
366}
367
368impl AdapterRegistry {
369    /// Create a registry from a vec of boxed adapters.
370    pub fn new(adapters: Vec<Box<dyn PlatformAdapter>>) -> Self {
371        let map = adapters
372            .into_iter()
373            .map(|a| (a.name().to_string(), a))
374            .collect();
375        Self { adapters: map }
376    }
377
378    /// Create the built-in registry with all known platform adapters.
379    pub fn builtin() -> Self {
380        Self::new(vec![
381            Box::new(claude_code_adapter()),
382            Box::new(gemini_cli_adapter()),
383            Box::new(codex_adapter()),
384        ])
385    }
386
387    /// Look up an adapter by name.
388    pub fn get(&self, name: &str) -> Option<&dyn PlatformAdapter> {
389        self.adapters.get(name).map(|b| &**b)
390    }
391
392    /// Check if an adapter with this name exists.
393    pub fn contains(&self, name: &str) -> bool {
394        self.adapters.contains_key(name)
395    }
396
397    /// Sorted list of all adapter names.
398    pub fn names(&self) -> Vec<&str> {
399        let mut names: Vec<&str> = self.adapters.keys().map(|s| s.as_str()).collect();
400        names.sort();
401        names
402    }
403}
404
405impl fmt::Debug for AdapterRegistry {
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        f.debug_struct("AdapterRegistry")
408            .field("adapters", &self.names())
409            .finish()
410    }
411}
412
413// ---------------------------------------------------------------------------
414// Built-in adapters
415// ---------------------------------------------------------------------------
416
417fn claude_code_adapter() -> FileSystemAdapter {
418    FileSystemAdapter::new(
419        "claude-code",
420        HashMap::from([
421            (
422                "agent".to_string(),
423                EntityConfig {
424                    global_path: "~/.claude/agents".into(),
425                    local_path: ".claude/agents".into(),
426                    dir_mode: DirInstallMode::Flat,
427                },
428            ),
429            (
430                "skill".to_string(),
431                EntityConfig {
432                    global_path: "~/.claude/skills".into(),
433                    local_path: ".claude/skills".into(),
434                    dir_mode: DirInstallMode::Nested,
435                },
436            ),
437        ]),
438    )
439}
440
441fn gemini_cli_adapter() -> FileSystemAdapter {
442    FileSystemAdapter::new(
443        "gemini-cli",
444        HashMap::from([
445            (
446                "agent".to_string(),
447                EntityConfig {
448                    global_path: "~/.gemini/agents".into(),
449                    local_path: ".gemini/agents".into(),
450                    dir_mode: DirInstallMode::Flat,
451                },
452            ),
453            (
454                "skill".to_string(),
455                EntityConfig {
456                    global_path: "~/.gemini/skills".into(),
457                    local_path: ".gemini/skills".into(),
458                    dir_mode: DirInstallMode::Nested,
459                },
460            ),
461        ]),
462    )
463}
464
465fn codex_adapter() -> FileSystemAdapter {
466    FileSystemAdapter::new(
467        "codex",
468        HashMap::from([(
469            "skill".to_string(),
470            EntityConfig {
471                global_path: "~/.codex/skills".into(),
472                local_path: ".codex/skills".into(),
473                dir_mode: DirInstallMode::Nested,
474            },
475        )]),
476    )
477}
478
479// ---------------------------------------------------------------------------
480// Global registry accessor (backward-compatible convenience)
481// ---------------------------------------------------------------------------
482
483/// Get the global adapter registry (lazily initialized).
484#[must_use]
485pub fn adapters() -> &'static AdapterRegistry {
486    static REGISTRY: OnceLock<AdapterRegistry> = OnceLock::new();
487    REGISTRY.get_or_init(AdapterRegistry::builtin)
488}
489
490/// Sorted list of known adapter names.
491#[must_use]
492pub fn known_adapters() -> Vec<&'static str> {
493    adapters().names()
494}
495
496// ---------------------------------------------------------------------------
497// Tests
498// ---------------------------------------------------------------------------
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    // -- Trait compliance: every registered adapter satisfies PlatformAdapter --
505
506    #[test]
507    fn all_builtin_adapters_in_registry() {
508        let reg = adapters();
509        assert!(reg.contains("claude-code"));
510        assert!(reg.contains("gemini-cli"));
511        assert!(reg.contains("codex"));
512    }
513
514    #[test]
515    fn known_adapters_contains_all() {
516        let names = known_adapters();
517        assert!(names.contains(&"claude-code"));
518        assert!(names.contains(&"gemini-cli"));
519        assert!(names.contains(&"codex"));
520        assert_eq!(names.len(), 3);
521    }
522
523    #[test]
524    fn adapter_name_matches_registry_key() {
525        let reg = adapters();
526        for name in reg.names() {
527            let adapter = reg.get(name).unwrap();
528            assert_eq!(adapter.name(), name);
529        }
530    }
531
532    #[test]
533    fn registry_get_unknown_returns_none() {
534        assert!(adapters().get("unknown-tool").is_none());
535    }
536
537    // -- supports() --
538
539    #[test]
540    fn claude_code_supports_agent_and_skill() {
541        let a = adapters().get("claude-code").unwrap();
542        assert!(a.supports("agent"));
543        assert!(a.supports("skill"));
544        assert!(!a.supports("hook"));
545    }
546
547    #[test]
548    fn gemini_cli_supports_agent_and_skill() {
549        let a = adapters().get("gemini-cli").unwrap();
550        assert!(a.supports("agent"));
551        assert!(a.supports("skill"));
552    }
553
554    #[test]
555    fn codex_supports_skill_not_agent() {
556        let a = adapters().get("codex").unwrap();
557        assert!(a.supports("skill"));
558        assert!(!a.supports("agent"));
559    }
560
561    // -- target_dir() --
562
563    #[test]
564    fn local_target_dir_claude_code() {
565        let tmp = PathBuf::from("/tmp/test");
566        let a = adapters().get("claude-code").unwrap();
567        assert_eq!(
568            a.target_dir("agent", Scope::Local, &tmp),
569            tmp.join(".claude/agents")
570        );
571        assert_eq!(
572            a.target_dir("skill", Scope::Local, &tmp),
573            tmp.join(".claude/skills")
574        );
575    }
576
577    #[test]
578    fn local_target_dir_gemini_cli() {
579        let tmp = PathBuf::from("/tmp/test");
580        let a = adapters().get("gemini-cli").unwrap();
581        assert_eq!(
582            a.target_dir("agent", Scope::Local, &tmp),
583            tmp.join(".gemini/agents")
584        );
585        assert_eq!(
586            a.target_dir("skill", Scope::Local, &tmp),
587            tmp.join(".gemini/skills")
588        );
589    }
590
591    #[test]
592    fn local_target_dir_codex() {
593        let tmp = PathBuf::from("/tmp/test");
594        let a = adapters().get("codex").unwrap();
595        assert_eq!(
596            a.target_dir("skill", Scope::Local, &tmp),
597            tmp.join(".codex/skills")
598        );
599    }
600
601    #[test]
602    fn global_target_dir_is_absolute() {
603        let a = adapters().get("claude-code").unwrap();
604        let result = a.target_dir("agent", Scope::Global, Path::new("/tmp"));
605        assert!(result.is_absolute());
606        assert!(result.to_string_lossy().ends_with(".claude/agents"));
607    }
608
609    #[test]
610    fn global_target_dir_gemini_cli_skill() {
611        let a = adapters().get("gemini-cli").unwrap();
612        let result = a.target_dir("skill", Scope::Global, Path::new("/tmp"));
613        assert!(result.is_absolute());
614        assert!(result.to_string_lossy().ends_with(".gemini/skills"));
615    }
616
617    #[test]
618    fn global_target_dir_codex_skill() {
619        let a = adapters().get("codex").unwrap();
620        let result = a.target_dir("skill", Scope::Global, Path::new("/tmp"));
621        assert!(result.is_absolute());
622        assert!(result.to_string_lossy().ends_with(".codex/skills"));
623    }
624
625    // -- dir_mode --
626
627    #[test]
628    fn claude_code_dir_modes() {
629        let a = adapters().get("claude-code").unwrap();
630        assert_eq!(a.dir_mode("agent"), Some(DirInstallMode::Flat));
631        assert_eq!(a.dir_mode("skill"), Some(DirInstallMode::Nested));
632    }
633
634    #[test]
635    fn gemini_cli_dir_modes() {
636        let a = adapters().get("gemini-cli").unwrap();
637        assert_eq!(a.dir_mode("agent"), Some(DirInstallMode::Flat));
638        assert_eq!(a.dir_mode("skill"), Some(DirInstallMode::Nested));
639    }
640
641    #[test]
642    fn codex_dir_mode() {
643        let a = adapters().get("codex").unwrap();
644        assert_eq!(a.dir_mode("skill"), Some(DirInstallMode::Nested));
645    }
646
647    // -- Custom adapter extensibility --
648
649    #[test]
650    fn custom_adapter_via_registry() {
651        let custom = FileSystemAdapter::new(
652            "my-tool",
653            HashMap::from([(
654                "skill".to_string(),
655                EntityConfig {
656                    global_path: "~/.my-tool/skills".into(),
657                    local_path: ".my-tool/skills".into(),
658                    dir_mode: DirInstallMode::Nested,
659                },
660            )]),
661        );
662        let registry = AdapterRegistry::new(vec![Box::new(custom)]);
663        let a = registry.get("my-tool").unwrap();
664        assert!(a.supports("skill"));
665        assert!(!a.supports("agent"));
666        assert_eq!(registry.names(), vec!["my-tool"]);
667    }
668
669    // -- deploy_entry key contract --
670
671    #[test]
672    fn deploy_entry_single_file_key_matches_patch_convention() {
673        use skillfile_core::models::{EntityType, SourceFields};
674
675        let dir = tempfile::tempdir().unwrap();
676        let source_dir = dir.path().join(".skillfile/cache/agents/test");
677        std::fs::create_dir_all(&source_dir).unwrap();
678        std::fs::write(source_dir.join("agent.md"), "# Agent\n").unwrap();
679        let source = source_dir.join("agent.md");
680
681        let entry = Entry {
682            entity_type: EntityType::Agent,
683            name: "test".into(),
684            source: SourceFields::Github {
685                owner_repo: "o/r".into(),
686                path_in_repo: "agents/agent.md".into(),
687                ref_: "main".into(),
688            },
689        };
690        let a = adapters().get("claude-code").unwrap();
691        let result = a.deploy_entry(
692            &entry,
693            &source,
694            Scope::Local,
695            dir.path(),
696            &InstallOptions::default(),
697        );
698        assert!(
699            result.contains_key("test.md"),
700            "Single-file key must be 'test.md', got {:?}",
701            result.keys().collect::<Vec<_>>()
702        );
703    }
704
705    // -- deploy_flat --
706
707    #[test]
708    fn deploy_flat_copies_md_files_to_target_dir() {
709        use skillfile_core::models::{EntityType, SourceFields};
710
711        let dir = tempfile::tempdir().unwrap();
712        // Set up vendor cache dir with .md files and a .meta
713        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
714        std::fs::create_dir_all(&source_dir).unwrap();
715        std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
716        std::fs::write(source_dir.join("frontend.md"), "# Frontend").unwrap();
717        std::fs::write(source_dir.join(".meta"), "{}").unwrap();
718
719        let entry = Entry {
720            entity_type: EntityType::Agent,
721            name: "core-dev".into(),
722            source: SourceFields::Github {
723                owner_repo: "o/r".into(),
724                path_in_repo: "agents/core-dev".into(),
725                ref_: "main".into(),
726            },
727        };
728        let a = adapters().get("claude-code").unwrap();
729        let result = a.deploy_entry(
730            &entry,
731            &source_dir,
732            Scope::Local,
733            dir.path(),
734            &InstallOptions {
735                dry_run: false,
736                overwrite: true,
737            },
738        );
739        // Flat mode: keys are relative paths from source dir
740        assert!(result.contains_key("backend.md"));
741        assert!(result.contains_key("frontend.md"));
742        assert!(!result.contains_key(".meta"));
743        // Files actually exist
744        let target = dir.path().join(".claude/agents");
745        assert!(target.join("backend.md").exists());
746        assert!(target.join("frontend.md").exists());
747    }
748
749    #[test]
750    fn deploy_flat_dry_run_returns_empty() {
751        use skillfile_core::models::{EntityType, SourceFields};
752
753        let dir = tempfile::tempdir().unwrap();
754        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
755        std::fs::create_dir_all(&source_dir).unwrap();
756        std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
757
758        let entry = Entry {
759            entity_type: EntityType::Agent,
760            name: "core-dev".into(),
761            source: SourceFields::Github {
762                owner_repo: "o/r".into(),
763                path_in_repo: "agents/core-dev".into(),
764                ref_: "main".into(),
765            },
766        };
767        let a = adapters().get("claude-code").unwrap();
768        let result = a.deploy_entry(
769            &entry,
770            &source_dir,
771            Scope::Local,
772            dir.path(),
773            &InstallOptions {
774                dry_run: true,
775                overwrite: false,
776            },
777        );
778        assert!(result.is_empty());
779        assert!(!dir.path().join(".claude/agents/backend.md").exists());
780    }
781
782    #[test]
783    fn deploy_flat_skips_existing_when_no_overwrite() {
784        use skillfile_core::models::{EntityType, SourceFields};
785
786        let dir = tempfile::tempdir().unwrap();
787        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
788        std::fs::create_dir_all(&source_dir).unwrap();
789        std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
790
791        // Pre-create the target file
792        let target = dir.path().join(".claude/agents");
793        std::fs::create_dir_all(&target).unwrap();
794        std::fs::write(target.join("backend.md"), "# Old").unwrap();
795
796        let entry = Entry {
797            entity_type: EntityType::Agent,
798            name: "core-dev".into(),
799            source: SourceFields::Github {
800                owner_repo: "o/r".into(),
801                path_in_repo: "agents/core-dev".into(),
802                ref_: "main".into(),
803            },
804        };
805        let a = adapters().get("claude-code").unwrap();
806        let result = a.deploy_entry(
807            &entry,
808            &source_dir,
809            Scope::Local,
810            dir.path(),
811            &InstallOptions {
812                dry_run: false,
813                overwrite: false,
814            },
815        );
816        // Should skip the existing file
817        assert!(result.is_empty());
818        // Original content preserved
819        assert_eq!(
820            std::fs::read_to_string(target.join("backend.md")).unwrap(),
821            "# Old"
822        );
823    }
824
825    #[test]
826    fn deploy_flat_overwrites_existing_when_overwrite_true() {
827        use skillfile_core::models::{EntityType, SourceFields};
828
829        let dir = tempfile::tempdir().unwrap();
830        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
831        std::fs::create_dir_all(&source_dir).unwrap();
832        std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
833
834        let target = dir.path().join(".claude/agents");
835        std::fs::create_dir_all(&target).unwrap();
836        std::fs::write(target.join("backend.md"), "# Old").unwrap();
837
838        let entry = Entry {
839            entity_type: EntityType::Agent,
840            name: "core-dev".into(),
841            source: SourceFields::Github {
842                owner_repo: "o/r".into(),
843                path_in_repo: "agents/core-dev".into(),
844                ref_: "main".into(),
845            },
846        };
847        let a = adapters().get("claude-code").unwrap();
848        let result = a.deploy_entry(
849            &entry,
850            &source_dir,
851            Scope::Local,
852            dir.path(),
853            &InstallOptions {
854                dry_run: false,
855                overwrite: true,
856            },
857        );
858        assert!(result.contains_key("backend.md"));
859        assert_eq!(
860            std::fs::read_to_string(target.join("backend.md")).unwrap(),
861            "# New"
862        );
863    }
864
865    // -- place_file skip logic --
866
867    #[test]
868    fn place_file_skips_existing_dir_when_no_overwrite() {
869        use skillfile_core::models::{EntityType, SourceFields};
870
871        let dir = tempfile::tempdir().unwrap();
872        let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
873        std::fs::create_dir_all(&source_dir).unwrap();
874        std::fs::write(source_dir.join("SKILL.md"), "# Skill").unwrap();
875
876        // Pre-create the destination dir
877        let dest = dir.path().join(".claude/skills/my-skill");
878        std::fs::create_dir_all(&dest).unwrap();
879        std::fs::write(dest.join("OLD.md"), "# Old").unwrap();
880
881        let entry = Entry {
882            entity_type: EntityType::Skill,
883            name: "my-skill".into(),
884            source: SourceFields::Github {
885                owner_repo: "o/r".into(),
886                path_in_repo: "skills/my-skill".into(),
887                ref_: "main".into(),
888            },
889        };
890        let a = adapters().get("claude-code").unwrap();
891        let result = a.deploy_entry(
892            &entry,
893            &source_dir,
894            Scope::Local,
895            dir.path(),
896            &InstallOptions {
897                dry_run: false,
898                overwrite: false,
899            },
900        );
901        // Should skip — dir already exists
902        assert!(result.is_empty());
903        // Old file still there
904        assert!(dest.join("OLD.md").exists());
905    }
906
907    #[test]
908    fn place_file_skips_existing_single_file_when_no_overwrite() {
909        use skillfile_core::models::{EntityType, SourceFields};
910
911        let dir = tempfile::tempdir().unwrap();
912        let source_file = dir.path().join("skills/my-skill.md");
913        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
914        std::fs::write(&source_file, "# New").unwrap();
915
916        let dest = dir.path().join(".claude/skills/my-skill.md");
917        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
918        std::fs::write(&dest, "# Old").unwrap();
919
920        let entry = Entry {
921            entity_type: EntityType::Skill,
922            name: "my-skill".into(),
923            source: SourceFields::Local {
924                path: "skills/my-skill.md".into(),
925            },
926        };
927        let a = adapters().get("claude-code").unwrap();
928        let result = a.deploy_entry(
929            &entry,
930            &source_file,
931            Scope::Local,
932            dir.path(),
933            &InstallOptions {
934                dry_run: false,
935                overwrite: false,
936            },
937        );
938        assert!(result.is_empty());
939        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old");
940    }
941
942    // -- installed_dir_files flat mode --
943
944    #[test]
945    fn installed_dir_files_flat_mode_returns_deployed_files() {
946        use skillfile_core::models::{EntityType, SourceFields};
947
948        let dir = tempfile::tempdir().unwrap();
949        // Set up vendor cache dir
950        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
951        std::fs::create_dir_all(&vdir).unwrap();
952        std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
953        std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
954        std::fs::write(vdir.join(".meta"), "{}").unwrap();
955
956        // Set up installed flat files
957        let target = dir.path().join(".claude/agents");
958        std::fs::create_dir_all(&target).unwrap();
959        std::fs::write(target.join("backend.md"), "# Backend").unwrap();
960        std::fs::write(target.join("frontend.md"), "# Frontend").unwrap();
961
962        let entry = Entry {
963            entity_type: EntityType::Agent,
964            name: "core-dev".into(),
965            source: SourceFields::Github {
966                owner_repo: "o/r".into(),
967                path_in_repo: "agents/core-dev".into(),
968                ref_: "main".into(),
969            },
970        };
971        let a = adapters().get("claude-code").unwrap();
972        let files = a.installed_dir_files(&entry, Scope::Local, dir.path());
973        assert!(files.contains_key("backend.md"));
974        assert!(files.contains_key("frontend.md"));
975        assert!(!files.contains_key(".meta"));
976    }
977
978    #[test]
979    fn installed_dir_files_flat_mode_no_vdir_returns_empty() {
980        use skillfile_core::models::{EntityType, SourceFields};
981
982        let dir = tempfile::tempdir().unwrap();
983        // No vendor cache dir
984        let entry = Entry {
985            entity_type: EntityType::Agent,
986            name: "core-dev".into(),
987            source: SourceFields::Github {
988                owner_repo: "o/r".into(),
989                path_in_repo: "agents/core-dev".into(),
990                ref_: "main".into(),
991            },
992        };
993        let a = adapters().get("claude-code").unwrap();
994        let files = a.installed_dir_files(&entry, Scope::Local, dir.path());
995        assert!(files.is_empty());
996    }
997
998    #[test]
999    fn installed_dir_files_flat_mode_skips_non_deployed_files() {
1000        use skillfile_core::models::{EntityType, SourceFields};
1001
1002        let dir = tempfile::tempdir().unwrap();
1003        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1004        std::fs::create_dir_all(&vdir).unwrap();
1005        std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1006        std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1007
1008        // Only deploy one file
1009        let target = dir.path().join(".claude/agents");
1010        std::fs::create_dir_all(&target).unwrap();
1011        std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1012        // frontend.md NOT deployed
1013
1014        let entry = Entry {
1015            entity_type: EntityType::Agent,
1016            name: "core-dev".into(),
1017            source: SourceFields::Github {
1018                owner_repo: "o/r".into(),
1019                path_in_repo: "agents/core-dev".into(),
1020                ref_: "main".into(),
1021            },
1022        };
1023        let a = adapters().get("claude-code").unwrap();
1024        let files = a.installed_dir_files(&entry, Scope::Local, dir.path());
1025        assert!(files.contains_key("backend.md"));
1026        assert!(!files.contains_key("frontend.md"));
1027    }
1028
1029    #[test]
1030    fn deploy_entry_dir_keys_match_source_relative_paths() {
1031        use skillfile_core::models::{EntityType, SourceFields};
1032
1033        let dir = tempfile::tempdir().unwrap();
1034        let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1035        std::fs::create_dir_all(&source_dir).unwrap();
1036        std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1037        std::fs::write(source_dir.join("examples.md"), "# Examples\n").unwrap();
1038
1039        let entry = Entry {
1040            entity_type: EntityType::Skill,
1041            name: "my-skill".into(),
1042            source: SourceFields::Github {
1043                owner_repo: "o/r".into(),
1044                path_in_repo: "skills/my-skill".into(),
1045                ref_: "main".into(),
1046            },
1047        };
1048        let a = adapters().get("claude-code").unwrap();
1049        let result = a.deploy_entry(
1050            &entry,
1051            &source_dir,
1052            Scope::Local,
1053            dir.path(),
1054            &InstallOptions::default(),
1055        );
1056        assert!(result.contains_key("SKILL.md"));
1057        assert!(result.contains_key("examples.md"));
1058    }
1059}