1use crate::manifest::{DetailedDependency, ResourceDependency};
7use crate::utils::normalize_path_for_storage;
8use anyhow::{Context, Result, anyhow};
9use glob::Pattern;
10use std::path::Path;
11use tokio::fs as async_fs;
12
13pub async fn match_skill_directories(
52 base_path: &Path,
53 pattern: &str,
54 strip_prefix: Option<&Path>,
55) -> Result<Vec<(String, String)>> {
56 let mut matches = Vec::new();
57
58 let skill_pattern = pattern.strip_prefix("skills/").unwrap_or(pattern);
60
61 let glob_pattern = Pattern::new(skill_pattern)
64 .map_err(|e| anyhow!("Invalid skill pattern '{}': {}", skill_pattern, e))?;
65
66 let skills_base_path = base_path.join("skills");
67
68 let metadata = match async_fs::metadata(&skills_base_path).await {
70 Ok(m) => m,
71 Err(_) => {
72 tracing::debug!("Skills directory not found at {}", skills_base_path.display());
73 return Ok(matches);
74 }
75 };
76
77 if !metadata.is_dir() {
78 tracing::debug!("Skills path is not a directory: {}", skills_base_path.display());
79 return Ok(matches);
80 }
81
82 let mut entries = async_fs::read_dir(&skills_base_path).await?;
83 while let Some(entry) = entries.next_entry().await? {
84 let path = entry.path();
85
86 let entry_metadata = match async_fs::metadata(&path).await {
88 Ok(m) => m,
89 Err(_) => continue,
90 };
91
92 if !entry_metadata.is_dir() {
93 continue;
94 }
95
96 let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
97
98 if !glob_pattern.matches(dir_name) {
100 continue;
101 }
102
103 let skill_md_path = path.join("SKILL.md");
105 if async_fs::metadata(&skill_md_path).await.is_err() {
106 tracing::warn!("Skipping directory {} - does not contain SKILL.md", path.display());
107 continue;
108 }
109
110 let resource_name = dir_name.to_string();
111
112 let concrete_path = if let Some(prefix) = strip_prefix {
115 let relative = path.strip_prefix(prefix).with_context(|| {
117 format!(
118 "Failed to strip prefix '{}' from skill path '{}'. \
119 This may indicate a path traversal attempt or misconfigured source.",
120 prefix.display(),
121 path.display()
122 )
123 })?;
124 normalize_path_for_storage(relative)
125 } else {
126 normalize_path_for_storage(&path)
127 };
128
129 matches.push((resource_name, concrete_path));
130 }
131
132 Ok(matches)
133}
134
135pub fn create_skill_dependency(
151 resource_name: String,
152 path: String,
153 source: Option<String>,
154 parent_dep: Option<&ResourceDependency>,
155) -> (String, ResourceDependency) {
156 let (tool, target, flatten, version) = if let Some(dep) = parent_dep {
157 match dep {
158 ResourceDependency::Detailed(d) => (
159 d.tool.clone(),
160 d.target.clone(),
161 d.flatten,
162 dep.get_version().map(std::string::ToString::to_string),
163 ),
164 _ => (None, None, None, None),
165 }
166 } else {
167 (None, None, None, None)
168 };
169
170 (
171 resource_name,
172 ResourceDependency::Detailed(Box::new(DetailedDependency {
173 source,
174 path,
175 version,
176 branch: None,
177 rev: None,
178 command: None,
179 args: None,
180 target,
181 filename: None,
182 dependencies: None,
183 tool,
184 flatten,
185 install: None,
186 template_vars: None,
187 })),
188 )
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn test_glob_pattern_wildcard() {
197 let pattern = Pattern::new("*").unwrap();
198 assert!(pattern.matches("any-name"));
199 assert!(pattern.matches("skill-1"));
200 assert!(pattern.matches(""));
201 }
202
203 #[test]
204 fn test_glob_pattern_exact() {
205 let pattern = Pattern::new("my-skill").unwrap();
206 assert!(pattern.matches("my-skill"));
207 assert!(!pattern.matches("other-skill"));
208 assert!(!pattern.matches("my-skill-extended"));
209 }
210
211 #[test]
212 fn test_glob_pattern_prefix() {
213 let pattern = Pattern::new("ai-*").unwrap();
214 assert!(pattern.matches("ai-helper"));
215 assert!(pattern.matches("ai-assistant"));
216 assert!(pattern.matches("ai-"));
217 assert!(!pattern.matches("helper-ai"));
218 assert!(!pattern.matches("ai"));
219 }
220
221 #[test]
222 fn test_glob_pattern_suffix() {
223 let pattern = Pattern::new("*-helper").unwrap();
224 assert!(pattern.matches("ai-helper"));
225 assert!(pattern.matches("test-helper"));
226 assert!(!pattern.matches("helper"));
227 assert!(!pattern.matches("helper-test"));
228 }
229
230 #[test]
231 fn test_glob_pattern_character_class() {
232 let pattern = Pattern::new("test-[0-9]*").unwrap();
233 assert!(pattern.matches("test-1"));
234 assert!(pattern.matches("test-123"));
235 assert!(pattern.matches("test-9-foo"));
236 assert!(!pattern.matches("test-abc"));
237 assert!(!pattern.matches("test-"));
238 }
239
240 #[test]
241 fn test_create_skill_dependency_no_parent() {
242 let (name, dep) = create_skill_dependency(
243 "test-skill".to_string(),
244 "skills/test-skill".to_string(),
245 Some("community".to_string()),
246 None,
247 );
248
249 assert_eq!(name, "test-skill");
250 match dep {
251 ResourceDependency::Detailed(d) => {
252 assert_eq!(d.path, "skills/test-skill");
253 assert_eq!(d.source, Some("community".to_string()));
254 assert_eq!(d.tool, None);
255 assert_eq!(d.target, None);
256 assert_eq!(d.flatten, None);
257 }
258 _ => panic!("Expected Detailed dependency"),
259 }
260 }
261
262 #[test]
263 fn test_create_skill_dependency_with_parent() {
264 let parent = ResourceDependency::Detailed(Box::new(DetailedDependency {
265 source: Some("test".to_string()),
266 path: "skills/*".to_string(),
267 version: Some("v1.0.0".to_string()),
268 branch: None,
269 rev: None,
270 command: None,
271 args: None,
272 target: Some(".custom/skills".to_string()),
273 filename: None,
274 dependencies: None,
275 template_vars: None,
276 tool: Some("claude-code".to_string()),
277 flatten: Some(true),
278 install: None,
279 }));
280
281 let (name, dep) = create_skill_dependency(
282 "test-skill".to_string(),
283 "skills/test-skill".to_string(),
284 Some("community".to_string()),
285 Some(&parent),
286 );
287
288 assert_eq!(name, "test-skill");
289 match dep {
290 ResourceDependency::Detailed(d) => {
291 assert_eq!(d.path, "skills/test-skill");
292 assert_eq!(d.source, Some("community".to_string()));
293 assert_eq!(d.tool, Some("claude-code".to_string()));
294 assert_eq!(d.target, Some(".custom/skills".to_string()));
295 assert_eq!(d.flatten, Some(true));
296 assert_eq!(d.version, Some("v1.0.0".to_string()));
297 }
298 _ => panic!("Expected Detailed dependency"),
299 }
300 }
301}