agpm_cli/resolver/
skills.rs

1//! Skills-specific resolution logic for pattern matching and dependency handling.
2//!
3//! This module contains specialized logic for resolving skill dependencies, which are
4//! directory-based resources requiring special handling compared to file-based resources.
5
6use 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
13/// Match skill directories in a base path that conform to a pattern.
14///
15/// Skills are directory-based resources that must contain a SKILL.md file.
16/// This function finds all directories matching the given pattern that are valid skills.
17///
18/// Supports full glob pattern syntax:
19/// - `*` - matches all skills
20/// - Exact name - matches single skill (e.g., `my-skill`)
21/// - Glob patterns - e.g., `ai-*`, `*-helper`, `test-[0-9]*`
22///
23/// # Arguments
24///
25/// * `base_path` - The base directory containing the skills/ subdirectory
26/// * `pattern` - The glob pattern to match (e.g., "*", "my-skill", "ai-*")
27/// * `strip_prefix` - Optional prefix to strip from matched paths (for Git sources)
28///
29/// # Returns
30///
31/// A vector of tuples containing (resource_name, absolute_path) for each matched skill
32///
33/// # Examples
34///
35/// ```no_run
36/// use agpm_cli::resolver::skills::match_skill_directories;
37/// use std::path::Path;
38///
39/// # async fn example() -> anyhow::Result<()> {
40/// // Match all skills
41/// let all = match_skill_directories(Path::new("/repo"), "*", None).await?;
42///
43/// // Match AI-related skills
44/// let ai = match_skill_directories(Path::new("/repo"), "ai-*", None).await?;
45///
46/// // Match specific skill
47/// let one = match_skill_directories(Path::new("/repo"), "my-skill", None).await?;
48/// # Ok(())
49/// # }
50/// ```
51pub 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    // Extract the skill-specific pattern (remove "skills/" prefix if present)
59    let skill_pattern = pattern.strip_prefix("skills/").unwrap_or(pattern);
60
61    // Compile the glob pattern FIRST (fail fast before any filesystem operations)
62    // This avoids wasting I/O on invalid patterns
63    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    // Check if skills directory exists using async I/O
69    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        // Check if it's a directory using async metadata
87        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        // Check if this directory matches the glob pattern
99        if !glob_pattern.matches(dir_name) {
100            continue;
101        }
102
103        // Check if it contains SKILL.md using async I/O
104        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        // Compute the path, optionally stripping a prefix
113        // Use normalized paths (forward slashes) for cross-platform compatibility
114        let concrete_path = if let Some(prefix) = strip_prefix {
115            // Properly handle strip_prefix failure - could indicate path traversal or misconfiguration
116            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
135/// Create a detailed dependency for a skill.
136///
137/// This helper creates a properly formatted DetailedDependency for a skill resource,
138/// inheriting settings from the parent dependency if provided.
139///
140/// # Arguments
141///
142/// * `resource_name` - The name of the skill resource
143/// * `path` - The path to the skill directory
144/// * `source` - Optional source name for Git-based skills
145/// * `parent_dep` - Optional parent dependency to inherit tool/target/flatten settings
146///
147/// # Returns
148///
149/// A ResourceDependency::Detailed variant configured for the skill
150pub 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}