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