agpm_cli/resolver/version_resolver.rs
1//! Centralized version resolution module for AGPM
2//!
3//! This module implements the core version-to-SHA resolution strategy that ensures
4//! deterministic and efficient dependency management. By resolving all version
5//! specifications to commit SHAs upfront, we enable:
6//!
7//! - **SHA-based worktree caching**: Reuse worktrees for identical commits
8//! - **Reduced network operations**: Single fetch per repository
9//! - **Deterministic installations**: Same SHA always produces same result
10//! - **Efficient deduplication**: Multiple refs to same commit share one worktree
11//!
12//! # Architecture
13//!
14//! The `VersionResolver` operates in two phases:
15//! 1. **Collection Phase**: Gather all unique (source, version) pairs
16//! 2. **Resolution Phase**: Batch resolve all versions to SHAs
17//!
18//! This design minimizes Git operations and enables parallel resolution.
19
20use anyhow::{Context, Result};
21use std::collections::HashMap;
22use std::path::PathBuf;
23
24use crate::cache::Cache;
25use crate::git::GitRepo;
26use crate::manifest::ResourceDependency;
27use crate::source::SourceManager;
28
29/// Version resolution entry tracking source and version to SHA mapping
30#[derive(Debug, Clone)]
31pub struct VersionEntry {
32 /// Source name from manifest
33 pub source: String,
34 /// Source URL (Git repository)
35 pub url: String,
36 /// Version specification (tag, branch, commit, or None for HEAD)
37 pub version: Option<String>,
38 /// Resolved SHA-1 hash (populated during resolution)
39 pub resolved_sha: Option<String>,
40 /// Resolved version (e.g., "latest" -> "v2.0.0")
41 pub resolved_version: Option<String>,
42}
43
44/// Centralized version resolver for efficient SHA resolution
45///
46/// The `VersionResolver` is responsible for resolving all dependency versions
47/// to their corresponding Git commit SHAs before any worktree operations.
48/// This ensures maximum efficiency and deduplication.
49///
50/// # Example
51///
52/// ```no_run
53/// # use agpm_cli::resolver::version_resolver::{VersionResolver, VersionEntry};
54/// # use agpm_cli::cache::Cache;
55/// # async fn example() -> anyhow::Result<()> {
56/// let cache = Cache::new()?;
57/// let mut resolver = VersionResolver::new(cache);
58///
59/// // Add versions to resolve
60/// resolver.add_version("community", "https://github.com/example/repo.git", Some("v1.0.0"));
61/// resolver.add_version("community", "https://github.com/example/repo.git", Some("main"));
62///
63/// // Batch resolve all versions to SHAs
64/// resolver.resolve_all().await?;
65///
66/// // Get resolved SHA for a specific version
67/// let sha = resolver.get_resolved_sha("community", "v1.0.0");
68/// # Ok(())
69/// # }
70/// ```
71/// Resolved version information
72#[derive(Debug, Clone)]
73pub struct ResolvedVersion {
74 /// The resolved SHA-1 hash
75 pub sha: String,
76 /// The resolved version (e.g., "latest" -> "v2.0.0")
77 /// If no constraint resolution happened, this will be the same as input
78 pub resolved_ref: String,
79}
80
81/// Centralized version resolver for batch SHA resolution.
82///
83/// The `VersionResolver` manages the collection and resolution of all dependency
84/// versions in a single batch operation, enabling optimal Git repository access
85/// patterns and maximum worktree reuse.
86pub struct VersionResolver {
87 /// Cache instance for repository access
88 cache: Cache,
89 /// Collection of versions to resolve, keyed by (source, version)
90 entries: HashMap<(String, String), VersionEntry>,
91 /// Resolved SHA cache, keyed by (source, version)
92 resolved: HashMap<(String, String), ResolvedVersion>,
93 /// Bare repository paths, keyed by source name
94 bare_repos: HashMap<String, PathBuf>,
95}
96
97impl VersionResolver {
98 /// Creates a new version resolver with the given cache
99 pub fn new(cache: Cache) -> Self {
100 Self {
101 cache,
102 entries: HashMap::new(),
103 resolved: HashMap::new(),
104 bare_repos: HashMap::new(),
105 }
106 }
107
108 /// Adds a version to be resolved
109 ///
110 /// Multiple calls with the same (source, version) pair will be deduplicated.
111 ///
112 /// # Arguments
113 ///
114 /// * `source` - Source name from manifest
115 /// * `url` - Git repository URL
116 /// * `version` - Version specification (tag, branch, commit, or None for HEAD)
117 pub fn add_version(&mut self, source: &str, url: &str, version: Option<&str>) {
118 let version_key = version.unwrap_or("HEAD").to_string();
119 let key = (source.to_string(), version_key);
120
121 // Only add if not already present (deduplication)
122 self.entries.entry(key).or_insert_with(|| VersionEntry {
123 source: source.to_string(),
124 url: url.to_string(),
125 version: version.map(std::string::ToString::to_string),
126 resolved_sha: None,
127 resolved_version: None,
128 });
129 }
130
131 /// Resolves all collected versions to their commit SHAs using cached repositories.
132 ///
133 /// This method implements the second phase of AGPM's two-phase resolution architecture.
134 /// It processes all version entries collected via `add_version()` calls and resolves
135 /// them to concrete commit SHAs using locally cached Git repositories.
136 ///
137 /// # Prerequisites
138 ///
139 /// **CRITICAL**: `pre_sync_sources()` must be called before this method. The resolver
140 /// requires all repositories to be pre-synced to the cache, and will return an error
141 /// if any required repository is missing from the `bare_repos` map.
142 ///
143 /// # Resolution Process
144 ///
145 /// The method performs the following steps:
146 /// 1. **Source Grouping**: Groups entries by source to minimize repository operations
147 /// 2. **Repository Access**: Uses pre-synced repositories from `pre_sync_sources()`
148 /// 3. **Version Constraint Resolution**: Handles semver constraints (`^1.0`, `~2.1`)
149 /// 4. **SHA Resolution**: Resolves all versions to SHAs using `git rev-parse`
150 /// 5. **Result Caching**: Stores resolved SHAs for quick retrieval
151 ///
152 /// # Version Resolution Strategy
153 ///
154 /// The resolver handles different version types:
155 /// - **Exact SHAs**: Used directly without resolution
156 /// - **Semantic Versions**: Resolved using semver constraint matching
157 /// - **Tags**: Resolved to their commit SHAs
158 /// - **Branch Names**: Resolved to current HEAD commit
159 /// - **Latest/None**: Defaults to the repository's default branch
160 ///
161 /// # Performance Characteristics
162 ///
163 /// - **Time Complexity**: O(n·log(t)) where n = entries, t = tags per repo
164 /// - **Space Complexity**: O(n) for storing resolved results
165 /// - **Network I/O**: Zero (operates on cached repositories only)
166 /// - **Parallelization**: Single-threaded but optimized for batch operations
167 ///
168 /// # Example
169 ///
170 /// ```ignore
171 /// # use agpm_cli::resolver::version_resolver::VersionResolver;
172 /// # use agpm_cli::cache::Cache;
173 /// # async fn example() -> anyhow::Result<()> {
174 /// let cache = Cache::new()?;
175 /// let mut resolver = VersionResolver::new(cache);
176 ///
177 /// // Add various version types
178 /// resolver.add_version("source", "https://github.com/org/repo.git", Some("v1.2.3"));
179 /// resolver.add_version("source", "https://github.com/org/repo.git", Some("^1.0"));
180 /// resolver.add_version("source", "https://github.com/org/repo.git", Some("main"));
181 /// resolver.add_version("source", "https://github.com/org/repo.git", None); // latest
182 ///
183 /// // Phase 1: Sync repositories
184 /// resolver.pre_sync_sources().await?;
185 ///
186 /// // Phase 2: Resolve versions to SHAs (this method)
187 /// resolver.resolve_all().await?;
188 ///
189 /// // Access resolved SHAs
190 /// if resolver.is_resolved("source", "v1.2.3") {
191 /// println!("v1.2.3 resolved successfully");
192 /// }
193 /// # Ok(())
194 /// # }
195 /// ```
196 ///
197 /// # Error Handling
198 ///
199 /// The method uses fail-fast behavior - if any version resolution fails,
200 /// the entire operation is aborted. This ensures consistency and prevents
201 /// partial resolution states.
202 ///
203 /// # Errors
204 ///
205 /// Returns an error if:
206 /// - **Pre-sync Required**: Repository was not pre-synced (call `pre_sync_sources()` first)
207 /// - **Version Not Found**: Specified version/tag/branch doesn't exist in repository
208 /// - **Constraint Resolution**: Semver constraint cannot be satisfied by available tags
209 /// - **Git Operations**: `git rev-parse` or other Git commands fail
210 /// - **Repository Access**: Cached repository is corrupted or inaccessible
211 pub async fn resolve_all(&mut self) -> Result<()> {
212 // Group entries by source for efficient processing
213 let mut by_source: HashMap<String, Vec<(String, VersionEntry)>> = HashMap::new();
214
215 for (key, entry) in &self.entries {
216 by_source.entry(entry.source.clone()).or_default().push((key.1.clone(), entry.clone()));
217 }
218
219 // Process each source
220 for (source, versions) in by_source {
221 // Repository must have been pre-synced
222 let repo_path = self
223 .bare_repos
224 .get(&source)
225 .ok_or_else(|| {
226 anyhow::anyhow!("Repository for source '{source}' was not pre-synced. Call pre_sync_sources() first.")
227 })?
228 .clone();
229
230 let repo = GitRepo::new(&repo_path);
231
232 // Resolve each version for this source
233 for (version_str, mut entry) in versions {
234 // Check if this is a local directory source (not a Git repository)
235 let is_local = crate::utils::is_local_path(&entry.url);
236
237 // For local directory sources, we don't resolve versions - just use "local"
238 let resolved_ref = if is_local {
239 "local".to_string()
240 } else if let Some(ref version) = entry.version {
241 // First check if this is a version constraint
242 if is_version_constraint(version) {
243 // Resolve constraint to actual tag first
244 // Note: get_or_clone_source already fetched, so tags should be available
245 let tags = repo.list_tags().await.unwrap_or_default();
246
247 if tags.is_empty() {
248 return Err(anyhow::anyhow!(
249 "No tags found in repository for constraint '{version}'"
250 ));
251 }
252
253 // Find best matching tag
254 find_best_matching_tag(version, tags)
255 .with_context(|| format!("Failed to resolve version constraint '{version}' for source '{source}'"))?
256 } else {
257 // Not a constraint, use as-is
258 version.clone()
259 }
260 } else {
261 // No version specified for Git source, resolve HEAD to actual branch name
262 repo.get_default_branch().await.unwrap_or_else(|_| "main".to_string())
263 };
264
265 // For local sources, don't resolve SHA. For Git sources, resolve ref to actual SHA
266 let sha = if is_local {
267 // Local directories don't have commit SHAs
268 None
269 } else {
270 // Resolve the actual ref to SHA for Git repositories
271 Some(repo.resolve_to_sha(Some(&resolved_ref)).await.with_context(|| {
272 format!("Failed to resolve version '{version_str}' for source '{source}'")
273 })?)
274 };
275
276 // Store the resolved SHA and version
277 entry.resolved_sha = sha.clone();
278 entry.resolved_version = Some(resolved_ref.clone());
279 let key = (source.clone(), version_str);
280 // Only insert into resolved map if we have a SHA (Git sources only)
281 if let Some(sha_value) = sha {
282 self.resolved.insert(
283 key,
284 ResolvedVersion {
285 sha: sha_value,
286 resolved_ref,
287 },
288 );
289 }
290 }
291 }
292
293 Ok(())
294 }
295
296 /// Resolves a single version to SHA without affecting the batch
297 ///
298 /// This is useful for incremental resolution or testing.
299 pub async fn resolve_single(
300 &mut self,
301 source: &str,
302 url: &str,
303 version: Option<&str>,
304 ) -> Result<String> {
305 // Get or clone the repository
306 let repo_path = self
307 .cache
308 .get_or_clone_source(source, url, None)
309 .await
310 .with_context(|| format!("Failed to prepare repository for source '{source}'"))?;
311
312 let repo = GitRepo::new(&repo_path);
313
314 // Resolve the version to SHA
315 let sha = repo.resolve_to_sha(version).await.with_context(|| {
316 format!(
317 "Failed to resolve version '{}' for source '{}'",
318 version.unwrap_or("HEAD"),
319 source
320 )
321 })?;
322
323 // Determine the resolved reference name
324 let resolved_ref = if let Some(v) = version {
325 v.to_string()
326 } else {
327 // When no version is specified, resolve HEAD to the actual branch name
328 repo.get_default_branch().await.unwrap_or_else(|_| "main".to_string())
329 };
330
331 // Cache the result
332 let version_key = version.unwrap_or("HEAD").to_string();
333 let key = (source.to_string(), version_key);
334 self.resolved.insert(
335 key,
336 ResolvedVersion {
337 sha: sha.clone(),
338 resolved_ref,
339 },
340 );
341
342 Ok(sha)
343 }
344
345 /// Gets the resolved SHA for a given source and version
346 ///
347 /// Returns None if the version hasn't been resolved yet.
348 ///
349 /// # Arguments
350 ///
351 /// * `source` - Source name
352 /// * `version` - Version specification (use "HEAD" for None)
353 pub fn get_resolved_sha(&self, source: &str, version: &str) -> Option<String> {
354 let key = (source.to_string(), version.to_string());
355 self.resolved.get(&key).map(|rv| rv.sha.clone())
356 }
357
358 /// Gets all resolved SHAs as a `HashMap`
359 ///
360 /// Useful for bulk operations or debugging.
361 pub fn get_all_resolved(&self) -> HashMap<(String, String), String> {
362 self.resolved.iter().map(|(k, v)| (k.clone(), v.sha.clone())).collect()
363 }
364
365 /// Gets all resolved versions with both SHA and resolved reference
366 ///
367 /// Returns a `HashMap` with (source, version) -> `ResolvedVersion`
368 pub const fn get_all_resolved_full(&self) -> &HashMap<(String, String), ResolvedVersion> {
369 &self.resolved
370 }
371
372 /// Checks if a specific version has been resolved
373 pub fn is_resolved(&self, source: &str, version: &str) -> bool {
374 let key = (source.to_string(), version.to_string());
375 self.resolved.contains_key(&key)
376 }
377
378 /// Pre-syncs all unique sources to ensure repositories are cloned/fetched.
379 ///
380 /// This method implements the first phase of AGPM's two-phase resolution architecture.
381 /// It is designed to be called during the "Syncing sources" phase to perform all
382 /// Git network operations upfront, before version resolution occurs.
383 ///
384 /// The method processes all entries in the resolver, groups them by unique source URLs,
385 /// and ensures each repository is cloned to the cache with the latest refs fetched.
386 /// This enables the subsequent `resolve_all()` method to work purely with local
387 /// cached data, providing better performance and progress reporting.
388 ///
389 /// # Post-Execution State
390 ///
391 /// After this method completes successfully:
392 /// - All required repositories will be cloned to `~/.agpm/cache/sources/`
393 /// - All repositories will have their latest refs fetched from remote
394 /// - The internal `bare_repos` map will be populated with repository paths
395 /// - `resolve_all()` can proceed without any network operations
396 ///
397 /// This separation provides several benefits:
398 /// - **Clear progress phases**: Network operations vs. local resolution
399 /// - **Better error handling**: Network failures separated from resolution logic
400 /// - **Batch optimization**: Single clone/fetch per unique repository
401 /// - **Parallelization potential**: Multiple repositories can be synced concurrently
402 ///
403 /// # Example
404 ///
405 /// ```ignore
406 /// use agpm_cli::resolver::version_resolver::VersionResolver;
407 /// use agpm_cli::cache::Cache;
408 ///
409 /// # async fn example() -> anyhow::Result<()> {
410 /// let cache = Cache::new()?;
411 /// let mut version_resolver = VersionResolver::new(cache);
412 ///
413 /// // Add versions to resolve across multiple sources
414 /// version_resolver.add_version(
415 /// "community",
416 /// "https://github.com/org/agpm-community.git",
417 /// Some("v1.0.0"),
418 /// );
419 /// version_resolver.add_version(
420 /// "community",
421 /// "https://github.com/org/agpm-community.git",
422 /// Some("v2.0.0"),
423 /// );
424 /// version_resolver.add_version(
425 /// "private-tools",
426 /// "https://github.com/company/private-agpm.git",
427 /// Some("main"),
428 /// );
429 ///
430 /// // Phase 1: Pre-sync all repositories (network operations)
431 /// version_resolver.pre_sync_sources().await?;
432 ///
433 /// // Phase 2: Resolve all versions to SHAs (local operations only)
434 /// version_resolver.resolve_all().await?;
435 ///
436 /// // Access resolved data
437 /// if version_resolver.is_resolved("community", "v1.0.0") {
438 /// println!("Successfully resolved community v1.0.0");
439 /// }
440 /// # Ok(())
441 /// # }
442 /// ```
443 ///
444 /// # Deduplication
445 ///
446 /// The method automatically deduplicates by source URL - if multiple entries
447 /// reference the same repository, only one clone/fetch operation is performed.
448 /// This is particularly efficient when resolving multiple versions from the
449 /// same source.
450 ///
451 /// # Errors
452 ///
453 /// Returns an error if:
454 /// - Repository cloning fails (network issues, authentication, invalid URL)
455 /// - Fetching latest refs fails (network connectivity, permission issues)
456 /// - Authentication fails for private repositories
457 /// - Disk space is insufficient for cloning repositories
458 /// - Repository is corrupted and cannot be accessed
459 pub async fn pre_sync_sources(&mut self) -> Result<()> {
460 // Group entries by source to get unique sources
461 let mut unique_sources: HashMap<String, String> = HashMap::new();
462
463 for entry in self.entries.values() {
464 unique_sources.insert(entry.source.clone(), entry.url.clone());
465 }
466
467 // Pre-sync each unique source
468 for (source, url) in unique_sources {
469 // Clone or update the repository (this does the actual Git operations)
470 let repo_path = self
471 .cache
472 .get_or_clone_source(&source, &url, None)
473 .await
474 .with_context(|| format!("Failed to sync repository for source '{source}'"))?;
475
476 // Store bare repo path for later use in resolve_all
477 self.bare_repos.insert(source.clone(), repo_path);
478 }
479
480 Ok(())
481 }
482
483 /// Gets the bare repository path for a source
484 ///
485 /// Returns None if the source hasn't been processed yet.
486 pub fn get_bare_repo_path(&self, source: &str) -> Option<&PathBuf> {
487 self.bare_repos.get(source)
488 }
489
490 /// Registers a bare repository path for a source
491 ///
492 /// This is used when manually ensuring a repository exists without clearing all state.
493 pub fn register_bare_repo(&mut self, source: String, repo_path: PathBuf) {
494 self.bare_repos.insert(source, repo_path);
495 }
496
497 /// Clears all resolved versions and cached data
498 ///
499 /// Useful for testing or when starting a fresh resolution.
500 pub fn clear(&mut self) {
501 self.entries.clear();
502 self.resolved.clear();
503 self.bare_repos.clear();
504 }
505
506 /// Returns the number of unique versions to resolve
507 pub fn pending_count(&self) -> usize {
508 self.entries.len()
509 }
510
511 /// Checks if the resolver has any entries to resolve.
512 ///
513 /// This is a convenience method to determine if the resolver has been populated
514 /// with version entries via `add_version()` calls. It's useful for conditional
515 /// logic to avoid unnecessary operations when no versions need resolution.
516 ///
517 /// # Returns
518 ///
519 /// Returns `true` if there are entries that need resolution, `false` if the
520 /// resolver is empty.
521 ///
522 /// # Example
523 ///
524 /// ```
525 /// # use agpm_cli::resolver::version_resolver::VersionResolver;
526 /// # use agpm_cli::cache::Cache;
527 /// # let cache = Cache::new().unwrap();
528 /// let mut resolver = VersionResolver::new(cache);
529 /// assert!(!resolver.has_entries()); // Initially empty
530 ///
531 /// resolver.add_version("source", "https://github.com/org/repo.git", Some("v1.0.0"));
532 /// assert!(resolver.has_entries()); // Now has entries
533 /// ```
534 pub fn has_entries(&self) -> bool {
535 !self.entries.is_empty()
536 }
537
538 /// Returns the number of successfully resolved versions
539 pub fn resolved_count(&self) -> usize {
540 self.resolved.len()
541 }
542}
543
544// ============================================================================
545// Version Resolution Service
546// ============================================================================
547
548use super::types::ResolutionCore;
549use std::path::Path;
550
551/// Service for version resolution and worktree management.
552///
553/// Provides high-level orchestration for version constraint resolution,
554/// SHA resolution, and worktree preparation for Git-backed dependencies.
555pub struct VersionResolutionService {
556 /// Centralized version resolver for batch SHA resolution
557 version_resolver: VersionResolver,
558
559 /// Cache of prepared versions (source::version -> worktree info)
560 prepared_versions: HashMap<String, PreparedSourceVersion>,
561}
562
563impl VersionResolutionService {
564 /// Create a new version resolution service.
565 pub fn new(cache: crate::cache::Cache) -> Self {
566 Self {
567 version_resolver: VersionResolver::new(cache),
568 prepared_versions: HashMap::new(),
569 }
570 }
571
572 /// Pre-sync all source repositories needed for dependencies.
573 ///
574 /// This performs all Git network operations upfront:
575 /// 1. Clone/fetch source repositories
576 /// 2. Resolve version constraints to commit SHAs
577 /// 3. Create worktrees for resolved commits
578 ///
579 /// # Arguments
580 ///
581 /// * `core` - The resolution core with cache and source manager
582 /// * `deps` - All dependencies that need sources synced
583 pub async fn pre_sync_sources(
584 &mut self,
585 core: &ResolutionCore,
586 deps: &[(String, ResourceDependency)],
587 ) -> Result<()> {
588 // Clear and rebuild version resolver entries
589 self.version_resolver.clear();
590
591 // Collect all unique (source, version) pairs
592 for (_name, dep) in deps {
593 if let Some(source) = dep.get_source() {
594 let version = dep.get_version(); // None means HEAD
595
596 let source_url = core
597 .source_manager
598 .get_source_url(source)
599 .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source))?;
600
601 // Add to version resolver for batch syncing (None -> "HEAD")
602 self.version_resolver.add_version(source, &source_url, version);
603 }
604 }
605
606 // Pre-sync all source repositories (clone/fetch)
607 self.version_resolver.pre_sync_sources().await?;
608
609 // Resolve all versions to SHAs in batch
610 self.version_resolver.resolve_all().await?;
611
612 // Handle local paths (non-Git sources) separately
613 // These don't go through version resolution but need to be in prepared_versions
614 for (_name, dep) in deps {
615 if let Some(source) = dep.get_source() {
616 let source_url = core
617 .source_manager
618 .get_source_url(source)
619 .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source))?;
620
621 if crate::utils::is_local_path(&source_url) {
622 let version_key = dep.get_version().unwrap_or("HEAD");
623 let group_key = format!("{}::{}", source, version_key);
624
625 // Add to prepared_versions with the local path
626 self.prepared_versions.insert(
627 group_key,
628 PreparedSourceVersion {
629 worktree_path: PathBuf::from(&source_url),
630 resolved_version: Some("local".to_string()),
631 resolved_commit: String::new(), // No commit for local sources
632 },
633 );
634 }
635 }
636 }
637
638 // Create worktrees for all resolved commits using WorktreeManager
639 let worktree_manager =
640 WorktreeManager::new(&core.cache, &core.source_manager, &self.version_resolver);
641 let prepared = worktree_manager.create_worktrees_for_resolved_versions().await?;
642
643 // Merge Git-backed worktrees with local paths
644 self.prepared_versions.extend(prepared);
645
646 Ok(())
647 }
648
649 /// Get a prepared version by source and version.
650 ///
651 /// # Arguments
652 ///
653 /// * `group_key` - The key in format "source::version"
654 ///
655 /// # Returns
656 ///
657 /// The prepared version info with worktree path and resolved commit
658 pub fn get_prepared_version(&self, group_key: &str) -> Option<&PreparedSourceVersion> {
659 self.prepared_versions.get(group_key)
660 }
661
662 /// Get the prepared versions map.
663 ///
664 /// Returns a reference to the HashMap of prepared source versions.
665 pub fn prepared_versions(&self) -> &HashMap<String, PreparedSourceVersion> {
666 &self.prepared_versions
667 }
668
669 /// Prepare an additional version on-demand without clearing existing ones.
670 ///
671 /// This is used for transitive dependencies discovered during resolution.
672 /// Unlike `pre_sync_sources`, this doesn't clear existing prepared versions.
673 ///
674 /// # Arguments
675 ///
676 /// * `core` - The resolution core with cache and source manager
677 /// * `source_name` - Name of the source repository
678 /// * `version` - Optional version constraint (None = HEAD)
679 pub async fn prepare_additional_version(
680 &mut self,
681 core: &ResolutionCore,
682 source_name: &str,
683 version: Option<&str>,
684 ) -> Result<()> {
685 let version_key = version.unwrap_or("HEAD");
686 let source_url = core
687 .source_manager
688 .get_source_url(source_name)
689 .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source_name))?;
690
691 // Handle local paths (non-Git sources) separately
692 if crate::utils::is_local_path(&source_url) {
693 let group_key = format!("{}::{}", source_name, version_key);
694 self.prepared_versions.insert(
695 group_key,
696 PreparedSourceVersion {
697 worktree_path: PathBuf::from(&source_url),
698 resolved_version: Some("local".to_string()),
699 resolved_commit: String::new(),
700 },
701 );
702 return Ok(());
703 }
704
705 // For Git sources, proceed with version resolution
706 self.version_resolver.add_version(source_name, &source_url, version);
707
708 // Ensure the bare repository exists
709 if self.version_resolver.get_bare_repo_path(source_name).is_none() {
710 let repo_path =
711 core.cache.get_or_clone_source(source_name, &source_url, None).await.with_context(
712 || format!("Failed to sync repository for source '{}'", source_name),
713 )?;
714 self.version_resolver.register_bare_repo(source_name.to_string(), repo_path);
715 }
716
717 // Resolve this specific version to SHA
718 self.version_resolver.resolve_all().await?;
719
720 // Get the resolved SHA and resolved reference
721 let resolved_version_data = self
722 .version_resolver
723 .get_all_resolved_full()
724 .get(&(source_name.to_string(), version_key.to_string()))
725 .ok_or_else(|| {
726 anyhow::anyhow!("Failed to resolve version for {} @ {}", source_name, version_key)
727 })?
728 .clone();
729
730 let sha = resolved_version_data.sha.clone();
731 let resolved_ref = resolved_version_data.resolved_ref.clone();
732
733 // Create worktree for this SHA
734 let worktree_path =
735 core.cache.get_or_create_worktree_for_sha(source_name, &source_url, &sha, None).await?;
736
737 // Cache the prepared version with the RESOLVED reference, not the constraint
738 let group_key = format!("{}::{}", source_name, version_key);
739 self.prepared_versions.insert(
740 group_key,
741 PreparedSourceVersion {
742 worktree_path,
743 resolved_version: Some(resolved_ref),
744 resolved_commit: sha,
745 },
746 );
747
748 Ok(())
749 }
750
751 /// Get available versions (tags/branches) for a repository.
752 ///
753 /// # Arguments
754 ///
755 /// * `core` - The resolution core with cache
756 /// * `repo_path` - Path to bare repository
757 ///
758 /// # Returns
759 ///
760 /// List of available version strings
761 pub async fn get_available_versions(
762 _core: &ResolutionCore,
763 repo_path: &Path,
764 ) -> Result<Vec<String>> {
765 let repo = GitRepo::new(repo_path);
766
767 // Get all tags
768 let tags = repo.list_tags().await.context("Failed to list tags")?;
769
770 // TODO: Add branches if needed in future
771 // For now, only use tags
772 let versions = tags;
773
774 Ok(versions)
775 }
776
777 /// Get the version resolver (for testing).
778 #[cfg(test)]
779 pub fn version_resolver(&self) -> &VersionResolver {
780 &self.version_resolver
781 }
782}
783
784// ============================================================================
785// Version Constraint Resolution Helpers
786// ============================================================================
787
788use crate::version::constraints::{ConstraintSet, VersionConstraint};
789use semver::Version;
790
791/// Checks if a string represents a version constraint rather than a direct reference.
792///
793/// Version constraints contain operators like `^`, `~`, `>`, `<`, `=`, or special
794/// keywords. Direct references are branch names, tag names, or commit hashes.
795/// This function now supports prefixed constraints like `agents-^v1.0.0`.
796///
797/// # Arguments
798///
799/// * `version` - The version string to check
800///
801/// # Returns
802///
803/// Returns `true` if the string contains constraint operators or keywords,
804/// `false` for plain tags, branches, or commit hashes.
805#[must_use]
806pub fn is_version_constraint(version: &str) -> bool {
807 // Extract prefix first, then check the version part for constraint indicators
808 let (_prefix, version_str) = crate::version::split_prefix_and_version(version);
809
810 // Check for wildcard (works with or without prefix)
811 if version_str == "*" {
812 return true;
813 }
814
815 // Check for version constraint operators in the version part
816 if version_str.starts_with('^')
817 || version_str.starts_with('~')
818 || version_str.starts_with('>')
819 || version_str.starts_with('<')
820 || version_str.starts_with('=')
821 || version_str.contains(',')
822 // Range constraints like ">=1.0.0, <2.0.0"
823 {
824 return true;
825 }
826
827 false
828}
829
830/// Parses Git tags into semantic versions, filtering out non-semver tags.
831///
832/// This function handles both prefixed and non-prefixed version tags,
833/// including support for monorepo-style prefixes like `agents-v1.0.0`.
834/// Tags that don't represent valid semantic versions are filtered out.
835#[must_use]
836pub fn parse_tags_to_versions(tags: Vec<String>) -> Vec<(String, Version)> {
837 let mut versions = Vec::new();
838
839 for tag in tags {
840 // Extract prefix and version part (handles both prefixed and unprefixed)
841 let (_prefix, version_str) = crate::version::split_prefix_and_version(&tag);
842
843 // Strip 'v' prefix from version part
844 let cleaned = version_str.trim_start_matches('v').trim_start_matches('V');
845
846 if let Ok(version) = Version::parse(cleaned) {
847 versions.push((tag, version));
848 }
849 }
850
851 // Sort by version, highest first
852 versions.sort_by(|a, b| b.1.cmp(&a.1));
853
854 versions
855}
856
857/// Finds the best matching tag for a version constraint.
858///
859/// This function resolves version constraints to actual Git tags by:
860/// 1. Extracting the prefix from the constraint (if any)
861/// 2. Filtering tags to only those with matching prefix
862/// 3. Parsing the constraint and matching tags
863/// 4. Selecting the best match (usually the highest compatible version)
864pub fn find_best_matching_tag(constraint_str: &str, tags: Vec<String>) -> Result<String> {
865 // Extract prefix from constraint
866 let (constraint_prefix, version_str) = crate::version::split_prefix_and_version(constraint_str);
867
868 // Filter tags by prefix first
869 let filtered_tags: Vec<String> = tags
870 .into_iter()
871 .filter(|tag| {
872 let (tag_prefix, _) = crate::version::split_prefix_and_version(tag);
873 tag_prefix.as_ref() == constraint_prefix.as_ref()
874 })
875 .collect();
876
877 if filtered_tags.is_empty() {
878 return Err(anyhow::anyhow!(
879 "No tags found with matching prefix for constraint: {constraint_str}"
880 ));
881 }
882
883 // Parse filtered tags to versions
884 let tag_versions = parse_tags_to_versions(filtered_tags);
885
886 if tag_versions.is_empty() {
887 return Err(anyhow::anyhow!(
888 "No valid semantic version tags found for constraint: {constraint_str}"
889 ));
890 }
891
892 // Special case: wildcard (*) matches the highest available version
893 if version_str == "*" {
894 // tag_versions is already sorted highest first
895 return Ok(tag_versions[0].0.clone());
896 }
897
898 // Parse constraint using ONLY the version part (prefix already filtered)
899 // This ensures semver matching works correctly after prefix filtering
900 let constraint = VersionConstraint::parse(version_str)?;
901
902 // Extract just the versions for constraint matching
903 let versions: Vec<Version> = tag_versions.iter().map(|(_, v)| v.clone()).collect();
904
905 // Create a constraint set with just this constraint
906 let mut constraint_set = ConstraintSet::new();
907 constraint_set.add(constraint)?;
908
909 // Find the best match
910 if let Some(best_version) = constraint_set.find_best_match(&versions) {
911 // Find the original tag name for this version
912 for (tag_name, version) in tag_versions {
913 if &version == best_version {
914 return Ok(tag_name);
915 }
916 }
917 }
918
919 Err(anyhow::anyhow!("No tag found matching constraint: {constraint_str}"))
920}
921
922// ============================================================================
923// Worktree Management
924// ============================================================================
925
926/// Represents a prepared source version with worktree information.
927#[derive(Clone, Debug, Default)]
928pub struct PreparedSourceVersion {
929 /// Path to the worktree for this version
930 pub worktree_path: std::path::PathBuf,
931 /// The resolved version reference (tag, branch, etc.)
932 pub resolved_version: Option<String>,
933 /// The commit SHA for this version
934 pub resolved_commit: String,
935}
936
937/// Manages worktree creation for resolved dependency versions.
938pub struct WorktreeManager<'a> {
939 cache: &'a Cache,
940 source_manager: &'a SourceManager,
941 version_resolver: &'a VersionResolver,
942}
943
944impl<'a> WorktreeManager<'a> {
945 /// Create a new worktree manager.
946 pub fn new(
947 cache: &'a Cache,
948 source_manager: &'a SourceManager,
949 version_resolver: &'a VersionResolver,
950 ) -> Self {
951 Self {
952 cache,
953 source_manager,
954 version_resolver,
955 }
956 }
957
958 /// Create a group key for identifying source-version combinations.
959 pub fn group_key(source: &str, version: &str) -> String {
960 format!("{source}::{version}")
961 }
962
963 /// Create worktrees for all resolved versions in parallel.
964 ///
965 /// This function takes the resolved versions from the VersionResolver
966 /// and creates Git worktrees for each unique commit SHA, enabling
967 /// efficient parallel access to dependency resources.
968 ///
969 /// # Returns
970 ///
971 /// A map of group keys to prepared source versions containing worktree paths.
972 pub async fn create_worktrees_for_resolved_versions(
973 &self,
974 ) -> Result<HashMap<String, PreparedSourceVersion>> {
975 use crate::core::AgpmError;
976 use futures::future::join_all;
977
978 let resolved_full = self.version_resolver.get_all_resolved_full().clone();
979 let mut prepared_versions = HashMap::new();
980
981 // Build futures for parallel worktree creation
982 let mut futures = Vec::new();
983
984 for ((source_name, version_key), resolved_version) in resolved_full {
985 let sha = resolved_version.sha;
986 let resolved_ref = resolved_version.resolved_ref;
987 let repo_key = Self::group_key(&source_name, &version_key);
988 let cache_clone = self.cache.clone();
989 let source_name_clone = source_name.clone();
990
991 // Get the source URL for this source
992 let source_url_clone = self
993 .source_manager
994 .get_source_url(&source_name)
995 .ok_or_else(|| AgpmError::SourceNotFound {
996 name: source_name.to_string(),
997 })?
998 .to_string();
999
1000 let sha_clone = sha.clone();
1001 let resolved_ref_clone = resolved_ref.clone();
1002
1003 let future = async move {
1004 // Use SHA-based worktree creation
1005 // The version resolver has already handled fetching and SHA resolution
1006 let worktree_path = cache_clone
1007 .get_or_create_worktree_for_sha(
1008 &source_name_clone,
1009 &source_url_clone,
1010 &sha_clone,
1011 Some(&source_name_clone), // context for logging
1012 )
1013 .await?;
1014
1015 Ok::<_, anyhow::Error>((
1016 repo_key,
1017 PreparedSourceVersion {
1018 worktree_path,
1019 resolved_version: Some(resolved_ref_clone),
1020 resolved_commit: sha_clone,
1021 },
1022 ))
1023 };
1024
1025 futures.push(future);
1026 }
1027
1028 // Execute all futures concurrently and collect results
1029 let results = join_all(futures).await;
1030
1031 // Process results and build the map
1032 for result in results {
1033 let (key, prepared) = result?;
1034 prepared_versions.insert(key, prepared);
1035 }
1036
1037 Ok(prepared_versions)
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044 use tempfile::TempDir;
1045
1046 #[tokio::test]
1047 async fn test_version_resolver_deduplication() {
1048 let temp_dir = TempDir::new().unwrap();
1049 let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1050 let mut resolver = VersionResolver::new(cache);
1051
1052 // Add same version multiple times
1053 resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
1054 resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
1055 resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
1056
1057 // Should only have one entry
1058 assert_eq!(resolver.pending_count(), 1);
1059 }
1060
1061 #[tokio::test]
1062 async fn test_sha_optimization() {
1063 let temp_dir = TempDir::new().unwrap();
1064 let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1065 let _resolver = VersionResolver::new(cache);
1066
1067 // Test that full SHA is recognized
1068 let full_sha = "a".repeat(40);
1069 assert_eq!(full_sha.len(), 40);
1070 assert!(full_sha.chars().all(|c| c.is_ascii_hexdigit()));
1071 }
1072
1073 #[tokio::test]
1074 async fn test_resolved_retrieval() {
1075 let temp_dir = TempDir::new().unwrap();
1076 let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1077 let mut resolver = VersionResolver::new(cache);
1078
1079 // Manually insert a resolved SHA for testing
1080 let key = ("test_source".to_string(), "v1.0.0".to_string());
1081 let sha = "1234567890abcdef1234567890abcdef12345678";
1082 resolver.resolved.insert(
1083 key,
1084 ResolvedVersion {
1085 sha: sha.to_string(),
1086 resolved_ref: "v1.0.0".to_string(),
1087 },
1088 );
1089
1090 // Verify retrieval
1091 assert!(resolver.is_resolved("test_source", "v1.0.0"));
1092 assert_eq!(resolver.get_resolved_sha("test_source", "v1.0.0"), Some(sha.to_string()));
1093 assert!(!resolver.is_resolved("test_source", "v2.0.0"));
1094 }
1095
1096 #[tokio::test]
1097 async fn test_worktree_group_key() {
1098 assert_eq!(WorktreeManager::group_key("source", "version"), "source::version");
1099 assert_eq!(WorktreeManager::group_key("community", "v1.0.0"), "community::v1.0.0");
1100 }
1101}