agpm_cli/resolver/
pattern_expander.rs

1//! Pattern expansion for AGPM dependencies.
2//!
3//! This module handles expansion of glob patterns to concrete file paths,
4//! converting pattern dependencies (like "agents/*.md") into individual file
5//! dependencies. It supports both local and remote pattern resolution with
6//! proper path handling, dependency naming, and locked resource generation.
7
8use crate::git::GitRepo;
9use crate::manifest::{DetailedDependency, ResourceDependency};
10use crate::pattern::PatternResolver;
11use crate::resolver::version_resolver::PreparedSourceVersion;
12use crate::utils::normalize_path_for_storage;
13use anyhow::{Context, Result};
14use dashmap::DashMap;
15use std::path::{Path, PathBuf};
16use tracing::debug;
17
18/// Expands a pattern dependency into concrete dependencies.
19///
20/// This function is the core engine for pattern-based dependency resolution,
21/// handling both local and remote patterns to generate specific resource
22/// dependencies that can be fetched and installed.
23///
24/// # Pattern Types Supported
25///
26/// ## Local Patterns
27/// - Relative paths: `local/agents/*.md`, `./snippets/*.toml`
28/// - Absolute paths: `/home/user/resources/*`
29/// - Directory patterns: `tools/*/bin`
30///
31/// ## Remote Patterns
32/// - Git repository patterns: `repo:agents/*`, `source:tools/*.py`
33/// - Version-constrained patterns: `repo:agents/*@v2.0.0`
34///
35/// # Resolution Strategy
36///
37/// 1. **Pattern Analysis**: Parse pattern to identify base path and glob
38/// 2. **Source Resolution**: For remote patterns, resolve source name
39/// 3. **Resource Discovery**: Use pattern to find matching resources
40/// 4. **Dependency Generation**: Create concrete dependencies for each resource
41/// 5. **Version Application**: Apply version constraints to remote patterns
42///
43/// # Parameters
44///
45/// * `dep` - The pattern dependency to expand
46/// * `resource_type` - Type of resource (agent, snippet, command, etc.)
47/// * `source_manager` - Source management instance for remote patterns
48/// * `cache` - Cache instance for repository access
49/// * `manifest_dir` - Optional manifest directory for local pattern resolution
50/// * `prepared_versions` - Pre-resolved versions for performance optimization
51///
52/// # Returns
53///
54/// Vector of tuples containing:
55/// - Generated dependency name
56/// - Concrete resource dependency ready for installation
57///
58/// # Examples
59///
60/// ```rust,no_run
61/// use agpm_cli::resolver::pattern_expander::expand_pattern_to_concrete_deps;
62/// use agpm_cli::source::SourceManager;
63/// use agpm_cli::cache::Cache;
64/// use agpm_cli::manifest::{DetailedDependency, ResourceDependency};
65/// use agpm_cli::core::ResourceType;
66/// use std::path::Path;
67///
68/// # async fn example() -> anyhow::Result<()> {
69/// # let source_manager = SourceManager::new()?;
70/// # let cache = Cache::new()?;
71/// # let pattern_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
72/// #     path: "agents/*.md".to_string(),
73/// #     source: Some("community".to_string()),
74/// #     version: None,
75/// #     branch: None,
76/// #     rev: None,
77/// #     command: None,
78/// #     args: None,
79/// #     target: None,
80/// #     filename: None,
81/// #     dependencies: None,
82/// #     tool: None,
83/// #     flatten: None,
84/// #     install: None,
85/// #     template_vars: None,
86/// # }));
87/// let deps = expand_pattern_to_concrete_deps(
88///     &pattern_dep,           // Pattern dependency
89///     ResourceType::Agent,     // Resource type
90///     &source_manager,         // For remote sources
91///     &cache,                // For repository access
92///     Some(Path::new("/project")), // For local resolution
93///     None,                  // No pre-prepared versions
94/// ).await?;
95/// # Ok(())
96/// # }
97/// ```
98///
99/// # Performance Considerations
100///
101/// - Remote patterns trigger a single repository fetch, then cache multiple resources
102/// - Local patterns scan filesystem without network operations
103/// - Prepared versions enable SHA reuse to avoid redundant Git operations
104/// - The function returns a complete dependency set ready for installer processing
105pub async fn expand_pattern_to_concrete_deps(
106    dep: &ResourceDependency,
107    resource_type: crate::core::ResourceType,
108    source_manager: &crate::source::SourceManager,
109    cache: &crate::cache::Cache,
110    manifest_dir: Option<&Path>,
111    prepared_versions: Option<&DashMap<String, PreparedSourceVersion>>,
112) -> Result<Vec<(String, ResourceDependency)>> {
113    let pattern = dep.get_path();
114
115    if dep.is_local() {
116        expand_local_pattern(dep, pattern, resource_type, manifest_dir).await
117    } else {
118        expand_remote_pattern(dep, pattern, resource_type, source_manager, cache, prepared_versions)
119            .await
120    }
121}
122
123/// Expands a local pattern dependency.
124async fn expand_local_pattern(
125    dep: &ResourceDependency,
126    pattern: &str,
127    resource_type: crate::core::ResourceType,
128    manifest_dir: Option<&Path>,
129) -> Result<Vec<(String, ResourceDependency)>> {
130    // For absolute patterns, use the parent directory as base and strip the pattern to just the filename part
131    // For relative patterns, use manifest directory
132    let pattern_path = Path::new(pattern);
133    let (base_path, search_pattern) = if pattern_path.is_absolute() {
134        // Absolute pattern: extract base directory and relative pattern
135        // Example: "/tmp/xyz/agents/*.md" -> base="/tmp/xyz", pattern="agents/*.md"
136        let components: Vec<_> = pattern_path.components().collect();
137
138        // Find the first component with a glob character
139        let glob_idx = components.iter().position(|c| {
140            let s = c.as_os_str().to_string_lossy();
141            s.contains('*') || s.contains('?') || s.contains('[')
142        });
143
144        if let Some(idx) = glob_idx {
145            // Split at the glob component
146            let base_components = &components[..idx];
147            let pattern_components = &components[idx..];
148
149            let base: PathBuf = base_components.iter().collect();
150            let pattern: String = pattern_components
151                .iter()
152                .map(|c| c.as_os_str().to_string_lossy())
153                .collect::<Vec<_>>()
154                .join("/");
155
156            (base, pattern)
157        } else {
158            // No glob characters, use as-is
159            (PathBuf::from("."), pattern.to_string())
160        }
161    } else {
162        // Relative pattern, use manifest directory as base
163        let base = manifest_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
164        (base, pattern.to_string())
165    };
166
167    // Get tool, target, and flatten from parent pattern dependency
168    let (tool, target, flatten) = match dep {
169        ResourceDependency::Detailed(d) => (d.tool.clone(), d.target.clone(), d.flatten),
170        _ => (None, None, None),
171    };
172
173    let mut concrete_deps = Vec::new();
174
175    // Skills are directory-based, so use special directory matching
176    if resource_type == crate::core::ResourceType::Skill {
177        let skill_matches = crate::resolver::skills::match_skill_directories(
178            &base_path,
179            &search_pattern,
180            None, // No strip prefix for local patterns
181        )
182        .await?;
183
184        debug!("Local skill pattern '{}' matched {} directories", pattern, skill_matches.len());
185
186        for (skill_name, skill_path) in skill_matches {
187            // Create a concrete dependency for the matched skill directory
188            let concrete_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
189                path: skill_path,
190                source: None,
191                version: None,
192                branch: None,
193                rev: None,
194                command: None,
195                args: None,
196                target: target.clone(),
197                filename: None,
198                dependencies: None,
199                tool: tool.clone(),
200                flatten,
201                install: None,
202                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
203            }));
204
205            concrete_deps.push((skill_name, concrete_dep));
206        }
207    } else {
208        // For file-based resources, use the pattern resolver
209        let pattern_resolver = PatternResolver::new();
210        let matches = pattern_resolver.resolve(&search_pattern, &base_path)?;
211
212        debug!("Pattern '{}' matched {} files", pattern, matches.len());
213
214        for matched_path in matches {
215            // Convert matched path to absolute by joining with base_path
216            // Use normalized paths (forward slashes) for cross-platform lockfile compatibility
217            let absolute_path = base_path.join(&matched_path);
218            let concrete_path = normalize_path_for_storage(&absolute_path);
219
220            // Generate a dependency name using source context
221            let source_context = if let Some(manifest_dir) = manifest_dir {
222                // For local dependencies, use manifest directory as source context
223                crate::resolver::source_context::SourceContext::local(manifest_dir)
224            } else {
225                // Fallback: use the base_path as source context
226                crate::resolver::source_context::SourceContext::local(&base_path)
227            };
228
229            let dep_name = generate_dependency_name(&concrete_path, &source_context);
230
231            // Create a concrete dependency for the matched file, inheriting tool, target, and flatten from parent
232            let concrete_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
233                path: concrete_path,
234                source: None,
235                version: None,
236                branch: None,
237                rev: None,
238                command: None,
239                args: None,
240                target: target.clone(),
241                filename: None,
242                dependencies: None,
243                tool: tool.clone(),
244                flatten,
245                install: None,
246                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
247            }));
248
249            concrete_deps.push((dep_name, concrete_dep));
250        }
251    }
252
253    Ok(concrete_deps)
254}
255
256/// Expands a remote pattern dependency.
257async fn expand_remote_pattern(
258    dep: &ResourceDependency,
259    pattern: &str,
260    resource_type: crate::core::ResourceType,
261    source_manager: &crate::source::SourceManager,
262    cache: &crate::cache::Cache,
263    prepared_versions: Option<&DashMap<String, PreparedSourceVersion>>,
264) -> Result<Vec<(String, ResourceDependency)>> {
265    let source_name = dep
266        .get_source()
267        .ok_or_else(|| anyhow::anyhow!("Remote pattern dependency missing source: {}", pattern))?;
268
269    let source_url = source_manager
270        .get_source_url(source_name)
271        .with_context(|| format!("Source not found: {}", source_name))?;
272
273    // Get or clone the source repository
274    let repo_path = cache
275        .get_or_clone_source(source_name, &source_url, dep.get_version())
276        .await
277        .with_context(|| format!("Failed to access source repository: {}", source_name))?;
278
279    let repo = GitRepo::new(&repo_path);
280
281    // Resolve the version to a commit SHA, preferring pre-prepared versions to avoid redundant Git work
282    let version = dep.get_version().unwrap_or("HEAD");
283    let group_key = format!("{}::{}", source_name, version);
284    let (commit_sha, worktree_path) = if let Some(prepared_map) = prepared_versions {
285        if let Some(prepared) = prepared_map.get(&group_key) {
286            (prepared.resolved_commit.clone(), prepared.worktree_path.clone())
287        } else {
288            let sha = repo.resolve_to_sha(Some(version)).await.with_context(|| {
289                format!("Failed to resolve version '{}' for source {}", version, source_name)
290            })?;
291            let path = cache
292                .get_or_create_worktree_for_sha(source_name, &source_url, &sha, Some(version))
293                .await
294                .with_context(|| {
295                    format!("Failed to create worktree for {}@{}", source_name, version)
296                })?;
297            (sha, path)
298        }
299    } else {
300        let sha = repo.resolve_to_sha(Some(version)).await.with_context(|| {
301            format!("Failed to resolve version '{}' for source {}", version, source_name)
302        })?;
303        let path = cache
304            .get_or_create_worktree_for_sha(source_name, &source_url, &sha, Some(version))
305            .await
306            .with_context(|| {
307                format!("Failed to create worktree for {}@{}", source_name, version)
308            })?;
309        (sha, path)
310    };
311
312    // Get tool, target, and flatten from parent pattern dependency
313    let (tool, target, flatten) = match dep {
314        ResourceDependency::Detailed(d) => (d.tool.clone(), d.target.clone(), d.flatten),
315        _ => (None, None, None),
316    };
317
318    let mut concrete_deps = Vec::new();
319
320    // Skills are directory-based, so use special directory matching
321    if resource_type == crate::core::ResourceType::Skill {
322        let skill_matches = crate::resolver::skills::match_skill_directories(
323            &worktree_path,
324            pattern,
325            Some(&worktree_path),
326        )
327        .await?;
328
329        debug!(
330            "Remote skill pattern '{}' in {} matched {} directories",
331            pattern,
332            source_name,
333            skill_matches.len()
334        );
335
336        for (skill_name, skill_path) in skill_matches {
337            // Create a concrete dependency for the matched skill directory
338            let concrete_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
339                path: skill_path,
340                source: Some(source_name.to_string()),
341                version: Some(commit_sha.clone()),
342                branch: None,
343                rev: None,
344                command: None,
345                args: None,
346                target: target.clone(),
347                filename: None,
348                dependencies: None,
349                tool: tool.clone(),
350                flatten,
351                install: None,
352                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
353            }));
354
355            concrete_deps.push((skill_name, concrete_dep));
356        }
357    } else {
358        // For file-based resources, use the pattern resolver
359        let pattern_resolver = PatternResolver::new();
360        let matches = pattern_resolver.resolve(pattern, &worktree_path)?;
361
362        debug!("Remote pattern '{}' in {} matched {} files", pattern, source_name, matches.len());
363
364        for matched_path in matches {
365            // Generate a dependency name using source context
366            // For Git dependencies, use the repository root as source context
367            let source_context =
368                crate::resolver::source_context::SourceContext::git(&worktree_path);
369            let dep_name =
370                generate_dependency_name(&matched_path.to_string_lossy(), &source_context);
371
372            // matched_path is already relative to worktree root (from PatternResolver)
373            // Create a concrete dependency for the matched file, inheriting tool, target, and flatten from parent
374            let concrete_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
375                path: matched_path.to_string_lossy().to_string(),
376                source: Some(source_name.to_string()),
377                version: Some(commit_sha.clone()),
378                branch: None,
379                rev: None,
380                command: None,
381                args: None,
382                target: target.clone(),
383                filename: None,
384                dependencies: None,
385                tool: tool.clone(),
386                flatten,
387                install: None,
388                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
389            }));
390
391            concrete_deps.push((dep_name, concrete_dep));
392        }
393    }
394
395    Ok(concrete_deps)
396}
397
398/// Generates a dependency name from a path using source context.
399/// Creates collision-resistant names by preserving directory structure relative to source.
400pub fn generate_dependency_name(
401    path: &str,
402    source_context: &crate::resolver::source_context::SourceContext,
403) -> String {
404    // Use the new source context-aware name generation
405    crate::resolver::source_context::compute_canonical_name(path, source_context)
406}
407
408// ============================================================================
409// Pattern Expansion Service
410// ============================================================================
411
412use crate::core::ResourceType;
413use std::sync::Arc;
414
415use super::types::ResolutionCore;
416
417/// Service for pattern expansion and resolution.
418///
419/// Handles expansion of glob patterns to concrete dependencies and maintains
420/// mappings between concrete files and their source patterns.
421pub struct PatternExpansionService {
422    /// Map tracking pattern alias relationships (concrete_name -> pattern_name)
423    pattern_alias_map: Arc<DashMap<(ResourceType, String), String>>,
424}
425
426impl PatternExpansionService {
427    /// Create a new pattern expansion service.
428    pub fn new() -> Self {
429        Self {
430            pattern_alias_map: Arc::new(DashMap::new()),
431        }
432    }
433
434    /// Expand a pattern dependency to concrete dependencies.
435    ///
436    /// Takes a glob pattern like "agents/*.md" and expands it to
437    /// concrete file paths like ["agents/foo.md", "agents/bar.md"].
438    ///
439    /// # Arguments
440    ///
441    /// * `core` - The resolution core with cache and source manager
442    /// * `dep` - The pattern dependency to expand
443    /// * `resource_type` - The type of resource being expanded
444    /// # Returns
445    ///
446    /// List of (name, concrete_dependency) tuples
447    pub async fn expand_pattern(
448        &self,
449        core: &ResolutionCore,
450        dep: &ResourceDependency,
451        resource_type: ResourceType,
452        prepared_versions: &DashMap<String, PreparedSourceVersion>,
453    ) -> Result<Vec<(String, ResourceDependency)>> {
454        // Delegate to expand_pattern_to_concrete_deps helper
455        expand_pattern_to_concrete_deps(
456            dep,
457            resource_type,
458            &core.source_manager,
459            &core.cache,
460            core.manifest.manifest_dir.as_deref(),
461            Some(prepared_versions),
462        )
463        .await
464    }
465
466    /// Get pattern alias for a concrete dependency.
467    ///
468    /// # Arguments
469    ///
470    /// * `resource_type` - The resource type
471    /// * `name` - The concrete dependency name
472    ///
473    /// # Returns
474    ///
475    /// The pattern name if this is from a pattern expansion
476    pub fn get_pattern_alias(
477        &self,
478        resource_type: ResourceType,
479        name: &str,
480    ) -> Option<dashmap::mapref::one::Ref<'_, (ResourceType, String), String>> {
481        self.pattern_alias_map.get(&(resource_type, name.to_string()))
482    }
483
484    /// Record a pattern alias mapping.
485    ///
486    /// # Arguments
487    ///
488    /// * `resource_type` - The resource type
489    /// * `concrete_name` - The concrete file name
490    /// * `pattern_name` - The pattern that expanded to this file
491    pub fn add_pattern_alias(
492        &self,
493        resource_type: ResourceType,
494        concrete_name: String,
495        pattern_name: String,
496    ) {
497        self.pattern_alias_map.insert((resource_type, concrete_name), pattern_name);
498    }
499}
500
501impl Default for PatternExpansionService {
502    fn default() -> Self {
503        Self::new()
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::manifest::DetailedDependency;
511    use std::path::Path;
512    use tempfile;
513    use tokio::fs;
514
515    // Tests for generate_dependency_name() with proper SourceContext
516
517    #[test]
518    fn test_generate_dependency_name_local_context() {
519        // Test with local source context - paths relative to manifest directory
520        let manifest_dir = Path::new("/project");
521        let source_context = crate::resolver::source_context::SourceContext::local(manifest_dir);
522
523        // Test absolute path within manifest directory
524        let name = generate_dependency_name("/project/agents/helper.md", &source_context);
525        assert_eq!(name, "agents/helper");
526
527        // Test relative path (already relative to manifest)
528        let name = generate_dependency_name("agents/helper.md", &source_context);
529        assert_eq!(name, "agents/helper");
530
531        // Test nested path
532        let name = generate_dependency_name("/project/snippets/python/utils.md", &source_context);
533        assert_eq!(name, "snippets/python/utils");
534    }
535
536    #[test]
537    fn test_generate_dependency_name_git_context() {
538        // Test with git source context - paths relative to repository root
539        let repo_root = Path::new("/repo");
540        let source_context = crate::resolver::source_context::SourceContext::git(repo_root);
541
542        // Test path within repository
543        let name = generate_dependency_name("/repo/agents/helper.md", &source_context);
544        assert_eq!(name, "agents/helper");
545
546        // Test deeply nested path
547        let name = generate_dependency_name(
548            "/repo/community/agents/ai/python-assistant.md",
549            &source_context,
550        );
551        assert_eq!(name, "community/agents/ai/python-assistant");
552    }
553
554    #[test]
555    fn test_generate_dependency_name_remote_context() {
556        // Test with remote source context - preserves full path structure
557        let source_context = crate::resolver::source_context::SourceContext::remote("community");
558
559        // Test remote paths (passed as relative to repo root)
560        let name = generate_dependency_name("agents/helper.md", &source_context);
561        assert_eq!(name, "agents/helper");
562
563        // Test nested remote path
564        let name = generate_dependency_name("snippets/python/async-pattern.md", &source_context);
565        assert_eq!(name, "snippets/python/async-pattern");
566    }
567
568    #[tokio::test]
569    async fn test_expand_local_pattern_with_source_context() {
570        // Test integration of pattern expansion with source context
571        let temp_dir = tempfile::TempDir::new().unwrap();
572        let manifest_dir = temp_dir.path();
573
574        // Create test files
575        fs::create_dir_all(manifest_dir.join("agents")).await.unwrap();
576        fs::create_dir_all(manifest_dir.join("snippets")).await.unwrap();
577
578        fs::write(manifest_dir.join("agents/helper.md"), "# Helper Agent").await.unwrap();
579        fs::write(manifest_dir.join("agents/assistant.md"), "# Assistant Agent").await.unwrap();
580        fs::write(manifest_dir.join("snippets/python.md"), "# Python Snippets").await.unwrap();
581
582        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
583            path: "agents/*.md".to_string(),
584            source: None,
585            version: None,
586            branch: None,
587            rev: None,
588            command: None,
589            args: None,
590            target: None,
591            filename: None,
592            dependencies: None,
593            tool: None,
594            flatten: None,
595            install: None,
596            template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
597        }));
598
599        // Test pattern expansion with local source context
600        let result = expand_local_pattern(
601            &dep,
602            "agents/*.md",
603            crate::core::ResourceType::Agent,
604            Some(manifest_dir),
605        )
606        .await
607        .unwrap();
608
609        // Verify we got expected files with correct names
610        assert_eq!(result.len(), 2);
611
612        let mut names: Vec<String> = result.iter().map(|(name, _dep)| name.clone()).collect();
613        names.sort();
614
615        assert_eq!(names[0], "agents/assistant");
616        assert_eq!(names[1], "agents/helper");
617
618        // Verify that the dependencies have the correct paths
619        for (name, expanded_dep) in &result {
620            // The expanded dependency should have the correct path
621            assert!(expanded_dep.get_path().ends_with(".md"));
622
623            // Verify the name matches what we'd expect from generate_dependency_name
624            let source_context =
625                crate::resolver::source_context::SourceContext::local(manifest_dir);
626            let expected_name = generate_dependency_name(expanded_dep.get_path(), &source_context);
627            assert_eq!(*name, expected_name);
628        }
629    }
630}