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