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 anyhow::{Context, Result};
12use std::path::{Path, PathBuf};
13use tracing::debug;
14
15/// Expands a pattern dependency into concrete dependencies.
16///
17/// This function takes a pattern dependency (e.g., `agents/*.md`) and expands it
18/// into individual file dependencies. It handles both local and remote patterns.
19///
20/// # Arguments
21///
22/// * `name` - The name of the pattern dependency
23/// * `dep` - The pattern dependency to expand
24/// * `resource_type` - The type of resource being expanded
25/// * `source_manager` - Source manager for remote repositories
26/// * `cache` - Cache for storing resolved files
27///
28/// # Returns
29///
30/// A vector of tuples containing:
31/// - The generated dependency name
32/// - The concrete resource dependency
33pub async fn expand_pattern_to_concrete_deps(
34    dep: &ResourceDependency,
35    resource_type: crate::core::ResourceType,
36    source_manager: &crate::source::SourceManager,
37    cache: &crate::cache::Cache,
38    manifest_dir: Option<&Path>,
39) -> Result<Vec<(String, ResourceDependency)>> {
40    let pattern = dep.get_path();
41
42    if dep.is_local() {
43        expand_local_pattern(dep, pattern, manifest_dir).await
44    } else {
45        expand_remote_pattern(dep, pattern, resource_type, source_manager, cache).await
46    }
47}
48
49/// Expands a local pattern dependency.
50async fn expand_local_pattern(
51    dep: &ResourceDependency,
52    pattern: &str,
53    manifest_dir: Option<&Path>,
54) -> Result<Vec<(String, ResourceDependency)>> {
55    // For absolute patterns, use the parent directory as base and strip the pattern to just the filename part
56    // For relative patterns, use manifest directory
57    let pattern_path = Path::new(pattern);
58    let (base_path, search_pattern) = if pattern_path.is_absolute() {
59        // Absolute pattern: extract base directory and relative pattern
60        // Example: "/tmp/xyz/agents/*.md" -> base="/tmp/xyz", pattern="agents/*.md"
61        let components: Vec<_> = pattern_path.components().collect();
62
63        // Find the first component with a glob character
64        let glob_idx = components.iter().position(|c| {
65            let s = c.as_os_str().to_string_lossy();
66            s.contains('*') || s.contains('?') || s.contains('[')
67        });
68
69        if let Some(idx) = glob_idx {
70            // Split at the glob component
71            let base_components = &components[..idx];
72            let pattern_components = &components[idx..];
73
74            let base: PathBuf = base_components.iter().collect();
75            let pattern: String = pattern_components
76                .iter()
77                .map(|c| c.as_os_str().to_string_lossy())
78                .collect::<Vec<_>>()
79                .join("/");
80
81            (base, pattern)
82        } else {
83            // No glob characters, use as-is
84            (PathBuf::from("."), pattern.to_string())
85        }
86    } else {
87        // Relative pattern, use manifest directory as base
88        let base = manifest_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
89        (base, pattern.to_string())
90    };
91
92    let pattern_resolver = PatternResolver::new();
93    let matches = pattern_resolver.resolve(&search_pattern, &base_path)?;
94
95    debug!("Pattern '{}' matched {} files", pattern, matches.len());
96
97    // Get tool, target, and flatten from parent pattern dependency
98    let (tool, target, flatten) = match dep {
99        ResourceDependency::Detailed(d) => (d.tool.clone(), d.target.clone(), d.flatten),
100        _ => (None, None, None),
101    };
102
103    let mut concrete_deps = Vec::new();
104
105    for matched_path in matches {
106        // Convert matched path to absolute by joining with base_path
107        let absolute_path = base_path.join(&matched_path);
108        let concrete_path = absolute_path.to_string_lossy().to_string();
109
110        // Generate a dependency name using source context
111        let source_context = if let Some(manifest_dir) = manifest_dir {
112            // For local dependencies, use manifest directory as source context
113            crate::resolver::source_context::SourceContext::local(manifest_dir)
114        } else {
115            // Fallback: use the base_path as source context
116            crate::resolver::source_context::SourceContext::local(&base_path)
117        };
118
119        let dep_name = generate_dependency_name(&concrete_path, &source_context);
120
121        // Create a concrete dependency for the matched file, inheriting tool, target, and flatten from parent
122        let concrete_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
123            path: concrete_path,
124            source: None,
125            version: None,
126            branch: None,
127            rev: None,
128            command: None,
129            args: None,
130            target: target.clone(),
131            filename: None,
132            dependencies: None,
133            tool: tool.clone(),
134            flatten,
135            install: None,
136            template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
137        }));
138
139        concrete_deps.push((dep_name, concrete_dep));
140    }
141
142    Ok(concrete_deps)
143}
144
145/// Expands a remote pattern dependency.
146async fn expand_remote_pattern(
147    dep: &ResourceDependency,
148    pattern: &str,
149    _resource_type: crate::core::ResourceType,
150    source_manager: &crate::source::SourceManager,
151    cache: &crate::cache::Cache,
152) -> Result<Vec<(String, ResourceDependency)>> {
153    let source_name = dep
154        .get_source()
155        .ok_or_else(|| anyhow::anyhow!("Remote pattern dependency missing source: {}", pattern))?;
156
157    let source_url = source_manager
158        .get_source_url(source_name)
159        .with_context(|| format!("Source not found: {}", source_name))?;
160
161    // Get or clone the source repository
162    let repo_path = cache
163        .get_or_clone_source(source_name, &source_url, dep.get_version())
164        .await
165        .with_context(|| format!("Failed to access source repository: {}", source_name))?;
166
167    let repo = GitRepo::new(&repo_path);
168
169    // Resolve the version to a commit SHA
170    let version = dep.get_version().unwrap_or("HEAD");
171    let commit_sha = repo.resolve_to_sha(Some(version)).await.with_context(|| {
172        format!("Failed to resolve version '{}' for source {}", version, source_name)
173    })?;
174
175    // Create a worktree for the specific commit
176    let worktree_path = cache
177        .get_or_create_worktree_for_sha(source_name, &source_url, &commit_sha, Some(version))
178        .await
179        .with_context(|| format!("Failed to create worktree for {}@{}", source_name, version))?;
180
181    // Resolve the pattern within the worktree
182    let pattern_resolver = PatternResolver::new();
183    let matches = pattern_resolver.resolve(pattern, &worktree_path)?;
184
185    debug!("Remote pattern '{}' in {} matched {} files", pattern, source_name, matches.len());
186
187    // Get tool, target, and flatten from parent pattern dependency
188    let (tool, target, flatten) = match dep {
189        ResourceDependency::Detailed(d) => (d.tool.clone(), d.target.clone(), d.flatten),
190        _ => (None, None, None),
191    };
192
193    let mut concrete_deps = Vec::new();
194
195    for matched_path in matches {
196        // Generate a dependency name using source context
197        // For Git dependencies, use the repository root as source context
198        let source_context = crate::resolver::source_context::SourceContext::git(&worktree_path);
199        let dep_name = generate_dependency_name(&matched_path.to_string_lossy(), &source_context);
200
201        // matched_path is already relative to worktree root (from PatternResolver)
202        // Create a concrete dependency for the matched file, inheriting tool, target, and flatten from parent
203        let concrete_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
204            path: matched_path.to_string_lossy().to_string(),
205            source: Some(source_name.to_string()),
206            version: Some(commit_sha.clone()),
207            branch: None,
208            rev: None,
209            command: None,
210            args: None,
211            target: target.clone(),
212            filename: None,
213            dependencies: None,
214            tool: tool.clone(),
215            flatten,
216            install: None,
217            template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
218        }));
219
220        concrete_deps.push((dep_name, concrete_dep));
221    }
222
223    Ok(concrete_deps)
224}
225
226/// Generates a dependency name from a path using source context.
227/// Creates collision-resistant names by preserving directory structure relative to source.
228pub fn generate_dependency_name(
229    path: &str,
230    source_context: &crate::resolver::source_context::SourceContext,
231) -> String {
232    // Use the new source context-aware name generation
233    crate::resolver::source_context::compute_canonical_name(path, source_context)
234}
235
236// ============================================================================
237// Pattern Expansion Service
238// ============================================================================
239
240use crate::core::ResourceType;
241use std::collections::HashMap;
242
243use super::types::ResolutionCore;
244use super::version_resolver::VersionResolutionService;
245
246/// Service for pattern expansion and resolution.
247///
248/// Handles expansion of glob patterns to concrete dependencies and maintains
249/// mappings between concrete files and their source patterns.
250pub struct PatternExpansionService {
251    /// Map tracking pattern alias relationships (concrete_name -> pattern_name)
252    pattern_alias_map: HashMap<(ResourceType, String), String>,
253}
254
255impl PatternExpansionService {
256    /// Create a new pattern expansion service.
257    pub fn new() -> Self {
258        Self {
259            pattern_alias_map: HashMap::new(),
260        }
261    }
262
263    /// Expand a pattern dependency to concrete dependencies.
264    ///
265    /// Takes a glob pattern like "agents/*.md" and expands it to
266    /// concrete file paths like ["agents/foo.md", "agents/bar.md"].
267    ///
268    /// # Arguments
269    ///
270    /// * `core` - The resolution core with cache and source manager
271    /// * `dep` - The pattern dependency to expand
272    /// * `resource_type` - The type of resource being expanded
273    /// * `version_service` - Version service for worktree paths
274    ///
275    /// # Returns
276    ///
277    /// List of (name, concrete_dependency) tuples
278    pub async fn expand_pattern(
279        &mut self,
280        core: &ResolutionCore,
281        dep: &ResourceDependency,
282        resource_type: ResourceType,
283        _version_service: &VersionResolutionService,
284    ) -> Result<Vec<(String, ResourceDependency)>> {
285        // Delegate to expand_pattern_to_concrete_deps helper
286        expand_pattern_to_concrete_deps(
287            dep,
288            resource_type,
289            &core.source_manager,
290            &core.cache,
291            None, // manifest_dir - use current working directory
292        )
293        .await
294    }
295
296    /// Get pattern alias for a concrete dependency.
297    ///
298    /// # Arguments
299    ///
300    /// * `resource_type` - The resource type
301    /// * `name` - The concrete dependency name
302    ///
303    /// # Returns
304    ///
305    /// The pattern name if this is from a pattern expansion
306    pub fn get_pattern_alias(&self, resource_type: ResourceType, name: &str) -> Option<&String> {
307        self.pattern_alias_map.get(&(resource_type, name.to_string()))
308    }
309
310    /// Record a pattern alias mapping.
311    ///
312    /// # Arguments
313    ///
314    /// * `resource_type` - The resource type
315    /// * `concrete_name` - The concrete file name
316    /// * `pattern_name` - The pattern that expanded to this file
317    pub fn add_pattern_alias(
318        &mut self,
319        resource_type: ResourceType,
320        concrete_name: String,
321        pattern_name: String,
322    ) {
323        self.pattern_alias_map.insert((resource_type, concrete_name), pattern_name);
324    }
325}
326
327impl Default for PatternExpansionService {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::manifest::DetailedDependency;
337
338    // TODO: ADD NEW TESTS for the source context version once we have concrete examples
339    // These tests should use generate_dependency_name() with proper SourceContext
340
341    #[tokio::test]
342    async fn test_expand_local_pattern() {
343        // This test would require creating temporary files and directories
344        // For now, we'll test the logic with a mock scenario
345        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
346            path: "tests/fixtures/*.md".to_string(),
347            source: None,
348            version: None,
349            branch: None,
350            rev: None,
351            command: None,
352            args: None,
353            target: None,
354            filename: None,
355            dependencies: None,
356            tool: None,
357            flatten: None,
358            install: None,
359            template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
360        }));
361
362        // Note: This test would need actual test files to work properly
363        // For now, we just verify the function signature and basic structure
364        match expand_local_pattern(&dep, "tests/fixtures/*.md", None).await {
365            Ok(_) => println!("Pattern expansion succeeded"),
366            Err(e) => println!("Pattern expansion failed (expected in test): {}", e),
367        }
368    }
369}