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
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
22/// Installed path for a single-file entry (first install target).
23pub 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
36/// Installed paths for a single-file entry across all install targets.
37pub 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
57/// Installed files for a directory entry (first install target).
58pub 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
71/// Installed file maps for a directory entry across all install targets.
72pub 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        // Create vendor cache
360        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        // Create installed copies
365        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        // resolve_target_dir only succeeds for known adapters; a successful
411        // call is sufficient proof that "claude-code" is registered.
412        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}