cub: add version constraint comparison for AUR deps (v6.0 2026)

This commit is contained in:
2026-06-10 16:02:53 +03:00
parent 282c4e3cbf
commit ecca89c1a6
4 changed files with 413 additions and 6 deletions
@@ -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());
}
}