use anyhow::Result;
use semver::{Version, VersionReq};
use std::collections::HashMap;
use std::fmt;
use crate::core::AgpmError;
#[derive(Debug, Clone)]
pub enum VersionConstraint {
Exact {
prefix: Option<String>,
version: Version,
},
Requirement {
prefix: Option<String>,
req: VersionReq,
},
GitRef(String),
}
impl VersionConstraint {
pub fn parse(constraint: &str) -> Result<Self> {
let trimmed = constraint.trim();
let (prefix, version_str) = crate::version::split_prefix_and_version(trimmed);
if version_str == "*" {
return Ok(Self::GitRef(trimmed.to_string()));
}
let cleaned_version_str = version_str.strip_prefix('v').unwrap_or(version_str);
if let Ok(version) = Version::parse(cleaned_version_str) {
if !version_str.starts_with('^')
&& !version_str.starts_with('~')
&& !version_str.starts_with('>')
&& !version_str.starts_with('<')
&& !version_str.starts_with('=')
{
return Ok(Self::Exact {
prefix,
version,
});
}
}
match crate::version::parse_version_req(version_str) {
Ok(req) => {
return Ok(Self::Requirement {
prefix,
req,
});
}
Err(e) => {
if version_str.starts_with('^')
|| version_str.starts_with('~')
|| version_str.starts_with('=')
|| version_str.starts_with('>')
|| version_str.starts_with('<')
{
return Err(anyhow::anyhow!("Invalid semver constraint '{trimmed}': {e}"));
}
}
}
Ok(Self::GitRef(trimmed.to_string()))
}
#[must_use]
pub fn matches(&self, version: &Version) -> bool {
match self {
Self::Exact {
version: v,
..
} => v == version,
Self::Requirement {
req,
..
} => req.matches(version),
Self::GitRef(_) => false, }
}
#[must_use]
pub fn matches_ref(&self, git_ref: &str) -> bool {
match self {
Self::GitRef(ref_name) => ref_name == git_ref,
_ => false,
}
}
#[inline]
#[must_use]
pub fn matches_version_info(&self, version_info: &crate::version::VersionInfo) -> bool {
let constraint_prefix = match self {
Self::Exact {
prefix,
..
}
| Self::Requirement {
prefix,
..
} => prefix.as_ref(),
_ => None,
};
if constraint_prefix != version_info.prefix.as_ref() {
return false;
}
self.matches(&version_info.version)
}
#[must_use]
pub fn to_version_req(&self) -> Option<VersionReq> {
match self {
Self::Exact {
version,
..
} => {
VersionReq::parse(&format!("={version}")).ok()
}
Self::Requirement {
req,
..
} => Some(req.clone()),
Self::GitRef(_) => None, }
}
#[must_use]
pub const fn allows_prerelease(&self) -> bool {
matches!(self, Self::GitRef(_))
}
}
impl fmt::Display for VersionConstraint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Exact {
prefix,
version,
} => {
if let Some(p) = prefix {
write!(f, "{p}-{version}")
} else {
write!(f, "{version}")
}
}
Self::Requirement {
prefix,
req,
} => {
if let Some(p) = prefix {
write!(f, "{p}-{req}")
} else {
write!(f, "{req}")
}
}
Self::GitRef(ref_name) => write!(f, "{ref_name}"),
}
}
}
#[derive(Debug, Clone)]
pub struct ConstraintSet {
constraints: Vec<VersionConstraint>,
}
impl Default for ConstraintSet {
fn default() -> Self {
Self::new()
}
}
impl ConstraintSet {
#[must_use]
pub const fn new() -> Self {
Self {
constraints: Vec::new(),
}
}
pub fn add(&mut self, constraint: VersionConstraint) -> Result<()> {
if self.has_conflict(&constraint) {
return Err(AgpmError::Other {
message: format!("Constraint {constraint} conflicts with existing constraints"),
}
.into());
}
self.constraints.push(constraint);
Ok(())
}
#[must_use]
pub fn satisfies(&self, version: &Version) -> bool {
self.constraints.iter().all(|c| c.matches(version))
}
#[must_use]
pub fn find_best_match<'a>(&self, versions: &'a [Version]) -> Option<&'a Version> {
let mut candidates: Vec<&Version> = versions.iter().filter(|v| self.satisfies(v)).collect();
candidates.sort_by(|a, b| b.cmp(a));
if !self.allows_prerelease() {
candidates.retain(|v| v.pre.is_empty());
}
candidates.first().copied()
}
#[must_use]
pub fn allows_prerelease(&self) -> bool {
self.constraints.iter().any(VersionConstraint::allows_prerelease)
}
fn has_conflict(&self, new_constraint: &VersionConstraint) -> bool {
for existing in &self.constraints {
match (existing, new_constraint) {
(
VersionConstraint::Exact {
prefix: p1,
version: v1,
},
VersionConstraint::Exact {
prefix: p2,
version: v2,
},
) => {
if p1 != p2 {
continue;
}
if v1 != v2 {
return true;
}
}
(VersionConstraint::GitRef(r1), VersionConstraint::GitRef(r2)) => {
if r1 != r2 {
return true;
}
}
(
VersionConstraint::Exact {
prefix: p1,
..
}
| VersionConstraint::Requirement {
prefix: p1,
..
},
VersionConstraint::Requirement {
prefix: p2,
..
},
)
| (
VersionConstraint::Requirement {
prefix: p1,
..
},
VersionConstraint::Exact {
prefix: p2,
..
},
) => {
if p1 != p2 {
}
}
_ => {
}
}
}
false
}
}
pub struct ConstraintResolver {
constraints: HashMap<String, ConstraintSet>,
}
impl Default for ConstraintResolver {
fn default() -> Self {
Self::new()
}
}
impl ConstraintResolver {
#[must_use]
pub fn new() -> Self {
Self {
constraints: HashMap::new(),
}
}
pub fn add_constraint(&mut self, dependency: &str, constraint: &str) -> Result<()> {
let parsed = VersionConstraint::parse(constraint)?;
self.constraints.entry(dependency.to_string()).or_default().add(parsed)?;
Ok(())
}
pub fn resolve(
&self,
available_versions: &HashMap<String, Vec<Version>>,
) -> Result<HashMap<String, Version>> {
let mut resolved = HashMap::new();
for (dep, constraint_set) in &self.constraints {
let versions = available_versions.get(dep).ok_or_else(|| AgpmError::Other {
message: format!("No versions available for dependency: {dep}"),
})?;
let best_match =
constraint_set.find_best_match(versions).ok_or_else(|| AgpmError::Other {
message: format!("No version satisfies constraints for dependency: {dep}"),
})?;
resolved.insert(dep.clone(), best_match.clone());
}
Ok(resolved)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_constraint_parse() {
let constraint = VersionConstraint::parse("1.0.0").unwrap();
assert!(matches!(constraint, VersionConstraint::Exact { .. }));
let constraint = VersionConstraint::parse("v1.0.0").unwrap();
assert!(matches!(constraint, VersionConstraint::Exact { .. }));
let constraint = VersionConstraint::parse("^1.0.0").unwrap();
assert!(matches!(constraint, VersionConstraint::Requirement { .. }));
let constraint = VersionConstraint::parse("~1.2.0").unwrap();
assert!(matches!(constraint, VersionConstraint::Requirement { .. }));
let constraint = VersionConstraint::parse(">=1.0.0, <2.0.0").unwrap();
assert!(matches!(constraint, VersionConstraint::Requirement { .. }));
let constraint = VersionConstraint::parse("latest").unwrap();
assert!(matches!(constraint, VersionConstraint::GitRef(_)));
let constraint = VersionConstraint::parse("main").unwrap();
assert!(matches!(constraint, VersionConstraint::GitRef(_)));
}
#[test]
fn test_constraint_matching() {
let v100 = Version::parse("1.0.0").unwrap();
let v110 = Version::parse("1.1.0").unwrap();
let v200 = Version::parse("2.0.0").unwrap();
let exact = VersionConstraint::Exact {
prefix: None,
version: v100.clone(),
};
assert!(exact.matches(&v100));
assert!(!exact.matches(&v110));
let caret = VersionConstraint::parse("^1.0.0").unwrap();
assert!(caret.matches(&v100));
assert!(caret.matches(&v110));
assert!(!caret.matches(&v200));
let git_ref = VersionConstraint::GitRef("latest".to_string());
assert!(!git_ref.matches(&v100));
assert!(!git_ref.matches(&v200));
}
#[test]
fn test_constraint_set() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::parse(">=1.0.0").unwrap()).unwrap();
set.add(VersionConstraint::parse("<2.0.0").unwrap()).unwrap();
let v090 = Version::parse("0.9.0").unwrap();
let v100 = Version::parse("1.0.0").unwrap();
let v150 = Version::parse("1.5.0").unwrap();
let v200 = Version::parse("2.0.0").unwrap();
assert!(!set.satisfies(&v090));
assert!(set.satisfies(&v100));
assert!(set.satisfies(&v150));
assert!(!set.satisfies(&v200));
}
#[test]
fn test_find_best_match() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::parse("^1.0.0").unwrap()).unwrap();
let versions = vec![
Version::parse("0.9.0").unwrap(),
Version::parse("1.0.0").unwrap(),
Version::parse("1.2.0").unwrap(),
Version::parse("1.5.0").unwrap(),
Version::parse("2.0.0").unwrap(),
];
let best = set.find_best_match(&versions).unwrap();
assert_eq!(best, &Version::parse("1.5.0").unwrap());
}
#[test]
fn test_constraint_conflicts() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::Exact {
prefix: None,
version: Version::parse("1.0.0").unwrap(),
})
.unwrap();
let result = set.add(VersionConstraint::Exact {
prefix: None,
version: Version::parse("2.0.0").unwrap(),
});
assert!(result.is_err());
let result = set.add(VersionConstraint::Exact {
prefix: None,
version: Version::parse("1.0.0").unwrap(),
});
assert!(result.is_ok());
}
#[test]
fn test_constraint_resolver() {
let mut resolver = ConstraintResolver::new();
resolver.add_constraint("dep1", "^1.0.0").unwrap();
resolver.add_constraint("dep2", "~2.1.0").unwrap();
let mut available = HashMap::new();
available.insert(
"dep1".to_string(),
vec![
Version::parse("0.9.0").unwrap(),
Version::parse("1.0.0").unwrap(),
Version::parse("1.5.0").unwrap(),
Version::parse("2.0.0").unwrap(),
],
);
available.insert(
"dep2".to_string(),
vec![
Version::parse("2.0.0").unwrap(),
Version::parse("2.1.0").unwrap(),
Version::parse("2.1.5").unwrap(),
Version::parse("2.2.0").unwrap(),
],
);
let resolved = resolver.resolve(&available).unwrap();
assert_eq!(resolved.get("dep1"), Some(&Version::parse("1.5.0").unwrap()));
assert_eq!(resolved.get("dep2"), Some(&Version::parse("2.1.5").unwrap()));
}
#[test]
fn test_allows_prerelease() {
assert!(VersionConstraint::GitRef("main".to_string()).allows_prerelease());
assert!(VersionConstraint::GitRef("latest".to_string()).allows_prerelease()); assert!(
!VersionConstraint::Exact {
prefix: None,
version: Version::parse("1.0.0").unwrap()
}
.allows_prerelease()
);
}
#[test]
fn test_version_constraint_parse_edge_cases() {
let constraint = VersionConstraint::parse("latest-prerelease").unwrap();
assert!(matches!(constraint, VersionConstraint::GitRef(_)));
let constraint = VersionConstraint::parse("*").unwrap();
assert!(matches!(constraint, VersionConstraint::GitRef(_)));
let constraint = VersionConstraint::parse(">=1.0.0").unwrap();
assert!(matches!(constraint, VersionConstraint::Requirement { .. }));
let constraint = VersionConstraint::parse("<2.0.0").unwrap();
assert!(matches!(constraint, VersionConstraint::Requirement { .. }));
let constraint = VersionConstraint::parse("=1.0.0").unwrap();
assert!(matches!(constraint, VersionConstraint::Requirement { .. }));
let constraint = VersionConstraint::parse("feature/new-feature").unwrap();
assert!(matches!(constraint, VersionConstraint::GitRef(_)));
let constraint = VersionConstraint::parse("abc123def456").unwrap();
assert!(matches!(constraint, VersionConstraint::GitRef(_)));
}
#[test]
fn test_version_constraint_display() {
let exact = VersionConstraint::Exact {
prefix: None,
version: Version::parse("1.0.0").unwrap(),
};
assert_eq!(format!("{exact}"), "1.0.0");
let req = VersionConstraint::parse("^1.0.0").unwrap();
assert_eq!(format!("{req}"), "^1.0.0");
let git_ref = VersionConstraint::GitRef("main".to_string());
assert_eq!(format!("{git_ref}"), "main");
let latest = VersionConstraint::GitRef("latest".to_string());
assert_eq!(format!("{latest}"), "latest");
}
#[test]
fn test_version_constraint_matches_ref() {
let git_ref = VersionConstraint::GitRef("main".to_string());
assert!(git_ref.matches_ref("main"));
assert!(!git_ref.matches_ref("develop"));
let exact = VersionConstraint::Exact {
prefix: None,
version: Version::parse("1.0.0").unwrap(),
};
assert!(!exact.matches_ref("v1.0.0"));
let latest = VersionConstraint::GitRef("latest".to_string());
assert!(latest.matches_ref("latest"));
}
#[test]
fn test_version_constraint_to_version_req() {
let exact = VersionConstraint::Exact {
prefix: None,
version: Version::parse("1.0.0").unwrap(),
};
let req = exact.to_version_req().unwrap();
assert!(req.matches(&Version::parse("1.0.0").unwrap()));
let caret = VersionConstraint::parse("^1.0.0").unwrap();
let req = caret.to_version_req().unwrap();
assert!(req.matches(&Version::parse("1.0.0").unwrap()));
let git_ref = VersionConstraint::GitRef("main".to_string());
assert!(git_ref.to_version_req().is_none());
let latest = VersionConstraint::GitRef("latest".to_string());
assert!(latest.to_version_req().is_none()); }
#[test]
fn test_constraint_set_with_prereleases() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::GitRef("main".to_string())).unwrap();
let v100_pre = Version::parse("1.0.0-alpha.1").unwrap();
let v100 = Version::parse("1.0.0").unwrap();
assert!(set.allows_prerelease());
let versions = vec![v100_pre.clone(), v100.clone()];
let best = set.find_best_match(&versions);
assert!(best.is_none()); }
#[test]
fn test_constraint_set_no_matches() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::parse(">=2.0.0").unwrap()).unwrap();
let versions = vec![Version::parse("1.0.0").unwrap(), Version::parse("1.5.0").unwrap()];
let best = set.find_best_match(&versions);
assert!(best.is_none());
}
#[test]
fn test_constraint_resolver_missing_dependency() {
let mut resolver = ConstraintResolver::new();
resolver.add_constraint("dep1", "^1.0.0").unwrap();
let available = HashMap::new();
let result = resolver.resolve(&available);
assert!(result.is_err());
}
#[test]
fn test_constraint_resolver_no_satisfying_version() {
let mut resolver = ConstraintResolver::new();
resolver.add_constraint("dep1", "^2.0.0").unwrap();
let mut available = HashMap::new();
available.insert(
"dep1".to_string(),
vec![Version::parse("1.0.0").unwrap()], );
let result = resolver.resolve(&available);
assert!(result.is_err());
}
#[test]
fn test_constraint_set_git_ref_conflicts() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::GitRef("main".to_string())).unwrap();
let result = set.add(VersionConstraint::GitRef("develop".to_string()));
assert!(result.is_err());
let result = set.add(VersionConstraint::GitRef("main".to_string()));
assert!(result.is_ok());
}
#[test]
fn test_git_ref_constraint_with_versions() {
let git_ref = VersionConstraint::GitRef("latest".to_string());
let v100_pre = Version::parse("1.0.0-alpha.1").unwrap();
let v100 = Version::parse("1.0.0").unwrap();
assert!(!git_ref.matches(&v100));
assert!(!git_ref.matches(&v100_pre));
}
#[test]
fn test_git_ref_allows_prereleases() {
let git_ref = VersionConstraint::GitRef("latest".to_string());
assert!(git_ref.allows_prerelease());
let main_ref = VersionConstraint::GitRef("main".to_string());
assert!(main_ref.allows_prerelease());
}
#[test]
fn test_requirement_constraint_allows_prerelease() {
let req = VersionConstraint::parse("^1.0.0").unwrap();
assert!(!req.allows_prerelease());
let exact = VersionConstraint::Exact {
prefix: None,
version: Version::parse("1.0.0").unwrap(),
};
assert!(!exact.allows_prerelease());
}
#[test]
fn test_constraint_set_prerelease_filtering() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::parse("^1.0.0").unwrap()).unwrap();
let versions = vec![
Version::parse("1.0.0-alpha.1").unwrap(),
Version::parse("1.0.0").unwrap(),
Version::parse("1.1.0-beta.1").unwrap(),
Version::parse("1.1.0").unwrap(),
];
let best = set.find_best_match(&versions).unwrap();
assert_eq!(best, &Version::parse("1.1.0").unwrap()); }
#[test]
fn test_parse_with_whitespace() {
let constraint = VersionConstraint::parse(" 1.0.0 ").unwrap();
assert!(matches!(constraint, VersionConstraint::Exact { .. }));
let constraint = VersionConstraint::parse(" latest ").unwrap();
assert!(matches!(constraint, VersionConstraint::GitRef(_)));
let constraint = VersionConstraint::parse(" ^1.0.0 ").unwrap();
assert!(matches!(constraint, VersionConstraint::Requirement { .. }));
}
#[test]
fn test_constraint_resolver_add_constraint_error() {
let mut resolver = ConstraintResolver::new();
resolver.add_constraint("dep1", "1.0.0").unwrap();
let result = resolver.add_constraint("dep1", "2.0.0");
assert!(result.is_err());
}
#[test]
fn test_constraint_set_no_conflict_different_types() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::parse("^1.0.0").unwrap()).unwrap();
set.add(VersionConstraint::GitRef("main".to_string())).unwrap();
assert_eq!(set.constraints.len(), 2);
}
#[test]
fn test_git_ref_to_version_req() {
let git_ref = VersionConstraint::GitRef("latest".to_string());
assert!(git_ref.to_version_req().is_none());
let main_ref = VersionConstraint::GitRef("main".to_string());
assert!(main_ref.to_version_req().is_none());
}
#[test]
fn test_prefixed_constraint_parsing() {
let constraint = VersionConstraint::parse("agents-v1.0.0").unwrap();
match constraint {
VersionConstraint::Exact {
prefix,
version,
} => {
assert_eq!(prefix, Some("agents".to_string()));
assert_eq!(version, Version::parse("1.0.0").unwrap());
}
_ => panic!("Expected Exact constraint"),
}
let constraint = VersionConstraint::parse("agents-^v1.0.0").unwrap();
match constraint {
VersionConstraint::Requirement {
prefix,
req,
} => {
assert_eq!(prefix, Some("agents".to_string()));
assert!(req.matches(&Version::parse("1.5.0").unwrap()));
assert!(!req.matches(&Version::parse("2.0.0").unwrap()));
}
_ => panic!("Expected Requirement constraint"),
}
let constraint = VersionConstraint::parse("^1.0.0").unwrap();
match constraint {
VersionConstraint::Requirement {
prefix,
..
} => {
assert_eq!(prefix, None);
}
_ => panic!("Expected Requirement constraint"),
}
}
#[test]
fn test_prefixed_constraint_display() {
let prefixed_exact = VersionConstraint::Exact {
prefix: Some("agents".to_string()),
version: Version::parse("1.0.0").unwrap(),
};
assert_eq!(prefixed_exact.to_string(), "agents-1.0.0");
let unprefixed_exact = VersionConstraint::Exact {
prefix: None,
version: Version::parse("1.0.0").unwrap(),
};
assert_eq!(unprefixed_exact.to_string(), "1.0.0");
let prefixed_req = VersionConstraint::parse("snippets-^v2.0.0").unwrap();
let display = prefixed_req.to_string();
assert!(display.starts_with("snippets-"));
}
#[test]
fn test_matches_version_info() {
use crate::version::VersionInfo;
let constraint = VersionConstraint::parse("agents-^v1.0.0").unwrap();
let version_info = VersionInfo {
prefix: Some("agents".to_string()),
version: Version::parse("1.2.0").unwrap(),
tag: "agents-v1.2.0".to_string(),
prerelease: false,
};
assert!(constraint.matches_version_info(&version_info));
let wrong_prefix = VersionInfo {
prefix: Some("snippets".to_string()),
version: Version::parse("1.2.0").unwrap(),
tag: "snippets-v1.2.0".to_string(),
prerelease: false,
};
assert!(!constraint.matches_version_info(&wrong_prefix));
let unprefixed_constraint = VersionConstraint::parse("^1.0.0").unwrap();
let unprefixed_version = VersionInfo {
prefix: None,
version: Version::parse("1.5.0").unwrap(),
tag: "v1.5.0".to_string(),
prerelease: false,
};
assert!(unprefixed_constraint.matches_version_info(&unprefixed_version));
assert!(!unprefixed_constraint.matches_version_info(&version_info));
}
#[test]
fn test_prefixed_constraint_conflicts() {
let mut set = ConstraintSet::new();
set.add(VersionConstraint::parse("agents-^v1.0.0").unwrap()).unwrap();
let result = set.add(VersionConstraint::parse("snippets-^v1.0.0").unwrap());
assert!(result.is_ok());
let result = set.add(VersionConstraint::parse("agents-~v1.2.0").unwrap());
assert!(result.is_ok());
let mut exact_set = ConstraintSet::new();
exact_set.add(VersionConstraint::parse("agents-v1.0.0").unwrap()).unwrap();
let result = exact_set.add(VersionConstraint::parse("snippets-v1.0.0").unwrap());
assert!(result.is_ok());
}
#[test]
fn test_prefix_with_hyphens() {
let constraint = VersionConstraint::parse("my-cool-agent-v1.0.0").unwrap();
match constraint {
VersionConstraint::Exact {
prefix,
version,
} => {
assert_eq!(prefix, Some("my-cool-agent".to_string()));
assert_eq!(version, Version::parse("1.0.0").unwrap());
}
_ => panic!("Expected Exact constraint"),
}
let constraint = VersionConstraint::parse("tool-v-v1.0.0").unwrap();
match constraint {
VersionConstraint::Exact {
prefix,
version,
} => {
assert_eq!(prefix, Some("tool-v".to_string()));
assert_eq!(version, Version::parse("1.0.0").unwrap());
}
_ => panic!("Expected Exact constraint"),
}
}
}