use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::cache::Cache;
use crate::git::GitRepo;
#[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 crate::resolver::version_resolution::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}'"
));
}
crate::resolver::version_resolution::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 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()
}
}
#[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"));
}
}