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