use crate::git::GitRepo;
use anyhow::{Context, Result};
use regex::Regex;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub fn parse_version_req(requirement: &str) -> Result<VersionReq, semver::Error> {
static RE: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"(^|[~^=><])v").unwrap());
let normalized = RE.replace_all(requirement, "$1");
VersionReq::parse(&normalized)
}
#[inline]
pub fn split_prefix_and_version(s: &str) -> (Option<String>, &str) {
for (byte_idx, ch) in s.char_indices() {
if "^~=<>!*".contains(ch) {
return split_at_index(s, byte_idx);
}
if ch == 'v' {
if s[byte_idx..].chars().nth(1).is_some_and(|next| next.is_ascii_digit()) {
return split_at_index(s, byte_idx);
}
}
if ch.is_ascii_digit() {
let is_version_start = byte_idx == 0 || s[..byte_idx].ends_with('-');
if is_version_start {
return split_at_index(s, byte_idx);
}
}
}
(None, s)
}
#[inline]
fn split_at_index(s: &str, i: usize) -> (Option<String>, &str) {
if i == 0 {
(None, s)
} else {
let prefix = s[..i].trim_end_matches('-');
if prefix.is_empty() {
(None, &s[i..])
} else {
(Some(prefix.to_string()), &s[i..])
}
}
}
#[derive(Debug, Clone)]
pub struct VersionInfo {
pub prefix: Option<String>,
pub version: Version,
pub tag: String,
pub prerelease: bool,
}
pub struct VersionResolver {
versions: Vec<Arc<VersionInfo>>,
}
impl VersionResolver {
#[must_use]
pub const fn new() -> Self {
Self {
versions: Vec::new(),
}
}
pub async fn from_git_tags(repo: &GitRepo) -> Result<Self> {
let tags = repo.list_tags().await?;
let mut versions = Vec::new();
for tag in tags {
if let Ok((prefix, version)) = Self::parse_tag(&tag) {
versions.push(Arc::new(VersionInfo {
prefix,
version: version.clone(),
tag: tag.clone(),
prerelease: !version.pre.is_empty(),
}));
}
}
versions.sort_by(|a, b| b.version.cmp(&a.version));
Ok(Self {
versions,
})
}
fn parse_tag(tag: &str) -> Result<(Option<String>, Version)> {
let (prefix, version_str) = split_prefix_and_version(tag);
let cleaned = version_str.trim_start_matches('v').trim_start_matches('V');
let version = Version::parse(cleaned)
.with_context(|| format!("Failed to parse version from tag: {tag}"))?;
Ok((prefix, version))
}
pub fn resolve(&self, requirement: &str) -> Result<Option<Arc<VersionInfo>>> {
let (prefix, version_str) = split_prefix_and_version(requirement);
let matching_prefix: Vec<&Arc<VersionInfo>> =
self.versions.iter().filter(|v| v.prefix.as_ref() == prefix.as_ref()).collect();
if let Ok(exact_version) = Version::parse(version_str.trim_start_matches('v')) {
return Ok(matching_prefix
.iter()
.find(|v| v.version == exact_version)
.map(|&v| Arc::clone(v)));
}
if let Ok(req) = parse_version_req(version_str) {
return Ok(matching_prefix
.iter()
.filter(|v| !v.prerelease) .find(|v| req.matches(&v.version))
.map(|&v| Arc::clone(v)));
}
for version in &self.versions {
if version.tag == requirement {
return Ok(Some(Arc::clone(version)));
}
}
Ok(None)
}
#[must_use]
pub fn get_latest(&self) -> Option<Arc<VersionInfo>> {
self.versions.first().map(Arc::clone)
}
#[must_use]
pub fn get_latest_stable(&self) -> Option<Arc<VersionInfo>> {
self.versions.iter().find(|v| !v.prerelease).map(Arc::clone)
}
#[must_use]
pub fn list_all(&self) -> Vec<Arc<VersionInfo>> {
self.versions.clone()
}
#[must_use]
pub fn list_stable(&self) -> Vec<Arc<VersionInfo>> {
self.versions.iter().filter(|v| !v.prerelease).map(Arc::clone).collect()
}
#[must_use]
pub fn has_version(&self, version: &str) -> bool {
self.resolve(version).unwrap_or(None).is_some()
}
}
impl Default for VersionResolver {
fn default() -> Self {
Self::new()
}
}
pub fn matches_requirement(version: &str, requirement: &str) -> Result<bool> {
let (version_prefix, version_str) = split_prefix_and_version(version);
let (req_prefix, req_str) = split_prefix_and_version(requirement);
if version_prefix != req_prefix {
return Ok(false);
}
if req_str == "*" {
return Ok(true);
}
let version = Version::parse(version_str.trim_start_matches('v'))?;
let req = parse_version_req(req_str)
.map_err(|e| anyhow::anyhow!("Invalid version requirement '{requirement}': {e}"))?;
Ok(req.matches(&version))
}
#[must_use]
pub fn parse_version_constraint(constraint: &str) -> VersionConstraint {
if constraint.len() >= 7 && constraint.chars().all(|c| c.is_ascii_hexdigit()) {
return VersionConstraint::Commit(constraint.to_string());
}
let (_prefix, version_str) = split_prefix_and_version(constraint);
if Version::parse(version_str.trim_start_matches('v')).is_ok()
|| parse_version_req(version_str).is_ok()
|| version_str == "*"
{
return VersionConstraint::Tag(constraint.to_string());
}
VersionConstraint::Branch(constraint.to_string())
}
pub mod comparison;
pub mod conflict;
pub mod constraints;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum VersionConstraint {
Tag(String),
Branch(String),
Commit(String),
}
impl VersionConstraint {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Tag(s) => s,
Self::Branch(s) => s,
Self::Commit(s) => s,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::TestGit;
use tempfile::TempDir;
fn create_test_repo_with_tags() -> (TempDir, GitRepo) {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let git = TestGit::new(repo_path);
git.init().unwrap();
git.config_user().unwrap();
std::fs::write(repo_path.join("README.md"), "Test").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
let tags = vec!["v1.0.0", "v1.1.0", "v1.2.0", "v2.0.0-beta.1", "v2.0.0"];
for tag in tags {
git.tag(tag).unwrap();
}
let repo = GitRepo::new(repo_path);
(temp_dir, repo)
}
#[tokio::test]
async fn test_version_parsing() {
let (_temp, repo) = create_test_repo_with_tags();
let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
assert_eq!(resolver.versions.len(), 5);
assert_eq!(resolver.get_latest().unwrap().tag, "v2.0.0");
assert_eq!(resolver.get_latest_stable().unwrap().tag, "v2.0.0");
}
#[tokio::test]
async fn test_version_resolution() {
let (_temp, repo) = create_test_repo_with_tags();
let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
assert_eq!(resolver.resolve("1.1.0").unwrap().unwrap().tag, "v1.1.0");
assert_eq!(resolver.resolve("v1.1.0").unwrap().unwrap().tag, "v1.1.0");
assert_eq!(resolver.resolve("^1.0.0").unwrap().unwrap().tag, "v1.2.0");
assert_eq!(resolver.resolve("~1.1.0").unwrap().unwrap().tag, "v1.1.0");
}
#[tokio::test]
async fn test_has_version() {
let (_temp, repo) = create_test_repo_with_tags();
let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
assert!(resolver.has_version("v1.0.0"));
assert!(resolver.has_version("1.0.0"));
assert!(!resolver.has_version("v3.0.0"));
assert!(!resolver.has_version("latest"));
assert!(!resolver.has_version("latest-prerelease"));
}
#[tokio::test]
async fn test_matches_requirement() {
assert!(matches_requirement("1.2.0", "^1.0.0").unwrap());
assert!(matches_requirement("v1.2.0", "^1.0.0").unwrap());
assert!(!matches_requirement("2.0.0", "^1.0.0").unwrap());
assert!(matches_requirement("any.version", "*").unwrap());
}
#[test]
fn test_matches_requirement_with_prefixes() {
assert!(matches_requirement("agents-v1.2.0", "agents-^v1.0.0").unwrap());
assert!(matches_requirement("agents-v1.2.5", "agents-~v1.2.0").unwrap());
assert!(matches_requirement("tool123-v2.0.0", "tool123->=v1.0.0").unwrap());
assert!(matches_requirement("agents-v1.2.0", "agents-*").unwrap());
assert!(!matches_requirement("agents-v1.2.0", "^v1.0.0").unwrap());
assert!(!matches_requirement("v1.2.0", "agents-^v1.0.0").unwrap());
assert!(!matches_requirement("agents-v1.2.0", "snippets-^v1.0.0").unwrap());
assert!(!matches_requirement("tool-v1.0.0", "agent-v1.0.0").unwrap());
assert!(!matches_requirement("agents-v2.0.0", "agents-^v1.0.0").unwrap());
}
#[test]
fn test_parse_version_constraint() {
assert_eq!(
parse_version_constraint("v1.0.0"),
VersionConstraint::Tag("v1.0.0".to_string())
);
assert_eq!(
parse_version_constraint("^1.0.0"),
VersionConstraint::Tag("^1.0.0".to_string())
);
assert_eq!(parse_version_constraint("*"), VersionConstraint::Tag("*".to_string()));
assert_eq!(parse_version_constraint("main"), VersionConstraint::Branch("main".to_string()));
assert_eq!(
parse_version_constraint("latest"),
VersionConstraint::Branch("latest".to_string())
);
assert_eq!(
parse_version_constraint("latest-prerelease"),
VersionConstraint::Branch("latest-prerelease".to_string())
);
assert_eq!(
parse_version_constraint("feature/test"),
VersionConstraint::Branch("feature/test".to_string())
);
assert_eq!(
parse_version_constraint("abc1234"),
VersionConstraint::Commit("abc1234".to_string())
);
assert_eq!(
parse_version_constraint("1234567890abcdef"),
VersionConstraint::Commit("1234567890abcdef".to_string())
);
assert_eq!(
parse_version_constraint("agents-v1.2.0"),
VersionConstraint::Tag("agents-v1.2.0".to_string())
);
assert_eq!(
parse_version_constraint("agents-^v1.0.0"),
VersionConstraint::Tag("agents-^v1.0.0".to_string())
);
assert_eq!(
parse_version_constraint("snippets-~v2.0.0"),
VersionConstraint::Tag("snippets-~v2.0.0".to_string())
);
assert_eq!(
parse_version_constraint("tool123-*"),
VersionConstraint::Tag("tool123-*".to_string())
);
assert_eq!(
parse_version_constraint("my-cool-tool->=v1.0.0"),
VersionConstraint::Tag("my-cool-tool->=v1.0.0".to_string())
);
assert_eq!(
parse_version_constraint("agents-main"),
VersionConstraint::Branch("agents-main".to_string())
);
}
#[tokio::test]
async fn test_version_list_all() {
let (_temp, repo) = create_test_repo_with_tags();
let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
let all_versions = resolver.list_all();
assert_eq!(all_versions.len(), 5);
assert_eq!(all_versions[0].tag, "v2.0.0");
assert_eq!(all_versions[1].tag, "v2.0.0-beta.1");
}
#[tokio::test]
async fn test_version_list_stable() {
let (_temp, repo) = create_test_repo_with_tags();
let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
let stable_versions = resolver.list_stable();
assert_eq!(stable_versions.len(), 4);
for version in stable_versions {
assert!(!version.prerelease);
}
}
#[test]
fn test_split_prefix_and_version() {
assert_eq!(
split_prefix_and_version("agents-v1.0.0"),
(Some("agents".to_string()), "v1.0.0")
);
assert_eq!(
split_prefix_and_version("agents-^v1.0.0"),
(Some("agents".to_string()), "^v1.0.0")
);
assert_eq!(
split_prefix_and_version("my-cool-agent-v2.0.0"),
(Some("my-cool-agent".to_string()), "v2.0.0")
);
assert_eq!(split_prefix_and_version("v1.0.0"), (None, "v1.0.0"));
assert_eq!(split_prefix_and_version("^v1.0.0"), (None, "^v1.0.0"));
assert_eq!(split_prefix_and_version("1.0.0"), (None, "1.0.0"));
assert_eq!(
split_prefix_and_version("tool-v-v1.0.0"),
(Some("tool-v".to_string()), "v1.0.0")
);
assert_eq!(split_prefix_and_version("a-b-c-v1.0.0"), (Some("a-b-c".to_string()), "v1.0.0"));
assert_eq!(
split_prefix_and_version("prefix-~1.0.0"),
(Some("prefix".to_string()), "~1.0.0")
);
}
#[test]
fn test_split_prefix_edge_cases() {
assert_eq!(split_prefix_and_version("-v1.0.0"), (None, "v1.0.0"));
assert_eq!(split_prefix_and_version("--v1.0.0"), (None, "v1.0.0"));
assert_eq!(
split_prefix_and_version("tool123-v1.0.0"),
(Some("tool123".to_string()), "v1.0.0")
);
assert_eq!(
split_prefix_and_version("agent2-v1.0.0"),
(Some("agent2".to_string()), "v1.0.0")
);
assert_eq!(split_prefix_and_version("tool-123"), (Some("tool".to_string()), "123"));
assert_eq!(
split_prefix_and_version("abc-v2-agent-v1.0.0"),
(Some("abc".to_string()), "v2-agent-v1.0.0")
);
let long_prefix = "a".repeat(100);
let tag = format!("{}-v1.0.0", long_prefix);
let (prefix, version) = split_prefix_and_version(&tag);
assert_eq!(prefix, Some(long_prefix));
assert_eq!(version, "v1.0.0");
assert_eq!(
split_prefix_and_version("агенты-v1.0.0"),
(Some("агенты".to_string()), "v1.0.0")
);
assert_eq!(split_prefix_and_version("工具-v1.0.0"), (Some("工具".to_string()), "v1.0.0"));
assert_eq!(split_prefix_and_version("агенты-2.0.0"), (Some("агенты".to_string()), "2.0.0"));
assert_eq!(split_prefix_and_version("prefix-v"), (None, "prefix-v"));
assert_eq!(split_prefix_and_version("v"), (None, "v"));
assert_eq!(
split_prefix_and_version("my-cool-tool-v1.0.0"),
(Some("my-cool-tool".to_string()), "v1.0.0")
);
}
fn create_test_repo_with_prefixed_tags() -> (TempDir, GitRepo) {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let git = TestGit::new(repo_path);
git.init().unwrap();
git.config_user().unwrap();
std::fs::write(repo_path.join("README.md"), "Test").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
let tags = vec![
"agents-v1.0.0",
"agents-v1.2.0",
"agents-v2.0.0",
"snippets-v1.0.0",
"snippets-v1.5.0",
"v1.0.0", "v2.0.0", ];
for tag in tags {
git.tag(tag).unwrap();
}
let repo = GitRepo::new(repo_path);
(temp_dir, repo)
}
#[tokio::test]
async fn test_prefixed_version_parsing() {
let (_temp, repo) = create_test_repo_with_prefixed_tags();
let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
assert_eq!(resolver.versions.len(), 7);
let agents_versions: Vec<_> =
resolver.versions.iter().filter(|v| v.prefix == Some("agents".to_string())).collect();
assert_eq!(agents_versions.len(), 3);
let snippets_versions: Vec<_> =
resolver.versions.iter().filter(|v| v.prefix == Some("snippets".to_string())).collect();
assert_eq!(snippets_versions.len(), 2);
let unprefixed_versions: Vec<_> =
resolver.versions.iter().filter(|v| v.prefix.is_none()).collect();
assert_eq!(unprefixed_versions.len(), 2);
}
#[tokio::test]
async fn test_prefixed_version_resolution() {
let (_temp, repo) = create_test_repo_with_prefixed_tags();
let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
let result = resolver.resolve("agents-v1.2.0").unwrap().unwrap();
assert_eq!(result.tag, "agents-v1.2.0");
assert_eq!(result.prefix, Some("agents".to_string()));
let result = resolver.resolve("agents-^v1.0.0").unwrap().unwrap();
assert_eq!(result.tag, "agents-v1.2.0");
assert_eq!(result.prefix, Some("agents".to_string()));
let result = resolver.resolve("snippets-^v1.0.0").unwrap().unwrap();
assert_eq!(result.tag, "snippets-v1.5.0");
assert_eq!(result.prefix, Some("snippets".to_string()));
let result = resolver.resolve("^v1.0.0").unwrap().unwrap();
assert_eq!(result.tag, "v1.0.0");
assert_eq!(result.prefix, None);
}
#[tokio::test]
async fn test_prefix_isolation() {
let (_temp, repo) = create_test_repo_with_prefixed_tags();
let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
let result = resolver.resolve("agents-^v1.0.0").unwrap().unwrap();
assert_eq!(result.prefix, Some("agents".to_string()));
assert_ne!(result.tag, "snippets-v1.5.0");
let result = resolver.resolve("^v1.0.0").unwrap().unwrap();
assert_eq!(result.prefix, None);
assert!(!result.tag.contains("agents-"));
assert!(!result.tag.contains("snippets-"));
}
#[test]
fn test_parse_version_req_with_prefix() -> anyhow::Result<()> {
parse_version_req("^1.0.0")?;
parse_version_req("^v1.0.0")?;
parse_version_req("~2.1.0")?;
parse_version_req(">=1.0.0")?;
Ok(())
}
}