diff --git a/local/recipes/system/cub/source/cub-cli/src/main.rs b/local/recipes/system/cub/source/cub-cli/src/main.rs index a602cffe25..87a5b68870 100644 --- a/local/recipes/system/cub/source/cub-cli/src/main.rs +++ b/local/recipes/system/cub/source/cub-cli/src/main.rs @@ -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 { + 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> { - 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}"), } diff --git a/local/recipes/system/cub/source/cub-lib/src/depgraph.rs b/local/recipes/system/cub/source/cub-lib/src/depgraph.rs index 800a24ab8e..902d8abde7 100644 --- a/local/recipes/system/cub/source/cub-lib/src/depgraph.rs +++ b/local/recipes/system/cub/source/cub-lib/src/depgraph.rs @@ -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, pub circular: Vec>, + /// 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>, } 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> = + 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 = 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 = 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()]) + ); + } } diff --git a/local/recipes/system/cub/source/cub-lib/src/lib.rs b/local/recipes/system/cub/source/cub-lib/src/lib.rs index 884bf55d1a..35471c53cd 100644 --- a/local/recipes/system/cub/source/cub-lib/src/lib.rs +++ b/local/recipes/system/cub/source/cub-lib/src/lib.rs @@ -15,5 +15,6 @@ pub mod recipe; pub mod resolver; pub mod sandbox; pub mod storage; +pub mod version; pub use error::CubError; diff --git a/local/recipes/system/cub/source/cub-lib/src/version.rs b/local/recipes/system/cub/source/cub-lib/src/version.rs new file mode 100644 index 0000000000..c64e85223d --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/version.rs @@ -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 = 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, + pub suffix: Option, + pub revision: Option, +} + +impl Version { + pub fn parse(s: &str) -> Option { + 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 = core? + .split('.') + .map(|p| p.parse::().ok()) + .collect::>()?; + 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 { + 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::>().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 { + 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()); + } +}