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            // NOTE: Use original version constraint, not commit_sha, to preserve
339            // the semantic version for conflict detection and transitive resolution.
340            let concrete_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
341                path: skill_path,
342                source: Some(source_name.to_string()),
343                version: Some(version.to_string()),
344                branch: None,
345                rev: None,
346                command: None,
347                args: None,
348                target: target.clone(),
349                filename: None,
350                dependencies: None,
351                tool: tool.clone(),
352                flatten,
353                install: None,
354                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
355            }));
356
357            concrete_deps.push((skill_name, concrete_dep));
358        }
359    } else {
360        // For file-based resources, use the pattern resolver
361        let pattern_resolver = PatternResolver::new();
362        let matches = pattern_resolver.resolve(pattern, &worktree_path)?;
363
364        debug!("Remote pattern '{}' in {} matched {} files", pattern, source_name, matches.len());
365
366        for matched_path in matches {
367            // Generate a dependency name using source context
368            // For Git dependencies, use the repository root as source context
369            let source_context =
370                crate::resolver::source_context::SourceContext::git(&worktree_path);
371            let dep_name =
372                generate_dependency_name(&matched_path.to_string_lossy(), &source_context);
373
374            // matched_path is already relative to worktree root (from PatternResolver)
375            // Create a concrete dependency for the matched file, inheriting tool, target, and flatten from parent
376            // NOTE: Use original version constraint, not commit_sha, to preserve
377            // the semantic version for conflict detection and transitive resolution.
378            let concrete_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
379                path: matched_path.to_string_lossy().to_string(),
380                source: Some(source_name.to_string()),
381                version: Some(version.to_string()),
382                branch: None,
383                rev: None,
384                command: None,
385                args: None,
386                target: target.clone(),
387                filename: None,
388                dependencies: None,
389                tool: tool.clone(),
390                flatten,
391                install: None,
392                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
393            }));
394
395            concrete_deps.push((dep_name, concrete_dep));
396        }
397    }
398
399    Ok(concrete_deps)
400}
401
402/// Generates a dependency name from a path using source context.
403/// Creates collision-resistant names by preserving directory structure relative to source.
404pub fn generate_dependency_name(
405    path: &str,
406    source_context: &crate::resolver::source_context::SourceContext,
407) -> String {
408    // Use the new source context-aware name generation
409    crate::resolver::source_context::compute_canonical_name(path, source_context)
410}
411
412// ============================================================================
413// Pattern Expansion Service
414// ============================================================================
415
416use crate::core::ResourceType;
417use std::sync::Arc;
418
419use super::types::ResolutionCore;
420
421/// Service for pattern expansion and resolution.
422///
423/// Handles expansion of glob patterns to concrete dependencies and maintains
424/// mappings between concrete files and their source patterns.
425pub struct PatternExpansionService {
426    /// Map tracking pattern alias relationships (concrete_name -> pattern_name)
427    pattern_alias_map: Arc<DashMap<(ResourceType, String), String>>,
428}
429
430impl PatternExpansionService {
431    /// Create a new pattern expansion service.
432    pub fn new() -> Self {
433        Self {
434            pattern_alias_map: Arc::new(DashMap::new()),
435        }
436    }
437
438    /// Expand a pattern dependency to concrete dependencies.
439    ///
440    /// Takes a glob pattern like "agents/*.md" and expands it to
441    /// concrete file paths like ["agents/foo.md", "agents/bar.md"].
442    ///
443    /// # Arguments
444    ///
445    /// * `core` - The resolution core with cache and source manager
446    /// * `dep` - The pattern dependency to expand
447    /// * `resource_type` - The type of resource being expanded
448    /// # Returns
449    ///
450    /// List of (name, concrete_dependency) tuples
451    pub async fn expand_pattern(
452        &self,
453        core: &ResolutionCore,
454        dep: &ResourceDependency,
455        resource_type: ResourceType,
456        prepared_versions: &DashMap<String, PreparedSourceVersion>,
457    ) -> Result<Vec<(String, ResourceDependency)>> {
458        // Delegate to expand_pattern_to_concrete_deps helper
459        expand_pattern_to_concrete_deps(
460            dep,
461            resource_type,
462            &core.source_manager,
463            &core.cache,
464            core.manifest.manifest_dir.as_deref(),
465            Some(prepared_versions),
466        )
467        .await
468    }
469
470    /// Get pattern alias for a concrete dependency.
471    ///
472    /// # Arguments
473    ///
474    /// * `resource_type` - The resource type
475    /// * `name` - The concrete dependency name
476    ///
477    /// # Returns
478    ///
479    /// The pattern name if this is from a pattern expansion
480    pub fn get_pattern_alias(
481        &self,
482        resource_type: ResourceType,
483        name: &str,
484    ) -> Option<dashmap::mapref::one::Ref<'_, (ResourceType, String), String>> {
485        self.pattern_alias_map.get(&(resource_type, name.to_string()))
486    }
487
488    /// Record a pattern alias mapping.
489    ///
490    /// # Arguments
491    ///
492    /// * `resource_type` - The resource type
493    /// * `concrete_name` - The concrete file name
494    /// * `pattern_name` - The pattern that expanded to this file
495    pub fn add_pattern_alias(
496        &self,
497        resource_type: ResourceType,
498        concrete_name: String,
499        pattern_name: String,
500    ) {
501        self.pattern_alias_map.insert((resource_type, concrete_name), pattern_name);
502    }
503}
504
505impl Default for PatternExpansionService {
506    fn default() -> Self {
507        Self::new()
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::manifest::DetailedDependency;
515    use std::path::Path;
516    use tempfile;
517    use tokio::fs;
518
519    // Tests for generate_dependency_name() with proper SourceContext
520
521    #[test]
522    fn test_generate_dependency_name_local_context() {
523        // Test with local source context - paths relative to manifest directory
524        // Use platform-appropriate absolute paths
525        #[cfg(windows)]
526        let manifest_dir = Path::new("C:\\project");
527        #[cfg(not(windows))]
528        let manifest_dir = Path::new("/project");
529        let source_context = crate::resolver::source_context::SourceContext::local(manifest_dir);
530
531        // Test absolute path within manifest directory
532        #[cfg(windows)]
533        let abs_path = "C:\\project\\agents\\helper.md";
534        #[cfg(not(windows))]
535        let abs_path = "/project/agents/helper.md";
536        let name = generate_dependency_name(abs_path, &source_context);
537        assert_eq!(name, "agents/helper");
538
539        // Test relative path (already relative to manifest)
540        let name = generate_dependency_name("agents/helper.md", &source_context);
541        assert_eq!(name, "agents/helper");
542
543        // Test nested path
544        #[cfg(windows)]
545        let nested_path = "C:\\project\\snippets\\python\\utils.md";
546        #[cfg(not(windows))]
547        let nested_path = "/project/snippets/python/utils.md";
548        let name = generate_dependency_name(nested_path, &source_context);
549        assert_eq!(name, "snippets/python/utils");
550    }
551
552    #[test]
553    fn test_generate_dependency_name_git_context() {
554        // Test with git source context - paths relative to repository root
555        // Use platform-appropriate absolute paths
556        #[cfg(windows)]
557        let repo_root = Path::new("C:\\repo");
558        #[cfg(not(windows))]
559        let repo_root = Path::new("/repo");
560        let source_context = crate::resolver::source_context::SourceContext::git(repo_root);
561
562        // Test path within repository
563        #[cfg(windows)]
564        let repo_path = "C:\\repo\\agents\\helper.md";
565        #[cfg(not(windows))]
566        let repo_path = "/repo/agents/helper.md";
567        let name = generate_dependency_name(repo_path, &source_context);
568        assert_eq!(name, "agents/helper");
569
570        // Test deeply nested path
571        #[cfg(windows)]
572        let nested_path = "C:\\repo\\community\\agents\\ai\\python-assistant.md";
573        #[cfg(not(windows))]
574        let nested_path = "/repo/community/agents/ai/python-assistant.md";
575        let name = generate_dependency_name(nested_path, &source_context);
576        assert_eq!(name, "community/agents/ai/python-assistant");
577    }
578
579    #[test]
580    fn test_generate_dependency_name_remote_context() {
581        // Test with remote source context - preserves full path structure
582        let source_context = crate::resolver::source_context::SourceContext::remote("community");
583
584        // Test remote paths (passed as relative to repo root)
585        let name = generate_dependency_name("agents/helper.md", &source_context);
586        assert_eq!(name, "agents/helper");
587
588        // Test nested remote path
589        let name = generate_dependency_name("snippets/python/async-pattern.md", &source_context);
590        assert_eq!(name, "snippets/python/async-pattern");
591    }
592
593    #[tokio::test]
594    async fn test_expand_local_pattern_with_source_context() {
595        // Test integration of pattern expansion with source context
596        let temp_dir = tempfile::TempDir::new().unwrap();
597        let manifest_dir = temp_dir.path();
598
599        // Create test files
600        fs::create_dir_all(manifest_dir.join("agents")).await.unwrap();
601        fs::create_dir_all(manifest_dir.join("snippets")).await.unwrap();
602
603        fs::write(manifest_dir.join("agents/helper.md"), "# Helper Agent").await.unwrap();
604        fs::write(manifest_dir.join("agents/assistant.md"), "# Assistant Agent").await.unwrap();
605        fs::write(manifest_dir.join("snippets/python.md"), "# Python Snippets").await.unwrap();
606
607        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
608            path: "agents/*.md".to_string(),
609            source: None,
610            version: None,
611            branch: None,
612            rev: None,
613            command: None,
614            args: None,
615            target: None,
616            filename: None,
617            dependencies: None,
618            tool: None,
619            flatten: None,
620            install: None,
621            template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
622        }));
623
624        // Test pattern expansion with local source context
625        let result = expand_local_pattern(
626            &dep,
627            "agents/*.md",
628            crate::core::ResourceType::Agent,
629            Some(manifest_dir),
630        )
631        .await
632        .unwrap();
633
634        // Verify we got expected files with correct names
635        assert_eq!(result.len(), 2);
636
637        let mut names: Vec<String> = result.iter().map(|(name, _dep)| name.clone()).collect();
638        names.sort();
639
640        assert_eq!(names[0], "agents/assistant");
641        assert_eq!(names[1], "agents/helper");
642
643        // Verify that the dependencies have the correct paths
644        for (name, expanded_dep) in &result {
645            // The expanded dependency should have the correct path
646            assert!(expanded_dep.get_path().ends_with(".md"));
647
648            // Verify the name matches what we'd expect from generate_dependency_name
649            let source_context =
650                crate::resolver::source_context::SourceContext::local(manifest_dir);
651            let expected_name = generate_dependency_name(expanded_dep.get_path(), &source_context);
652            assert_eq!(*name, expected_name);
653        }
654    }
655}