cub: add version constraint comparison for AUR deps (v6.0 2026)
This commit is contained in:
@@ -839,12 +839,46 @@ fn build_dep_graph(
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn check_version_constraint(dep: &str) {
|
||||
if let Some((name, req)) = cub::version::parse_constraint(dep) {
|
||||
if let Some(installed) = read_installed_version(&name) {
|
||||
if !req.matches(&installed) {
|
||||
eprintln!(
|
||||
" warning: {name} constraint {} not satisfied by installed version {installed}",
|
||||
req
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_installed_version(pkg: &str) -> Option<cub::version::Version> {
|
||||
let canonical = pkg.trim_start_matches("host:").to_ascii_lowercase();
|
||||
let path = format!("/var/lib/pkg/lib/{canonical}/info");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
for line in content.lines() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
if key.trim() == "version" {
|
||||
return cub::version::Version::parse(value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn install_one_dep(
|
||||
context: &AppContext,
|
||||
library: &mut Library,
|
||||
dep: &str,
|
||||
raw_dep: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let package_name = match PackageName::new(dep.to_string()) {
|
||||
let dep = cub::deps::dependency_base_name(raw_dep);
|
||||
if dep.is_empty() {
|
||||
eprintln!(" skipping empty dep after stripping constraint: {raw_dep}");
|
||||
return Ok(());
|
||||
}
|
||||
check_version_constraint(raw_dep);
|
||||
|
||||
let package_name = match PackageName::new(dep.clone()) {
|
||||
Ok(name) => name,
|
||||
Err(_) => {
|
||||
eprintln!(" skipping invalid package name: {dep}");
|
||||
@@ -871,7 +905,7 @@ fn install_one_dep(
|
||||
|
||||
print!(" fetching {dep} from AUR... ");
|
||||
io::stdout().flush()?;
|
||||
match fetch_aur_to_store(dep) {
|
||||
match fetch_aur_to_store(&dep) {
|
||||
Ok(_) => println!("done (recipe at ~/.cub/recipes/{dep}/)"),
|
||||
Err(error) => println!("failed: {error}"),
|
||||
}
|
||||
|
||||
@@ -4,9 +4,18 @@ use crate::aur::AurPackage;
|
||||
use crate::depresolve::{DepNode, resolve_build_order};
|
||||
use crate::deps::dependency_base_name;
|
||||
|
||||
fn display_name_key(name: &str) -> String {
|
||||
name.trim_start_matches("host:").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
pub struct ResolvedInstallPlan {
|
||||
pub build_order: Vec<String>,
|
||||
pub circular: Vec<Vec<String>>,
|
||||
/// Map from canonical package name to a comma-separated list of
|
||||
/// version constraints the build order honours (e.g. "openssl>=1.1").
|
||||
/// Populated only when AUR info was available; missing for offline
|
||||
/// or unknown packages.
|
||||
pub version_constraints: std::collections::HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct DepGraphBuilder<'a> {
|
||||
@@ -45,6 +54,9 @@ impl<'a> DepGraphBuilder<'a> {
|
||||
}
|
||||
|
||||
pub fn build(mut self) -> ResolvedInstallPlan {
|
||||
let mut version_constraints: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
while let Some(name) = self.queue.pop_front() {
|
||||
let normalized = name.to_ascii_lowercase();
|
||||
if !self.seen.insert(normalized.clone()) {
|
||||
@@ -56,7 +68,7 @@ impl<'a> DepGraphBuilder<'a> {
|
||||
|
||||
let pkg = (self.fetch)(&name);
|
||||
|
||||
let (depends, makedepends, provides, display_name) = match pkg {
|
||||
let (depends, makedepends, provides, display_name, raw_constraints) = match pkg {
|
||||
Some(pkg) => {
|
||||
let depends: Vec<String> = pkg
|
||||
.depends
|
||||
@@ -70,10 +82,28 @@ impl<'a> DepGraphBuilder<'a> {
|
||||
.map(|d| dependency_base_name(d).to_string())
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
let raw_constraints: Vec<String> = pkg
|
||||
.depends
|
||||
.iter()
|
||||
.chain(pkg.makedepends.iter())
|
||||
.filter(|d| dependency_base_name(d) == display_name_key(&pkg.name))
|
||||
.chain(
|
||||
pkg.depends
|
||||
.iter()
|
||||
.chain(pkg.makedepends.iter())
|
||||
.filter(|d| {
|
||||
let base = dependency_base_name(d);
|
||||
!base.is_empty()
|
||||
&& depends.contains(&base.to_string())
|
||||
|| makedepends.contains(&base.to_string())
|
||||
}),
|
||||
)
|
||||
.cloned()
|
||||
.collect();
|
||||
let name = pkg.name.clone();
|
||||
(depends, makedepends, pkg.provides.clone(), name)
|
||||
(depends, makedepends, pkg.provides.clone(), name, raw_constraints)
|
||||
}
|
||||
None => (Vec::new(), Vec::new(), Vec::new(), name),
|
||||
None => (Vec::new(), Vec::new(), Vec::new(), name, Vec::new()),
|
||||
};
|
||||
|
||||
for transitive in depends.iter().chain(makedepends.iter()) {
|
||||
@@ -83,6 +113,15 @@ impl<'a> DepGraphBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
for raw in &raw_constraints {
|
||||
if let Some((base, _)) = crate::version::parse_constraint(raw) {
|
||||
let key = base.trim_start_matches("host:").to_ascii_lowercase();
|
||||
if !key.is_empty() {
|
||||
version_constraints.entry(key).or_default().push(raw.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.nodes.push(DepNode {
|
||||
name: display_name,
|
||||
depends,
|
||||
@@ -95,6 +134,7 @@ impl<'a> DepGraphBuilder<'a> {
|
||||
ResolvedInstallPlan {
|
||||
build_order: resolved.build_order,
|
||||
circular: resolved.circular,
|
||||
version_constraints,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,4 +242,18 @@ mod tests {
|
||||
assert!(pos("b") < pos("a"));
|
||||
assert!(pos("c") < pos("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_constraints_preserved_on_dependents() {
|
||||
let packages = vec![
|
||||
aur_pkg("a", &["b>=1.0"], &[], &[]),
|
||||
aur_pkg("b", &[], &[], &[]),
|
||||
];
|
||||
let installed = HashSet::new();
|
||||
let plan = DepGraphBuilder::with_fetcher(["a"], &installed, fetch_one(&packages)).build();
|
||||
assert_eq!(
|
||||
plan.version_constraints.get("b"),
|
||||
Some(&vec!["b>=1.0".to_string()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ pub mod recipe;
|
||||
pub mod resolver;
|
||||
pub mod sandbox;
|
||||
pub mod storage;
|
||||
pub mod version;
|
||||
|
||||
pub use error::CubError;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionReq {
|
||||
pub comparator: Comparator,
|
||||
pub version: Version,
|
||||
/// A second clause for ranges (e.g. `>=1.0,<2.0`). Only `&&` chaining
|
||||
/// is supported (no `||`).
|
||||
pub second: Option<(Comparator, Version)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Comparator {
|
||||
Eq,
|
||||
Lt,
|
||||
Le,
|
||||
Gt,
|
||||
Ge,
|
||||
}
|
||||
|
||||
impl Comparator {
|
||||
fn parse(s: &str) -> Option<(Self, &str)> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
if chars.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (op, rest) = match (chars[0], chars[1]) {
|
||||
('=', _) => (Comparator::Eq, &s[1..]),
|
||||
('<', '=') => (Comparator::Le, &s[2..]),
|
||||
('<', _) => (Comparator::Lt, &s[1..]),
|
||||
('>', '=') => (Comparator::Ge, &s[2..]),
|
||||
('>', _) => (Comparator::Gt, &s[1..]),
|
||||
_ => return None,
|
||||
};
|
||||
Some((op, rest.trim_start()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Version {
|
||||
pub parts: Vec<u64>,
|
||||
pub suffix: Option<String>,
|
||||
pub revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (base, revision) = match s.split_once('-') {
|
||||
Some((b, r)) if r.chars().next().map_or(false, |c| c.is_ascii_digit()) => {
|
||||
(b, Some(r.parse().ok()?))
|
||||
}
|
||||
_ => (s, None),
|
||||
};
|
||||
let (core, suffix) = match base.find(|c: char| !c.is_ascii_digit() && c != '.') {
|
||||
Some(i) => (Some(&base[..i]), Some(base[i..].to_string())),
|
||||
None => (Some(base), None),
|
||||
};
|
||||
let parts: Vec<u64> = core?
|
||||
.split('.')
|
||||
.map(|p| p.parse::<u64>().ok())
|
||||
.collect::<Option<_>>()?;
|
||||
Some(Version {
|
||||
parts,
|
||||
suffix,
|
||||
revision,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn satisfies(&self, op: &Comparator, target: &Version) -> bool {
|
||||
let cmp = self.cmp(target);
|
||||
match op {
|
||||
Comparator::Eq => cmp == Ordering::Equal,
|
||||
Comparator::Lt => cmp == Ordering::Less,
|
||||
Comparator::Le => cmp == Ordering::Less || cmp == Ordering::Equal,
|
||||
Comparator::Gt => cmp == Ordering::Greater,
|
||||
Comparator::Ge => cmp == Ordering::Greater || cmp == Ordering::Equal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Version {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Version {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
let max = self.parts.len().max(other.parts.len());
|
||||
for i in 0..max {
|
||||
let a = self.parts.get(i).copied().unwrap_or(0);
|
||||
let b = other.parts.get(i).copied().unwrap_or(0);
|
||||
match a.cmp(&b) {
|
||||
Ordering::Equal => continue,
|
||||
ord => return ord,
|
||||
}
|
||||
}
|
||||
let sa = self.suffix.as_deref().unwrap_or("");
|
||||
let sb = other.suffix.as_deref().unwrap_or("");
|
||||
if sa != sb {
|
||||
return sa.cmp(sb);
|
||||
}
|
||||
self.revision.cmp(&other.revision)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Version {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.parts.iter().map(u64::to_string).collect::<Vec<_>>().join("."))?;
|
||||
if let Some(suffix) = &self.suffix {
|
||||
write!(f, "{suffix}")?;
|
||||
}
|
||||
if let Some(rev) = self.revision {
|
||||
write!(f, "-{rev}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl VersionReq {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut parts = s.split(',');
|
||||
let first = parts.next()?.trim();
|
||||
let (op, rest) = Comparator::parse(first)?;
|
||||
let version = Version::parse(rest)?;
|
||||
|
||||
let second = if let Some(second_raw) = parts.next() {
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let (op2, rest2) = Comparator::parse(second_raw.trim())?;
|
||||
let version2 = Version::parse(rest2)?;
|
||||
Some((op2, version2))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(VersionReq {
|
||||
comparator: op,
|
||||
version,
|
||||
second,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn matches(&self, candidate: &Version) -> bool {
|
||||
if !candidate.satisfies(&self.comparator, &self.version) {
|
||||
return false;
|
||||
}
|
||||
if let Some((op, version)) = &self.second {
|
||||
if !candidate.satisfies(op, version) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VersionReq {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let op = match self.comparator {
|
||||
Comparator::Eq => "=",
|
||||
Comparator::Lt => "<",
|
||||
Comparator::Le => "<=",
|
||||
Comparator::Gt => ">",
|
||||
Comparator::Ge => ">=",
|
||||
};
|
||||
write!(f, "{op}{}", self.version)?;
|
||||
if let Some((op, v)) = &self.second {
|
||||
let op = match op {
|
||||
Comparator::Eq => "=",
|
||||
Comparator::Lt => "<",
|
||||
Comparator::Le => "<=",
|
||||
Comparator::Gt => ">",
|
||||
Comparator::Ge => ">=",
|
||||
};
|
||||
write!(f, ",{op}{v}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_constraint(s: &str) -> Option<(String, VersionReq)> {
|
||||
let (base, rest) = s.split_once(|c: char| matches!(c, '<' | '>' | '='))?;
|
||||
let base = base.trim().to_string();
|
||||
let req = VersionReq::parse(rest)?;
|
||||
Some((base, req))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn v(s: &str) -> Version {
|
||||
Version::parse(s).expect(s)
|
||||
}
|
||||
|
||||
fn r(s: &str) -> VersionReq {
|
||||
VersionReq::parse(s).expect(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_semver_version() {
|
||||
let v = v("1.2.3");
|
||||
assert_eq!(v.parts, vec![1, 2, 3]);
|
||||
assert_eq!(v.revision, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_arch_date_based_version() {
|
||||
let v = v("1.2.3-1");
|
||||
assert_eq!(v.parts, vec![1, 2, 3]);
|
||||
assert_eq!(v.revision, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_version_with_alpha_suffix() {
|
||||
let v = v("1.2.3rc1");
|
||||
assert_eq!(v.parts, vec![1, 2, 3]);
|
||||
assert_eq!(v.suffix.as_deref(), Some("rc1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_compare_lexically_on_parts() {
|
||||
assert!(v("1.2.3") < v("1.2.4"));
|
||||
assert!(v("1.2.10") > v("1.2.9"));
|
||||
assert_eq!(v("1.2.3").cmp(&v("1.2.3")), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_version_treats_missing_parts_as_zero() {
|
||||
assert!(v("1.2") < v("1.2.1"));
|
||||
assert_eq!(v("1.0").cmp(&v("1.0.0")), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arch_pkgrel_breaks_ties() {
|
||||
assert!(v("1.2.3-1") < v("1.2.3-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equality_constraint() {
|
||||
let req = r("=1.2.3");
|
||||
assert!(req.matches(&v("1.2.3")));
|
||||
assert!(!req.matches(&v("1.2.4")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greater_than_constraint() {
|
||||
let req = r(">=1.0");
|
||||
assert!(req.matches(&v("1.0.0")));
|
||||
assert!(req.matches(&v("2.0.0")));
|
||||
assert!(!req.matches(&v("0.9.0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn less_than_constraint() {
|
||||
let req = r("<2.0");
|
||||
assert!(req.matches(&v("1.9.9")));
|
||||
assert!(!req.matches(&v("2.0.0")));
|
||||
assert!(!req.matches(&v("2.0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn less_than_or_equal_constraint() {
|
||||
let req = r("<=2.0");
|
||||
assert!(req.matches(&v("2.0.0")));
|
||||
assert!(req.matches(&v("1.5.0")));
|
||||
assert!(!req.matches(&v("2.0.1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_constraint() {
|
||||
let req = r(">=1.0,<2.0");
|
||||
assert!(req.matches(&v("1.0.0")));
|
||||
assert!(req.matches(&v("1.5.7")));
|
||||
assert!(!req.matches(&v("2.0.0")));
|
||||
assert!(!req.matches(&v("0.9.9")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arch_pkgrel_constraint() {
|
||||
let req = r(">=1.2.3-1");
|
||||
assert!(req.matches(&v("1.2.3-1")));
|
||||
assert!(req.matches(&v("1.2.3-2")));
|
||||
assert!(!req.matches(&v("1.2.3")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_semver_and_arch_versions() {
|
||||
let req = r(">=1.0");
|
||||
assert!(req.matches(&v("1.0.0-1")));
|
||||
assert!(req.matches(&v("1.0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_constraint_with_name() {
|
||||
let (name, req) = parse_constraint("openssl>=1.1").expect("parse");
|
||||
assert_eq!(name, "openssl");
|
||||
assert!(req.matches(&v("1.1.0")));
|
||||
assert!(!req.matches(&v("1.0.0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_constraint_syntax() {
|
||||
assert!(VersionReq::parse("not-a-version").is_none());
|
||||
assert!(VersionReq::parse(">=").is_none());
|
||||
assert!(VersionReq::parse(">1.0,<2.0,extra").is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user