use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::cache::Cache;
use crate::git::GitRepo;
use crate::manifest::ResourceDependency;
use crate::source::SourceManager;
#[derive(Debug, Clone)]
pub struct VersionEntry {
pub source: String,
pub url: String,
pub version: Option<String>,
pub resolved_sha: Option<String>,
pub resolved_version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedVersion {
pub sha: String,
pub resolved_ref: String,
}
pub struct VersionResolver {
cache: Cache,
entries: HashMap<(String, String), VersionEntry>,
resolved: HashMap<(String, String), ResolvedVersion>,
bare_repos: HashMap<String, PathBuf>,
}
impl VersionResolver {
pub fn new(cache: Cache) -> Self {
Self {
cache,
entries: HashMap::new(),
resolved: HashMap::new(),
bare_repos: HashMap::new(),
}
}
pub fn add_version(&mut self, source: &str, url: &str, version: Option<&str>) {
let version_key = version.unwrap_or("HEAD").to_string();
let key = (source.to_string(), version_key);
self.entries.entry(key).or_insert_with(|| VersionEntry {
source: source.to_string(),
url: url.to_string(),
version: version.map(std::string::ToString::to_string),
resolved_sha: None,
resolved_version: None,
});
}
pub async fn resolve_all(&mut self) -> Result<()> {
let mut by_source: HashMap<String, Vec<(String, VersionEntry)>> = HashMap::new();
for (key, entry) in &self.entries {
by_source.entry(entry.source.clone()).or_default().push((key.1.clone(), entry.clone()));
}
for (source, versions) in by_source {
let repo_path = self
.bare_repos
.get(&source)
.ok_or_else(|| {
anyhow::anyhow!("Repository for source '{source}' was not pre-synced. Call pre_sync_sources() first.")
})?
.clone();
let repo = GitRepo::new(&repo_path);
for (version_str, mut entry) in versions {
let is_local = crate::utils::is_local_path(&entry.url);
let resolved_ref = if is_local {
"local".to_string()
} else if let Some(ref version) = entry.version {
if is_version_constraint(version) {
let tags = repo.list_tags().await.unwrap_or_default();
if tags.is_empty() {
return Err(anyhow::anyhow!(
"No tags found in repository for constraint '{version}'"
));
}
find_best_matching_tag(version, tags)
.with_context(|| format!("Failed to resolve version constraint '{version}' for source '{source}'"))?
} else {
version.clone()
}
} else {
repo.get_default_branch().await.unwrap_or_else(|_| "main".to_string())
};
let sha = if is_local {
None
} else {
Some(repo.resolve_to_sha(Some(&resolved_ref)).await.with_context(|| {
format!("Failed to resolve version '{version_str}' for source '{source}'")
})?)
};
entry.resolved_sha = sha.clone();
entry.resolved_version = Some(resolved_ref.clone());
let key = (source.clone(), version_str);
if let Some(sha_value) = sha {
self.resolved.insert(
key,
ResolvedVersion {
sha: sha_value,
resolved_ref,
},
);
}
}
}
Ok(())
}
pub async fn resolve_single(
&mut self,
source: &str,
url: &str,
version: Option<&str>,
) -> Result<String> {
let repo_path = self
.cache
.get_or_clone_source(source, url, None)
.await
.with_context(|| format!("Failed to prepare repository for source '{source}'"))?;
let repo = GitRepo::new(&repo_path);
let sha = repo.resolve_to_sha(version).await.with_context(|| {
format!(
"Failed to resolve version '{}' for source '{}'",
version.unwrap_or("HEAD"),
source
)
})?;
let resolved_ref = if let Some(v) = version {
v.to_string()
} else {
repo.get_default_branch().await.unwrap_or_else(|_| "main".to_string())
};
let version_key = version.unwrap_or("HEAD").to_string();
let key = (source.to_string(), version_key);
self.resolved.insert(
key,
ResolvedVersion {
sha: sha.clone(),
resolved_ref,
},
);
Ok(sha)
}
pub fn get_resolved_sha(&self, source: &str, version: &str) -> Option<String> {
let key = (source.to_string(), version.to_string());
self.resolved.get(&key).map(|rv| rv.sha.clone())
}
pub fn get_all_resolved(&self) -> HashMap<(String, String), String> {
self.resolved.iter().map(|(k, v)| (k.clone(), v.sha.clone())).collect()
}
pub const fn get_all_resolved_full(&self) -> &HashMap<(String, String), ResolvedVersion> {
&self.resolved
}
pub fn is_resolved(&self, source: &str, version: &str) -> bool {
let key = (source.to_string(), version.to_string());
self.resolved.contains_key(&key)
}
pub async fn pre_sync_sources(&mut self) -> Result<()> {
let mut unique_sources: HashMap<String, String> = HashMap::new();
for entry in self.entries.values() {
unique_sources.insert(entry.source.clone(), entry.url.clone());
}
for (source, url) in unique_sources {
let repo_path = self
.cache
.get_or_clone_source(&source, &url, None)
.await
.with_context(|| format!("Failed to sync repository for source '{source}'"))?;
self.bare_repos.insert(source.clone(), repo_path);
}
Ok(())
}
pub fn get_bare_repo_path(&self, source: &str) -> Option<&PathBuf> {
self.bare_repos.get(source)
}
pub fn register_bare_repo(&mut self, source: String, repo_path: PathBuf) {
self.bare_repos.insert(source, repo_path);
}
pub fn clear(&mut self) {
self.entries.clear();
self.resolved.clear();
self.bare_repos.clear();
}
pub fn pending_count(&self) -> usize {
self.entries.len()
}
pub fn has_entries(&self) -> bool {
!self.entries.is_empty()
}
pub fn resolved_count(&self) -> usize {
self.resolved.len()
}
}
use super::types::ResolutionCore;
use std::path::Path;
pub struct VersionResolutionService {
version_resolver: VersionResolver,
prepared_versions: HashMap<String, PreparedSourceVersion>,
}
impl VersionResolutionService {
pub fn new(cache: crate::cache::Cache) -> Self {
Self {
version_resolver: VersionResolver::new(cache),
prepared_versions: HashMap::new(),
}
}
pub async fn pre_sync_sources(
&mut self,
core: &ResolutionCore,
deps: &[(String, ResourceDependency)],
) -> Result<()> {
self.version_resolver.clear();
for (_name, dep) in deps {
if let Some(source) = dep.get_source() {
let version = dep.get_version();
let source_url = core
.source_manager
.get_source_url(source)
.ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source))?;
self.version_resolver.add_version(source, &source_url, version);
}
}
self.version_resolver.pre_sync_sources().await?;
self.version_resolver.resolve_all().await?;
for (_name, dep) in deps {
if let Some(source) = dep.get_source() {
let source_url = core
.source_manager
.get_source_url(source)
.ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source))?;
if crate::utils::is_local_path(&source_url) {
let version_key = dep.get_version().unwrap_or("HEAD");
let group_key = format!("{}::{}", source, version_key);
self.prepared_versions.insert(
group_key,
PreparedSourceVersion {
worktree_path: PathBuf::from(&source_url),
resolved_version: Some("local".to_string()),
resolved_commit: String::new(), },
);
}
}
}
let worktree_manager =
WorktreeManager::new(&core.cache, &core.source_manager, &self.version_resolver);
let prepared = worktree_manager.create_worktrees_for_resolved_versions().await?;
self.prepared_versions.extend(prepared);
Ok(())
}
pub fn get_prepared_version(&self, group_key: &str) -> Option<&PreparedSourceVersion> {
self.prepared_versions.get(group_key)
}
pub fn prepared_versions(&self) -> &HashMap<String, PreparedSourceVersion> {
&self.prepared_versions
}
pub async fn prepare_additional_version(
&mut self,
core: &ResolutionCore,
source_name: &str,
version: Option<&str>,
) -> Result<()> {
let version_key = version.unwrap_or("HEAD");
let source_url = core
.source_manager
.get_source_url(source_name)
.ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source_name))?;
if crate::utils::is_local_path(&source_url) {
let group_key = format!("{}::{}", source_name, version_key);
self.prepared_versions.insert(
group_key,
PreparedSourceVersion {
worktree_path: PathBuf::from(&source_url),
resolved_version: Some("local".to_string()),
resolved_commit: String::new(),
},
);
return Ok(());
}
self.version_resolver.add_version(source_name, &source_url, version);
if self.version_resolver.get_bare_repo_path(source_name).is_none() {
let repo_path =
core.cache.get_or_clone_source(source_name, &source_url, None).await.with_context(
|| format!("Failed to sync repository for source '{}'", source_name),
)?;
self.version_resolver.register_bare_repo(source_name.to_string(), repo_path);
}
self.version_resolver.resolve_all().await?;
let resolved_version_data = self
.version_resolver
.get_all_resolved_full()
.get(&(source_name.to_string(), version_key.to_string()))
.ok_or_else(|| {
anyhow::anyhow!("Failed to resolve version for {} @ {}", source_name, version_key)
})?
.clone();
let sha = resolved_version_data.sha.clone();
let resolved_ref = resolved_version_data.resolved_ref.clone();
let worktree_path =
core.cache.get_or_create_worktree_for_sha(source_name, &source_url, &sha, None).await?;
let group_key = format!("{}::{}", source_name, version_key);
self.prepared_versions.insert(
group_key,
PreparedSourceVersion {
worktree_path,
resolved_version: Some(resolved_ref),
resolved_commit: sha,
},
);
Ok(())
}
pub async fn get_available_versions(
_core: &ResolutionCore,
repo_path: &Path,
) -> Result<Vec<String>> {
let repo = GitRepo::new(repo_path);
let tags = repo.list_tags().await.context("Failed to list tags")?;
let versions = tags;
Ok(versions)
}
#[cfg(test)]
pub fn version_resolver(&self) -> &VersionResolver {
&self.version_resolver
}
}
use crate::version::constraints::{ConstraintSet, VersionConstraint};
use semver::Version;
#[must_use]
pub fn is_version_constraint(version: &str) -> bool {
let (_prefix, version_str) = crate::version::split_prefix_and_version(version);
if version_str == "*" {
return true;
}
if version_str.starts_with('^')
|| version_str.starts_with('~')
|| version_str.starts_with('>')
|| version_str.starts_with('<')
|| version_str.starts_with('=')
|| version_str.contains(',')
{
return true;
}
false
}
#[must_use]
pub fn parse_tags_to_versions(tags: Vec<String>) -> Vec<(String, Version)> {
let mut versions = Vec::new();
for tag in tags {
let (_prefix, version_str) = crate::version::split_prefix_and_version(&tag);
let cleaned = version_str.trim_start_matches('v').trim_start_matches('V');
if let Ok(version) = Version::parse(cleaned) {
versions.push((tag, version));
}
}
versions.sort_by(|a, b| b.1.cmp(&a.1));
versions
}
pub fn find_best_matching_tag(constraint_str: &str, tags: Vec<String>) -> Result<String> {
let (constraint_prefix, version_str) = crate::version::split_prefix_and_version(constraint_str);
let filtered_tags: Vec<String> = tags
.into_iter()
.filter(|tag| {
let (tag_prefix, _) = crate::version::split_prefix_and_version(tag);
tag_prefix.as_ref() == constraint_prefix.as_ref()
})
.collect();
if filtered_tags.is_empty() {
return Err(anyhow::anyhow!(
"No tags found with matching prefix for constraint: {constraint_str}"
));
}
let tag_versions = parse_tags_to_versions(filtered_tags);
if tag_versions.is_empty() {
return Err(anyhow::anyhow!(
"No valid semantic version tags found for constraint: {constraint_str}"
));
}
if version_str == "*" {
return Ok(tag_versions[0].0.clone());
}
let constraint = VersionConstraint::parse(version_str)?;
let versions: Vec<Version> = tag_versions.iter().map(|(_, v)| v.clone()).collect();
let mut constraint_set = ConstraintSet::new();
constraint_set.add(constraint)?;
if let Some(best_version) = constraint_set.find_best_match(&versions) {
for (tag_name, version) in tag_versions {
if &version == best_version {
return Ok(tag_name);
}
}
}
Err(anyhow::anyhow!("No tag found matching constraint: {constraint_str}"))
}
#[derive(Clone, Debug, Default)]
pub struct PreparedSourceVersion {
pub worktree_path: std::path::PathBuf,
pub resolved_version: Option<String>,
pub resolved_commit: String,
}
pub struct WorktreeManager<'a> {
cache: &'a Cache,
source_manager: &'a SourceManager,
version_resolver: &'a VersionResolver,
}
impl<'a> WorktreeManager<'a> {
pub fn new(
cache: &'a Cache,
source_manager: &'a SourceManager,
version_resolver: &'a VersionResolver,
) -> Self {
Self {
cache,
source_manager,
version_resolver,
}
}
pub fn group_key(source: &str, version: &str) -> String {
format!("{source}::{version}")
}
pub async fn create_worktrees_for_resolved_versions(
&self,
) -> Result<HashMap<String, PreparedSourceVersion>> {
use crate::core::AgpmError;
use futures::future::join_all;
let resolved_full = self.version_resolver.get_all_resolved_full().clone();
let mut prepared_versions = HashMap::new();
let mut futures = Vec::new();
for ((source_name, version_key), resolved_version) in resolved_full {
let sha = resolved_version.sha;
let resolved_ref = resolved_version.resolved_ref;
let repo_key = Self::group_key(&source_name, &version_key);
let cache_clone = self.cache.clone();
let source_name_clone = source_name.clone();
let source_url_clone = self
.source_manager
.get_source_url(&source_name)
.ok_or_else(|| AgpmError::SourceNotFound {
name: source_name.to_string(),
})?
.to_string();
let sha_clone = sha.clone();
let resolved_ref_clone = resolved_ref.clone();
let future = async move {
let worktree_path = cache_clone
.get_or_create_worktree_for_sha(
&source_name_clone,
&source_url_clone,
&sha_clone,
Some(&source_name_clone), )
.await?;
Ok::<_, anyhow::Error>((
repo_key,
PreparedSourceVersion {
worktree_path,
resolved_version: Some(resolved_ref_clone),
resolved_commit: sha_clone,
},
))
};
futures.push(future);
}
let results = join_all(futures).await;
for result in results {
let (key, prepared) = result?;
prepared_versions.insert(key, prepared);
}
Ok(prepared_versions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_version_resolver_deduplication() {
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = VersionResolver::new(cache);
resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
assert_eq!(resolver.pending_count(), 1);
}
#[tokio::test]
async fn test_sha_optimization() {
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let _resolver = VersionResolver::new(cache);
let full_sha = "a".repeat(40);
assert_eq!(full_sha.len(), 40);
assert!(full_sha.chars().all(|c| c.is_ascii_hexdigit()));
}
#[tokio::test]
async fn test_resolved_retrieval() {
let temp_dir = TempDir::new().unwrap();
let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
let mut resolver = VersionResolver::new(cache);
let key = ("test_source".to_string(), "v1.0.0".to_string());
let sha = "1234567890abcdef1234567890abcdef12345678";
resolver.resolved.insert(
key,
ResolvedVersion {
sha: sha.to_string(),
resolved_ref: "v1.0.0".to_string(),
},
);
assert!(resolver.is_resolved("test_source", "v1.0.0"));
assert_eq!(resolver.get_resolved_sha("test_source", "v1.0.0"), Some(sha.to_string()));
assert!(!resolver.is_resolved("test_source", "v2.0.0"));
}
#[tokio::test]
async fn test_worktree_group_key() {
assert_eq!(WorktreeManager::group_key("source", "version"), "source::version");
assert_eq!(WorktreeManager::group_key("community", "v1.0.0"), "community::v1.0.0");
}
}