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 {
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 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 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 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
402pub fn generate_dependency_name(
405 path: &str,
406 source_context: &crate::resolver::source_context::SourceContext,
407) -> String {
408 crate::resolver::source_context::compute_canonical_name(path, source_context)
410}
411
412use crate::core::ResourceType;
417use std::sync::Arc;
418
419use super::types::ResolutionCore;
420
421pub struct PatternExpansionService {
426 pattern_alias_map: Arc<DashMap<(ResourceType, String), String>>,
428}
429
430impl PatternExpansionService {
431 pub fn new() -> Self {
433 Self {
434 pattern_alias_map: Arc::new(DashMap::new()),
435 }
436 }
437
438 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 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 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 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 #[test]
522 fn test_generate_dependency_name_local_context() {
523 #[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 #[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 let name = generate_dependency_name("agents/helper.md", &source_context);
541 assert_eq!(name, "agents/helper");
542
543 #[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 #[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 #[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 #[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 let source_context = crate::resolver::source_context::SourceContext::remote("community");
583
584 let name = generate_dependency_name("agents/helper.md", &source_context);
586 assert_eq!(name, "agents/helper");
587
588 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 let temp_dir = tempfile::TempDir::new().unwrap();
597 let manifest_dir = temp_dir.path();
598
599 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 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 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 for (name, expanded_dep) in &result {
645 assert!(expanded_dep.get_path().ends_with(".md"));
647
648 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}