Skip to main content

skillfile_deploy/
paths.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use skillfile_core::error::SkillfileError;
5use skillfile_core::models::{EntityType, Entry, Manifest, SourceFields};
6use skillfile_sources::strategy::{content_file, is_dir_entry};
7use skillfile_sources::sync::vendor_dir_for;
8
9use crate::adapter::{adapters, AdapterScope, PlatformAdapter};
10
11/// Resolve absolute deploy directory for (adapter, entity_type, scope).
12pub fn resolve_target_dir(
13    adapter_name: &str,
14    entity_type: EntityType,
15    ctx: &AdapterScope<'_>,
16) -> Result<PathBuf, SkillfileError> {
17    let a = adapters()
18        .get(adapter_name)
19        .ok_or_else(|| SkillfileError::Manifest(format!("unknown adapter '{adapter_name}'")))?;
20    Ok(a.target_dir(entity_type, ctx))
21}
22
23/// Installed path for a single-file entry (first install target).
24pub fn installed_path(
25    entry: &Entry,
26    manifest: &Manifest,
27    repo_root: &Path,
28) -> Result<PathBuf, SkillfileError> {
29    let adapter = first_target(manifest)?;
30    let ctx = AdapterScope {
31        scope: manifest.install_targets[0].scope,
32        repo_root,
33    };
34    Ok(adapter.installed_path(entry, &ctx))
35}
36
37/// Installed files for a directory entry (first install target).
38pub fn installed_dir_files(
39    entry: &Entry,
40    manifest: &Manifest,
41    repo_root: &Path,
42) -> Result<HashMap<String, PathBuf>, SkillfileError> {
43    let adapter = first_target(manifest)?;
44    let ctx = AdapterScope {
45        scope: manifest.install_targets[0].scope,
46        repo_root,
47    };
48    Ok(adapter.installed_dir_files(entry, &ctx))
49}
50
51/// Resolve the cache or local source path for an entry.
52#[must_use]
53pub fn source_path(entry: &Entry, repo_root: &Path) -> Option<PathBuf> {
54    match &entry.source {
55        SourceFields::Local { path } => Some(repo_root.join(path)),
56        SourceFields::Github { .. } | SourceFields::Url { .. } => {
57            source_path_remote(entry, repo_root)
58        }
59    }
60}
61
62/// Resolve cache path for a remote (GitHub/URL) entry.
63fn source_path_remote(entry: &Entry, repo_root: &Path) -> Option<PathBuf> {
64    let vdir = vendor_dir_for(entry, repo_root);
65    if is_dir_entry(entry) {
66        vdir.exists().then_some(vdir)
67    } else {
68        let filename = content_file(entry);
69        (!filename.is_empty()).then(|| vdir.join(filename))
70    }
71}
72
73/// Return the adapter for the first install target, or error.
74fn first_target(manifest: &Manifest) -> Result<&'static dyn PlatformAdapter, SkillfileError> {
75    if manifest.install_targets.is_empty() {
76        return Err(SkillfileError::Manifest(
77            "no install targets configured — run `skillfile install` first".into(),
78        ));
79    }
80    let t = &manifest.install_targets[0];
81    adapters()
82        .get(&t.adapter)
83        .ok_or_else(|| SkillfileError::Manifest(format!("unknown adapter '{}'", t.adapter)))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::adapter::AdapterScope;
90    use skillfile_core::models::{EntityType, InstallTarget, Scope};
91
92    #[test]
93    fn resolve_target_dir_global() {
94        let ctx = AdapterScope {
95            scope: Scope::Global,
96            repo_root: Path::new("/tmp"),
97        };
98        let result = resolve_target_dir("claude-code", EntityType::Agent, &ctx).unwrap();
99        assert!(result.to_string_lossy().ends_with(".claude/agents"));
100    }
101
102    #[test]
103    fn resolve_target_dir_local() {
104        let tmp = tempfile::tempdir().unwrap();
105        let ctx = AdapterScope {
106            scope: Scope::Local,
107            repo_root: tmp.path(),
108        };
109        let result = resolve_target_dir("claude-code", EntityType::Agent, &ctx).unwrap();
110        assert_eq!(result, tmp.path().join(".claude/agents"));
111    }
112
113    #[test]
114    fn installed_path_no_targets() {
115        let entry = Entry {
116            entity_type: EntityType::Agent,
117            name: "test".into(),
118            source: SourceFields::Github {
119                owner_repo: "o/r".into(),
120                path_in_repo: "a.md".into(),
121                ref_: "main".into(),
122            },
123        };
124        let manifest = Manifest {
125            entries: vec![entry.clone()],
126            install_targets: vec![],
127        };
128        let result = installed_path(&entry, &manifest, Path::new("/tmp"));
129        assert!(result.is_err());
130        assert!(result
131            .unwrap_err()
132            .to_string()
133            .contains("no install targets"));
134    }
135
136    #[test]
137    fn installed_path_unknown_adapter() {
138        let entry = Entry {
139            entity_type: EntityType::Agent,
140            name: "test".into(),
141            source: SourceFields::Github {
142                owner_repo: "o/r".into(),
143                path_in_repo: "a.md".into(),
144                ref_: "main".into(),
145            },
146        };
147        let manifest = Manifest {
148            entries: vec![entry.clone()],
149            install_targets: vec![InstallTarget {
150                adapter: "unknown".into(),
151                scope: Scope::Global,
152            }],
153        };
154        let result = installed_path(&entry, &manifest, Path::new("/tmp"));
155        assert!(result.is_err());
156        assert!(result.unwrap_err().to_string().contains("unknown adapter"));
157    }
158
159    #[test]
160    fn installed_path_returns_correct_path() {
161        let tmp = tempfile::tempdir().unwrap();
162        let entry = Entry {
163            entity_type: EntityType::Agent,
164            name: "test".into(),
165            source: SourceFields::Github {
166                owner_repo: "o/r".into(),
167                path_in_repo: "a.md".into(),
168                ref_: "main".into(),
169            },
170        };
171        let manifest = Manifest {
172            entries: vec![entry.clone()],
173            install_targets: vec![InstallTarget {
174                adapter: "claude-code".into(),
175                scope: Scope::Local,
176            }],
177        };
178        let result = installed_path(&entry, &manifest, tmp.path()).unwrap();
179        assert_eq!(result, tmp.path().join(".claude/agents/test.md"));
180    }
181
182    #[test]
183    fn installed_dir_files_no_targets() {
184        let entry = Entry {
185            entity_type: EntityType::Agent,
186            name: "test".into(),
187            source: SourceFields::Github {
188                owner_repo: "o/r".into(),
189                path_in_repo: "agents".into(),
190                ref_: "main".into(),
191            },
192        };
193        let manifest = Manifest {
194            entries: vec![entry.clone()],
195            install_targets: vec![],
196        };
197        let result = installed_dir_files(&entry, &manifest, Path::new("/tmp"));
198        assert!(result.is_err());
199    }
200
201    #[test]
202    fn installed_dir_files_skill_dir() {
203        let tmp = tempfile::tempdir().unwrap();
204        let entry = Entry {
205            entity_type: EntityType::Skill,
206            name: "my-skill".into(),
207            source: SourceFields::Github {
208                owner_repo: "o/r".into(),
209                path_in_repo: "skills".into(),
210                ref_: "main".into(),
211            },
212        };
213        let manifest = Manifest {
214            entries: vec![entry.clone()],
215            install_targets: vec![InstallTarget {
216                adapter: "claude-code".into(),
217                scope: Scope::Local,
218            }],
219        };
220        let skill_dir = tmp.path().join(".claude/skills/my-skill");
221        std::fs::create_dir_all(&skill_dir).unwrap();
222        std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
223
224        let result = installed_dir_files(&entry, &manifest, tmp.path()).unwrap();
225        assert!(result.contains_key("SKILL.md"));
226    }
227
228    #[test]
229    fn installed_dir_files_agent_dir() {
230        let tmp = tempfile::tempdir().unwrap();
231        let entry = Entry {
232            entity_type: EntityType::Agent,
233            name: "my-agents".into(),
234            source: SourceFields::Github {
235                owner_repo: "o/r".into(),
236                path_in_repo: "agents".into(),
237                ref_: "main".into(),
238            },
239        };
240        let manifest = Manifest {
241            entries: vec![entry.clone()],
242            install_targets: vec![InstallTarget {
243                adapter: "claude-code".into(),
244                scope: Scope::Local,
245            }],
246        };
247        // Create vendor cache
248        let vdir = tmp.path().join(".skillfile/cache/agents/my-agents");
249        std::fs::create_dir_all(&vdir).unwrap();
250        std::fs::write(vdir.join("a.md"), "# A\n").unwrap();
251        std::fs::write(vdir.join("b.md"), "# B\n").unwrap();
252        // Create installed copies
253        let agents_dir = tmp.path().join(".claude/agents");
254        std::fs::create_dir_all(&agents_dir).unwrap();
255        std::fs::write(agents_dir.join("a.md"), "# A\n").unwrap();
256        std::fs::write(agents_dir.join("b.md"), "# B\n").unwrap();
257
258        let result = installed_dir_files(&entry, &manifest, tmp.path()).unwrap();
259        assert_eq!(result.len(), 2);
260    }
261
262    #[test]
263    fn source_path_local() {
264        let tmp = tempfile::tempdir().unwrap();
265        let entry = Entry {
266            entity_type: EntityType::Skill,
267            name: "test".into(),
268            source: SourceFields::Local {
269                path: "skills/test.md".into(),
270            },
271        };
272        let result = source_path(&entry, tmp.path());
273        assert_eq!(result, Some(tmp.path().join("skills/test.md")));
274    }
275
276    #[test]
277    fn source_path_github_single() {
278        let tmp = tempfile::tempdir().unwrap();
279        let entry = Entry {
280            entity_type: EntityType::Agent,
281            name: "test".into(),
282            source: SourceFields::Github {
283                owner_repo: "o/r".into(),
284                path_in_repo: "agents/test.md".into(),
285                ref_: "main".into(),
286            },
287        };
288        let vdir = tmp.path().join(".skillfile/cache/agents/test");
289        std::fs::create_dir_all(&vdir).unwrap();
290        std::fs::write(vdir.join("test.md"), "# Test\n").unwrap();
291
292        let result = source_path(&entry, tmp.path());
293        assert_eq!(result, Some(vdir.join("test.md")));
294    }
295
296    #[test]
297    fn known_adapters_includes_claude_code() {
298        // resolve_target_dir only succeeds for known adapters; a successful
299        // call is sufficient proof that "claude-code" is registered.
300        let ctx = AdapterScope {
301            scope: Scope::Global,
302            repo_root: Path::new("/tmp"),
303        };
304        assert!(resolve_target_dir("claude-code", EntityType::Skill, &ctx).is_ok());
305    }
306}