1use 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
18pub 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
123async 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 let pattern_path = Path::new(pattern);
133 let (base_path, search_pattern) = if pattern_path.is_absolute() {
134 let components: Vec<_> = pattern_path.components().collect();
137
138 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 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 (PathBuf::from("."), pattern.to_string())
160 }
161 } else {
162 let base = manifest_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
164 (base, pattern.to_string())
165 };
166
167 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 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, )
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 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 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 let absolute_path = base_path.join(&matched_path);
218 let concrete_path = normalize_path_for_storage(&absolute_path);
219
220 let source_context = if let Some(manifest_dir) = manifest_dir {
222 crate::resolver::source_context::SourceContext::local(manifest_dir)
224 } else {
225 crate::resolver::source_context::SourceContext::local(&base_path)
227 };
228
229 let dep_name = generate_dependency_name(&concrete_path, &source_context);
230
231 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
256async 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 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 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 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 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 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 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 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 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
398pub fn generate_dependency_name(
401 path: &str,
402 source_context: &crate::resolver::source_context::SourceContext,
403) -> String {
404 crate::resolver::source_context::compute_canonical_name(path, source_context)
406}
407
408use crate::core::ResourceType;
413use std::sync::Arc;
414
415use super::types::ResolutionCore;
416
417pub struct PatternExpansionService {
422 pattern_alias_map: Arc<DashMap<(ResourceType, String), String>>,
424}
425
426impl PatternExpansionService {
427 pub fn new() -> Self {
429 Self {
430 pattern_alias_map: Arc::new(DashMap::new()),
431 }
432 }
433
434 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 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 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 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 #[test]
518 fn test_generate_dependency_name_local_context() {
519 let manifest_dir = Path::new("/project");
521 let source_context = crate::resolver::source_context::SourceContext::local(manifest_dir);
522
523 let name = generate_dependency_name("/project/agents/helper.md", &source_context);
525 assert_eq!(name, "agents/helper");
526
527 let name = generate_dependency_name("agents/helper.md", &source_context);
529 assert_eq!(name, "agents/helper");
530
531 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 let repo_root = Path::new("/repo");
540 let source_context = crate::resolver::source_context::SourceContext::git(repo_root);
541
542 let name = generate_dependency_name("/repo/agents/helper.md", &source_context);
544 assert_eq!(name, "agents/helper");
545
546 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 let source_context = crate::resolver::source_context::SourceContext::remote("community");
558
559 let name = generate_dependency_name("agents/helper.md", &source_context);
561 assert_eq!(name, "agents/helper");
562
563 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 let temp_dir = tempfile::TempDir::new().unwrap();
572 let manifest_dir = temp_dir.path();
573
574 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 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 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 for (name, expanded_dep) in &result {
620 assert!(expanded_dep.get_path().ends_with(".md"));
622
623 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}