use crate::manifest::{DetailedDependency, ResourceDependency};
use crate::utils::normalize_path_for_storage;
use anyhow::{Context, Result, anyhow};
use glob::Pattern;
use std::path::Path;
use tokio::fs as async_fs;
pub async fn match_skill_directories(
base_path: &Path,
pattern: &str,
strip_prefix: Option<&Path>,
) -> Result<Vec<(String, String)>> {
let mut matches = Vec::new();
let skill_pattern = pattern.strip_prefix("skills/").unwrap_or(pattern);
let glob_pattern = Pattern::new(skill_pattern)
.map_err(|e| anyhow!("Invalid skill pattern '{}': {}", skill_pattern, e))?;
let skills_base_path = base_path.join("skills");
let metadata = match async_fs::metadata(&skills_base_path).await {
Ok(m) => m,
Err(_) => {
tracing::debug!("Skills directory not found at {}", skills_base_path.display());
return Ok(matches);
}
};
if !metadata.is_dir() {
tracing::debug!("Skills path is not a directory: {}", skills_base_path.display());
return Ok(matches);
}
let mut entries = async_fs::read_dir(&skills_base_path).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let entry_metadata = match async_fs::metadata(&path).await {
Ok(m) => m,
Err(_) => continue,
};
if !entry_metadata.is_dir() {
continue;
}
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if !glob_pattern.matches(dir_name) {
continue;
}
let skill_md_path = path.join("SKILL.md");
if async_fs::metadata(&skill_md_path).await.is_err() {
tracing::warn!("Skipping directory {} - does not contain SKILL.md", path.display());
continue;
}
let resource_name = dir_name.to_string();
let concrete_path = if let Some(prefix) = strip_prefix {
let relative = path.strip_prefix(prefix).with_context(|| {
format!(
"Failed to strip prefix '{}' from skill path '{}'. \
This may indicate a path traversal attempt or misconfigured source.",
prefix.display(),
path.display()
)
})?;
normalize_path_for_storage(relative)
} else {
normalize_path_for_storage(&path)
};
matches.push((resource_name, concrete_path));
}
Ok(matches)
}
pub fn create_skill_dependency(
resource_name: String,
path: String,
source: Option<String>,
parent_dep: Option<&ResourceDependency>,
) -> (String, ResourceDependency) {
let (tool, target, flatten, version) = if let Some(dep) = parent_dep {
match dep {
ResourceDependency::Detailed(d) => (
d.tool.clone(),
d.target.clone(),
d.flatten,
dep.get_version().map(std::string::ToString::to_string),
),
_ => (None, None, None, None),
}
} else {
(None, None, None, None)
};
(
resource_name,
ResourceDependency::Detailed(Box::new(DetailedDependency {
source,
path,
version,
branch: None,
rev: None,
command: None,
args: None,
target,
filename: None,
dependencies: None,
tool,
flatten,
install: None,
template_vars: None,
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glob_pattern_wildcard() {
let pattern = Pattern::new("*").unwrap();
assert!(pattern.matches("any-name"));
assert!(pattern.matches("skill-1"));
assert!(pattern.matches(""));
}
#[test]
fn test_glob_pattern_exact() {
let pattern = Pattern::new("my-skill").unwrap();
assert!(pattern.matches("my-skill"));
assert!(!pattern.matches("other-skill"));
assert!(!pattern.matches("my-skill-extended"));
}
#[test]
fn test_glob_pattern_prefix() {
let pattern = Pattern::new("ai-*").unwrap();
assert!(pattern.matches("ai-helper"));
assert!(pattern.matches("ai-assistant"));
assert!(pattern.matches("ai-"));
assert!(!pattern.matches("helper-ai"));
assert!(!pattern.matches("ai"));
}
#[test]
fn test_glob_pattern_suffix() {
let pattern = Pattern::new("*-helper").unwrap();
assert!(pattern.matches("ai-helper"));
assert!(pattern.matches("test-helper"));
assert!(!pattern.matches("helper"));
assert!(!pattern.matches("helper-test"));
}
#[test]
fn test_glob_pattern_character_class() {
let pattern = Pattern::new("test-[0-9]*").unwrap();
assert!(pattern.matches("test-1"));
assert!(pattern.matches("test-123"));
assert!(pattern.matches("test-9-foo"));
assert!(!pattern.matches("test-abc"));
assert!(!pattern.matches("test-"));
}
#[test]
fn test_create_skill_dependency_no_parent() {
let (name, dep) = create_skill_dependency(
"test-skill".to_string(),
"skills/test-skill".to_string(),
Some("community".to_string()),
None,
);
assert_eq!(name, "test-skill");
match dep {
ResourceDependency::Detailed(d) => {
assert_eq!(d.path, "skills/test-skill");
assert_eq!(d.source, Some("community".to_string()));
assert_eq!(d.tool, None);
assert_eq!(d.target, None);
assert_eq!(d.flatten, None);
}
_ => panic!("Expected Detailed dependency"),
}
}
#[test]
fn test_create_skill_dependency_with_parent() {
let parent = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test".to_string()),
path: "skills/*".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: Some(".custom/skills".to_string()),
filename: None,
dependencies: None,
template_vars: None,
tool: Some("claude-code".to_string()),
flatten: Some(true),
install: None,
}));
let (name, dep) = create_skill_dependency(
"test-skill".to_string(),
"skills/test-skill".to_string(),
Some("community".to_string()),
Some(&parent),
);
assert_eq!(name, "test-skill");
match dep {
ResourceDependency::Detailed(d) => {
assert_eq!(d.path, "skills/test-skill");
assert_eq!(d.source, Some("community".to_string()));
assert_eq!(d.tool, Some("claude-code".to_string()));
assert_eq!(d.target, Some(".custom/skills".to_string()));
assert_eq!(d.flatten, Some(true));
assert_eq!(d.version, Some("v1.0.0".to_string()));
}
_ => panic!("Expected Detailed dependency"),
}
}
}