Skip to main content

bv_builder/
resolve.rs

1use std::collections::{HashSet, VecDeque};
2
3use anyhow::{Context, Result, bail};
4use reqwest::Client;
5
6use crate::spec::{BuildSpec, PackageSpec, Platform, ResolvedPackage, ResolvedSpec};
7
8/// Resolve a `BuildSpec` to a fully pinned `ResolvedSpec` using the conda
9/// repodata from the declared channels.
10///
11/// Resolution strategy:
12/// 1. Download `repodata.json` for each channel + subdir.
13/// 2. BFS from the declared packages, resolving each transitive dependency.
14/// 3. Return a deterministically sorted `ResolvedSpec`.
15pub async fn resolve(spec: &BuildSpec) -> Result<ResolvedSpec> {
16    let direct = spec.package_specs()?;
17    let subdir = platform_subdir(&spec.platform);
18
19    let client = Client::builder()
20        .user_agent("bv-builder/0.1")
21        .timeout(std::time::Duration::from_secs(600))
22        .connect_timeout(std::time::Duration::from_secs(30))
23        .build()
24        .context("build HTTP client")?;
25
26    // Cache repodata to avoid re-downloading per package.
27    let mut repodata_cache: std::collections::HashMap<String, RepodataIndex> =
28        std::collections::HashMap::new();
29
30    let mut resolved_packages: Vec<ResolvedPackage> = Vec::new();
31    let mut resolved_names: HashSet<String> = HashSet::new();
32
33    // (name, is_direct)
34    let mut queue: VecDeque<(PackageSpec, bool)> = direct.into_iter().map(|p| (p, true)).collect();
35
36    while let Some((pkg_spec, is_direct)) = queue.pop_front() {
37        if resolved_names.contains(&pkg_spec.name) || is_virtual_package(&pkg_spec.name) {
38            continue;
39        }
40
41        let resolved = match resolve_package_cached(
42            &client,
43            &pkg_spec,
44            &spec.channels,
45            &subdir,
46            &mut repodata_cache,
47        )
48        .await
49        {
50            Ok(r) => r,
51            Err(e) if !is_direct => {
52                eprintln!("warning: skipping transitive dep '{}': {e}", pkg_spec.name);
53                resolved_names.insert(pkg_spec.name.clone());
54                continue;
55            }
56            Err(e) => return Err(e),
57        };
58
59        for dep_str in &resolved.depends {
60            if let Some(dep_spec) = parse_dep_spec(dep_str)
61                && !resolved_names.contains(&dep_spec.name)
62                && !is_virtual_package(&dep_spec.name)
63            {
64                queue.push_back((dep_spec, false));
65            }
66        }
67
68        resolved_names.insert(resolved.name.clone());
69        resolved_packages.push(resolved);
70    }
71
72    let base = spec.base.clone().or_else(|| {
73        Some(match &spec.platform {
74            crate::spec::Platform::LinuxAmd64 => {
75                "ghcr.io/tejasprabhune/bv-base/debian:12-slim".to_string()
76            }
77            crate::spec::Platform::LinuxArm64 => {
78                "ghcr.io/tejasprabhune/bv-base/debian:12-slim".to_string()
79            }
80        })
81    });
82
83    let mut out = ResolvedSpec {
84        name: spec.name.clone(),
85        version: spec.version.clone(),
86        platform: spec.platform.clone(),
87        channels: spec.channels.clone(),
88        packages: resolved_packages,
89        repodata_snapshot: None,
90        base,
91    };
92    out.sort_packages();
93    Ok(out)
94}
95
96/// Virtual/meta packages that don't have downloadable artifacts.
97// _openmp_mutex is excluded: though it ships no files, its libgomp dep must propagate.
98fn is_virtual_package(name: &str) -> bool {
99    name.starts_with("__")
100        || matches!(name, "_libgcc_mutex" | "ca-certificates" | "certifi")
101}
102
103/// Parse a conda dependency string (e.g. "libgcc-ng >=12.3.0,<13.0a0") into a PackageSpec.
104fn parse_dep_spec(dep: &str) -> Option<PackageSpec> {
105    let dep = dep.trim();
106    // Strip trailing build string markers (e.g. " * nomkl")
107    let dep = dep.split(" * ").next().unwrap_or(dep);
108
109    let mut parts = dep.splitn(2, ' ');
110    let name = parts.next()?.trim().to_string();
111    if name.is_empty() {
112        return None;
113    }
114    let version_spec = parts.next().unwrap_or("*").trim().to_string();
115    Some(PackageSpec {
116        name,
117        version_spec: crate::spec::VersionSpec(version_spec),
118    })
119}
120
121/// Try each channel in order and return the first match, using a repodata cache.
122async fn resolve_package_cached(
123    client: &Client,
124    pkg_spec: &PackageSpec,
125    channels: &[String],
126    subdir: &str,
127    cache: &mut std::collections::HashMap<String, RepodataIndex>,
128) -> Result<ResolvedPackage> {
129    for channel in channels {
130        for try_subdir in [subdir, "noarch"] {
131            let repodata_url = format!("{channel}/{try_subdir}/repodata.json");
132            let repodata = if let Some(rd) = cache.get(&repodata_url) {
133                rd
134            } else {
135                let rd: RepodataIndex = match client.get(&repodata_url).send().await {
136                    Ok(resp) if resp.status().is_success() => resp
137                        .json()
138                        .await
139                        .with_context(|| format!("parse repodata from {repodata_url}"))?,
140                    _ => continue,
141                };
142                cache.insert(repodata_url.clone(), rd);
143                cache.get(&repodata_url).unwrap()
144            };
145
146            if let Some(pkg) = find_best_match(repodata, pkg_spec, channel, try_subdir) {
147                return Ok(pkg);
148            }
149        }
150    }
151    bail!(
152        "package '{}' with spec '{}' not found in any channel",
153        pkg_spec.name,
154        pkg_spec.version_spec
155    )
156}
157
158/// Find the best (latest) matching package entry in a repodata index.
159fn find_best_match(
160    repodata: &RepodataIndex,
161    pkg_spec: &PackageSpec,
162    channel: &str,
163    subdir: &str,
164) -> Option<ResolvedPackage> {
165    let spec_str = pkg_spec.version_spec.0.as_str();
166
167    let mut candidates: Vec<(&str, &RepodataPackageRecord)> = repodata
168        .packages_conda
169        .iter()
170        .chain(repodata.packages.iter())
171        .filter(|(_, rec)| rec.name == pkg_spec.name && version_matches(&rec.version, spec_str))
172        .map(|(fname, rec)| (fname.as_str(), rec))
173        .collect();
174
175    // Sort by version descending, then build descending → pick latest.
176    candidates.sort_by(|(_, a), (_, b)| {
177        compare_conda_version(&b.version, &a.version).then(b.build_number.cmp(&a.build_number))
178    });
179
180    candidates.first().map(|(filename, rec)| {
181        let url = format!("{channel}/{subdir}/{filename}");
182        ResolvedPackage {
183            name: rec.name.clone(),
184            version: rec.version.clone(),
185            build: rec.build.clone(),
186            channel: channel.to_string(),
187            url,
188            sha256: rec.sha256.clone().unwrap_or_default(),
189            filename: filename.to_string(),
190            depends: rec.depends.clone(),
191        }
192    })
193}
194
195/// Check if `version` satisfies a conda-style constraint spec.
196/// Handles `*`, `==X`, `>=X`, `>X`, `<=X`, `<X`, and comma-separated combinations.
197fn version_matches(version: &str, spec: &str) -> bool {
198    let spec = spec.trim();
199    if spec.is_empty() || spec == "*" {
200        return true;
201    }
202    for part in spec.split(',') {
203        let part = part.trim();
204        if let Some(bound) = part.strip_prefix(">=") {
205            if compare_conda_version(version, bound.trim()) == std::cmp::Ordering::Less {
206                return false;
207            }
208        } else if let Some(bound) = part.strip_prefix('>') {
209            if compare_conda_version(version, bound.trim()) != std::cmp::Ordering::Greater {
210                return false;
211            }
212        } else if let Some(bound) = part.strip_prefix("<=") {
213            if compare_conda_version(version, bound.trim()) == std::cmp::Ordering::Greater {
214                return false;
215            }
216        } else if let Some(bound) = part.strip_prefix('<') {
217            if compare_conda_version(version, bound.trim()) != std::cmp::Ordering::Less {
218                return false;
219            }
220        } else if let Some(exact) = part.strip_prefix("==")
221            && version != exact.trim()
222        {
223            return false;
224        } else if let Some(ne) = part.strip_prefix("!=")
225            && version == ne.trim()
226        {
227            return false;
228        }
229        // Unknown operator: skip conservatively
230    }
231    true
232}
233
234/// Compare two conda version strings using numeric segment ordering.
235///
236/// Splits on "." and compares each segment numerically.  Segments with a
237/// non-numeric suffix (e.g. "0a0", "0b1", "0rc1") are treated as
238/// pre-releases and sort before the matching numeric-only segment:
239///   "1.22.0a0" < "1.22.0"
240/// This matches conda's version ordering so that constraints like
241/// `<1.22.0a0` work correctly.
242fn compare_conda_version(a: &str, b: &str) -> std::cmp::Ordering {
243    let a_segs: Vec<(u64, bool)> = a.split('.').map(version_seg).collect();
244    let b_segs: Vec<(u64, bool)> = b.split('.').map(version_seg).collect();
245    let len = a_segs.len().max(b_segs.len());
246    for i in 0..len {
247        let (an, a_pre) = a_segs.get(i).copied().unwrap_or((0, false));
248        let (bn, b_pre) = b_segs.get(i).copied().unwrap_or((0, false));
249        match an.cmp(&bn) {
250            std::cmp::Ordering::Equal => match (a_pre, b_pre) {
251                (true, false) => return std::cmp::Ordering::Less,
252                (false, true) => return std::cmp::Ordering::Greater,
253                _ => {}
254            },
255            other => return other,
256        }
257    }
258    std::cmp::Ordering::Equal
259}
260
261/// Parse one dot-separated version segment into (numeric_value, is_prerelease).
262/// "21" → (21, false), "0a0" → (0, true), "0rc1" → (0, true).
263fn version_seg(seg: &str) -> (u64, bool) {
264    let digits: String = seg.chars().take_while(|c| c.is_ascii_digit()).collect();
265    let is_pre = digits.len() < seg.len();
266    (digits.parse().unwrap_or(0), is_pre)
267}
268
269fn platform_subdir(platform: &Platform) -> String {
270    match platform {
271        Platform::LinuxAmd64 => "linux-64".to_string(),
272        Platform::LinuxArm64 => "linux-aarch64".to_string(),
273    }
274}
275
276// Repodata index structures
277
278#[derive(Debug, serde::Deserialize)]
279struct RepodataIndex {
280    #[serde(default)]
281    pub packages: std::collections::HashMap<String, RepodataPackageRecord>,
282    #[serde(default, rename = "packages.conda")]
283    pub packages_conda: std::collections::HashMap<String, RepodataPackageRecord>,
284}
285
286#[derive(Debug, Clone, serde::Deserialize)]
287struct RepodataPackageRecord {
288    pub name: String,
289    pub version: String,
290    pub build: String,
291    #[serde(default)]
292    pub build_number: u32,
293    pub sha256: Option<String>,
294    #[serde(default)]
295    pub depends: Vec<String>,
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn version_matches_star() {
304        assert!(version_matches("1.19.2", "*"));
305        assert!(version_matches("1.19.2", ""));
306    }
307
308    #[test]
309    fn version_matches_exact() {
310        assert!(version_matches("1.19.2", "==1.19.2"));
311        assert!(!version_matches("1.18.0", "==1.19.2"));
312    }
313
314    #[test]
315    fn version_matches_gte() {
316        assert!(version_matches("1.21", ">=1.21"));
317        assert!(version_matches("1.21.0", ">=1.21"));
318        assert!(!version_matches("1.9", ">=1.21"));
319        assert!(!version_matches("1.20.5", ">=1.21"));
320    }
321
322    #[test]
323    fn version_matches_range() {
324        assert!(version_matches("1.21.0", ">=1.21,<1.22.0a0"));
325        assert!(!version_matches("1.9", ">=1.21,<1.22.0a0"));
326        assert!(!version_matches("1.22.0", ">=1.21,<1.22.0a0"));
327    }
328
329    #[test]
330    fn compare_numeric_version_order() {
331        use std::cmp::Ordering::*;
332        assert_eq!(compare_conda_version("1.21", "1.9"), Greater);
333        assert_eq!(compare_conda_version("1.9", "1.21"), Less);
334        assert_eq!(compare_conda_version("1.21.0", "1.21"), Equal);
335        assert_eq!(compare_conda_version("2.0.0", "1.99.99"), Greater);
336    }
337
338    #[test]
339    fn compare_prerelease_sorts_before_release() {
340        use std::cmp::Ordering::*;
341        assert_eq!(compare_conda_version("1.22.0a0", "1.22.0"), Less);
342        assert_eq!(compare_conda_version("1.22.0", "1.22.0a0"), Greater);
343        assert_eq!(compare_conda_version("1.21.0", "1.22.0a0"), Less);
344    }
345}