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 files for a directory entry (first install target).
37pub 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        // Create vendor cache
244        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        // Create installed copies
249        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        // resolve_target_dir only succeeds for known adapters; a successful
295        // call is sufficient proof that "claude-code" is registered.
296        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}