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
11pub fn resolve_target_dir(
12 adapter_name: &str,
13 entity_type: EntityType,
14 ctx: &AdapterScope<'_>,
15) -> Result<PathBuf, SkillfileError> {
16 let a = adapters()
17 .get(adapter_name)
18 .ok_or_else(|| SkillfileError::Manifest(format!("unknown adapter '{adapter_name}'")))?;
19 Ok(a.target_dir(entity_type, ctx))
20}
21
22pub fn installed_path(
24 entry: &Entry,
25 manifest: &Manifest,
26 repo_root: &Path,
27) -> Result<PathBuf, SkillfileError> {
28 let adapter = first_target(manifest)?;
29 let ctx = AdapterScope {
30 scope: manifest.install_targets[0].scope,
31 repo_root,
32 };
33 Ok(adapter.installed_path(entry, &ctx))
34}
35
36pub fn installed_paths(
38 entry: &Entry,
39 manifest: &Manifest,
40 repo_root: &Path,
41) -> Result<Vec<PathBuf>, SkillfileError> {
42 let mut paths = Vec::new();
43 for target in &manifest.install_targets {
44 let adapter = adapter_for(target)?;
45 if !adapter.supports(entry.entity_type) {
46 continue;
47 }
48 let ctx = AdapterScope {
49 scope: target.scope,
50 repo_root,
51 };
52 paths.push(adapter.installed_path(entry, &ctx));
53 }
54 Ok(paths)
55}
56
57pub fn installed_dir_files(
59 entry: &Entry,
60 manifest: &Manifest,
61 repo_root: &Path,
62) -> Result<HashMap<String, PathBuf>, SkillfileError> {
63 let adapter = first_target(manifest)?;
64 let ctx = AdapterScope {
65 scope: manifest.install_targets[0].scope,
66 repo_root,
67 };
68 Ok(adapter.installed_dir_files(entry, &ctx))
69}
70
71pub fn installed_dir_file_sets(
73 entry: &Entry,
74 manifest: &Manifest,
75 repo_root: &Path,
76) -> Result<Vec<HashMap<String, PathBuf>>, SkillfileError> {
77 let mut file_sets = Vec::new();
78 for target in &manifest.install_targets {
79 let adapter = adapter_for(target)?;
80 if !adapter.supports(entry.entity_type) {
81 continue;
82 }
83 let ctx = AdapterScope {
84 scope: target.scope,
85 repo_root,
86 };
87 file_sets.push(adapter.installed_dir_files(entry, &ctx));
88 }
89 Ok(file_sets)
90}
91
92#[must_use]
93pub fn source_path(entry: &Entry, repo_root: &Path) -> Option<PathBuf> {
94 match &entry.source {
95 SourceFields::Local { path } => Some(repo_root.join(path)),
96 SourceFields::Github { .. } | SourceFields::Url { .. } => {
97 source_path_remote(entry, repo_root)
98 }
99 }
100}
101
102fn source_path_remote(entry: &Entry, repo_root: &Path) -> Option<PathBuf> {
103 let vdir = vendor_dir_for(entry, repo_root);
104 if is_dir_entry(entry) {
105 vdir.exists().then_some(vdir)
106 } else {
107 let filename = content_file(entry);
108 (!filename.is_empty()).then(|| vdir.join(filename))
109 }
110}
111
112fn first_target(manifest: &Manifest) -> Result<&'static dyn PlatformAdapter, SkillfileError> {
113 if manifest.install_targets.is_empty() {
114 return Err(SkillfileError::Manifest(
115 "no install targets configured — run `skillfile install` first".into(),
116 ));
117 }
118 adapter_for(&manifest.install_targets[0])
119}
120
121fn adapter_for(
122 target: &skillfile_core::models::InstallTarget,
123) -> Result<&'static dyn PlatformAdapter, SkillfileError> {
124 adapters()
125 .get(&target.adapter)
126 .ok_or_else(|| SkillfileError::Manifest(format!("unknown adapter '{}'", target.adapter)))
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::adapter::AdapterScope;
133 use skillfile_core::models::{EntityType, InstallTarget, Scope};
134
135 #[test]
136 fn resolve_target_dir_global() {
137 let ctx = AdapterScope {
138 scope: Scope::Global,
139 repo_root: Path::new("/tmp"),
140 };
141 let result = resolve_target_dir("claude-code", EntityType::Agent, &ctx).unwrap();
142 assert!(result.to_string_lossy().ends_with(".claude/agents"));
143 }
144
145 #[test]
146 fn resolve_target_dir_local() {
147 let tmp = tempfile::tempdir().unwrap();
148 let ctx = AdapterScope {
149 scope: Scope::Local,
150 repo_root: tmp.path(),
151 };
152 let result = resolve_target_dir("claude-code", EntityType::Agent, &ctx).unwrap();
153 assert_eq!(result, tmp.path().join(".claude/agents"));
154 }
155
156 #[test]
157 fn installed_path_no_targets() {
158 let entry = Entry {
159 entity_type: EntityType::Agent,
160 name: "test".into(),
161 source: SourceFields::Github {
162 owner_repo: "o/r".into(),
163 path_in_repo: "a.md".into(),
164 ref_: "main".into(),
165 },
166 };
167 let manifest = Manifest {
168 entries: vec![entry.clone()],
169 install_targets: vec![],
170 };
171 let result = installed_path(&entry, &manifest, Path::new("/tmp"));
172 assert!(result.is_err());
173 assert!(result
174 .unwrap_err()
175 .to_string()
176 .contains("no install targets"));
177 }
178
179 #[test]
180 fn installed_path_unknown_adapter() {
181 let entry = Entry {
182 entity_type: EntityType::Agent,
183 name: "test".into(),
184 source: SourceFields::Github {
185 owner_repo: "o/r".into(),
186 path_in_repo: "a.md".into(),
187 ref_: "main".into(),
188 },
189 };
190 let manifest = Manifest {
191 entries: vec![entry.clone()],
192 install_targets: vec![InstallTarget {
193 adapter: "unknown".into(),
194 scope: Scope::Global,
195 }],
196 };
197 let result = installed_path(&entry, &manifest, Path::new("/tmp"));
198 assert!(result.is_err());
199 assert!(result.unwrap_err().to_string().contains("unknown adapter"));
200 }
201
202 #[test]
203 fn installed_path_returns_correct_path() {
204 let tmp = tempfile::tempdir().unwrap();
205 let entry = Entry {
206 entity_type: EntityType::Agent,
207 name: "test".into(),
208 source: SourceFields::Github {
209 owner_repo: "o/r".into(),
210 path_in_repo: "a.md".into(),
211 ref_: "main".into(),
212 },
213 };
214 let manifest = Manifest {
215 entries: vec![entry.clone()],
216 install_targets: vec![InstallTarget {
217 adapter: "claude-code".into(),
218 scope: Scope::Local,
219 }],
220 };
221 let result = installed_path(&entry, &manifest, tmp.path()).unwrap();
222 assert_eq!(result, tmp.path().join(".claude/agents/test.md"));
223 }
224
225 #[test]
226 fn installed_paths_returns_all_targets() {
227 let tmp = tempfile::tempdir().unwrap();
228 let entry = Entry {
229 entity_type: EntityType::Skill,
230 name: "test".into(),
231 source: SourceFields::Github {
232 owner_repo: "o/r".into(),
233 path_in_repo: "skills/test.md".into(),
234 ref_: "main".into(),
235 },
236 };
237 let manifest = Manifest {
238 entries: vec![entry.clone()],
239 install_targets: vec![
240 InstallTarget {
241 adapter: "claude-code".into(),
242 scope: Scope::Local,
243 },
244 InstallTarget {
245 adapter: "copilot".into(),
246 scope: Scope::Local,
247 },
248 ],
249 };
250
251 let result = installed_paths(&entry, &manifest, tmp.path()).unwrap();
252 assert_eq!(result.len(), 2);
253 assert!(result.contains(&tmp.path().join(".claude/skills/test/SKILL.md")));
254 assert!(result.contains(&tmp.path().join(".github/skills/test/SKILL.md")));
255 }
256
257 #[test]
258 fn installed_dir_files_no_targets() {
259 let entry = Entry {
260 entity_type: EntityType::Agent,
261 name: "test".into(),
262 source: SourceFields::Github {
263 owner_repo: "o/r".into(),
264 path_in_repo: "agents".into(),
265 ref_: "main".into(),
266 },
267 };
268 let manifest = Manifest {
269 entries: vec![entry.clone()],
270 install_targets: vec![],
271 };
272 let result = installed_dir_files(&entry, &manifest, Path::new("/tmp"));
273 assert!(result.is_err());
274 }
275
276 #[test]
277 fn installed_dir_files_skill_dir() {
278 let tmp = tempfile::tempdir().unwrap();
279 let entry = Entry {
280 entity_type: EntityType::Skill,
281 name: "my-skill".into(),
282 source: SourceFields::Github {
283 owner_repo: "o/r".into(),
284 path_in_repo: "skills".into(),
285 ref_: "main".into(),
286 },
287 };
288 let manifest = Manifest {
289 entries: vec![entry.clone()],
290 install_targets: vec![InstallTarget {
291 adapter: "claude-code".into(),
292 scope: Scope::Local,
293 }],
294 };
295 let skill_dir = tmp.path().join(".claude/skills/my-skill");
296 std::fs::create_dir_all(&skill_dir).unwrap();
297 std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
298
299 let result = installed_dir_files(&entry, &manifest, tmp.path()).unwrap();
300 assert!(result.contains_key("SKILL.md"));
301 }
302
303 #[test]
304 fn installed_dir_file_sets_returns_all_targets() {
305 let tmp = tempfile::tempdir().unwrap();
306 let entry = Entry {
307 entity_type: EntityType::Skill,
308 name: "my-skill".into(),
309 source: SourceFields::Github {
310 owner_repo: "o/r".into(),
311 path_in_repo: "skills".into(),
312 ref_: "main".into(),
313 },
314 };
315 let manifest = Manifest {
316 entries: vec![entry.clone()],
317 install_targets: vec![
318 InstallTarget {
319 adapter: "claude-code".into(),
320 scope: Scope::Local,
321 },
322 InstallTarget {
323 adapter: "copilot".into(),
324 scope: Scope::Local,
325 },
326 ],
327 };
328 let claude_dir = tmp.path().join(".claude/skills/my-skill");
329 let copilot_dir = tmp.path().join(".github/skills/my-skill");
330 std::fs::create_dir_all(&claude_dir).unwrap();
331 std::fs::create_dir_all(&copilot_dir).unwrap();
332 std::fs::write(claude_dir.join("SKILL.md"), "# Skill\n").unwrap();
333 std::fs::write(copilot_dir.join("SKILL.md"), "# Skill\n").unwrap();
334
335 let result = installed_dir_file_sets(&entry, &manifest, tmp.path()).unwrap();
336 assert_eq!(result.len(), 2);
337 assert!(result.iter().all(|files| files.contains_key("SKILL.md")));
338 }
339
340 #[test]
341 fn installed_dir_files_agent_dir() {
342 let tmp = tempfile::tempdir().unwrap();
343 let entry = Entry {
344 entity_type: EntityType::Agent,
345 name: "my-agents".into(),
346 source: SourceFields::Github {
347 owner_repo: "o/r".into(),
348 path_in_repo: "agents".into(),
349 ref_: "main".into(),
350 },
351 };
352 let manifest = Manifest {
353 entries: vec![entry.clone()],
354 install_targets: vec![InstallTarget {
355 adapter: "claude-code".into(),
356 scope: Scope::Local,
357 }],
358 };
359 let vdir = tmp.path().join(".skillfile/cache/agents/my-agents");
361 std::fs::create_dir_all(&vdir).unwrap();
362 std::fs::write(vdir.join("a.md"), "# A\n").unwrap();
363 std::fs::write(vdir.join("b.md"), "# B\n").unwrap();
364 let agents_dir = tmp.path().join(".claude/agents");
366 std::fs::create_dir_all(&agents_dir).unwrap();
367 std::fs::write(agents_dir.join("a.md"), "# A\n").unwrap();
368 std::fs::write(agents_dir.join("b.md"), "# B\n").unwrap();
369
370 let result = installed_dir_files(&entry, &manifest, tmp.path()).unwrap();
371 assert_eq!(result.len(), 2);
372 }
373
374 #[test]
375 fn source_path_local() {
376 let tmp = tempfile::tempdir().unwrap();
377 let entry = Entry {
378 entity_type: EntityType::Skill,
379 name: "test".into(),
380 source: SourceFields::Local {
381 path: "skills/test.md".into(),
382 },
383 };
384 let result = source_path(&entry, tmp.path());
385 assert_eq!(result, Some(tmp.path().join("skills/test.md")));
386 }
387
388 #[test]
389 fn source_path_github_single() {
390 let tmp = tempfile::tempdir().unwrap();
391 let entry = Entry {
392 entity_type: EntityType::Agent,
393 name: "test".into(),
394 source: SourceFields::Github {
395 owner_repo: "o/r".into(),
396 path_in_repo: "agents/test.md".into(),
397 ref_: "main".into(),
398 },
399 };
400 let vdir = tmp.path().join(".skillfile/cache/agents/test");
401 std::fs::create_dir_all(&vdir).unwrap();
402 std::fs::write(vdir.join("test.md"), "# Test\n").unwrap();
403
404 let result = source_path(&entry, tmp.path());
405 assert_eq!(result, Some(vdir.join("test.md")));
406 }
407
408 #[test]
409 fn known_adapters_includes_claude_code() {
410 let ctx = AdapterScope {
413 scope: Scope::Global,
414 repo_root: Path::new("/tmp"),
415 };
416 assert!(resolve_target_dir("claude-code", EntityType::Skill, &ctx).is_ok());
417 }
418}