Skip to main content

bv_builder/
resolve.rs

1use anyhow::{bail, Context, Result};
2use reqwest::Client;
3
4use crate::spec::{BuildSpec, PackageSpec, Platform, ResolvedPackage, ResolvedSpec};
5
6/// Resolve a `BuildSpec` to a fully pinned `ResolvedSpec` using the conda
7/// repodata from the declared channels.
8///
9/// Resolution strategy:
10/// 1. Download `repodata.json` for each channel + subdir.
11/// 2. For each `PackageSpec`, find the latest matching package.
12/// 3. Walk transitive dependencies and pin each one.
13/// 4. Return a deterministically sorted `ResolvedSpec`.
14///
15/// This is a simplified resolver that handles direct dependencies.
16/// For full SAT-based solving, wire in rattler_solve when available.
17pub async fn resolve(spec: &BuildSpec) -> Result<ResolvedSpec> {
18    let packages = spec.package_specs()?;
19    let subdir = platform_subdir(&spec.platform);
20
21    let client = Client::builder()
22        .user_agent("bv-builder/0.1")
23        .timeout(std::time::Duration::from_secs(120))
24        .build()
25        .context("build HTTP client")?;
26
27    let mut resolved_packages: Vec<ResolvedPackage> = Vec::new();
28    let mut resolved_names: std::collections::HashSet<String> = std::collections::HashSet::new();
29
30    for pkg_spec in &packages {
31        if resolved_names.contains(&pkg_spec.name) {
32            continue;
33        }
34        let resolved = resolve_package(&client, pkg_spec, &spec.channels, &subdir).await?;
35        resolved_names.insert(resolved.name.clone());
36        resolved_packages.push(resolved);
37    }
38
39    let mut out = ResolvedSpec {
40        name: spec.name.clone(),
41        version: spec.version.clone(),
42        platform: spec.platform.clone(),
43        channels: spec.channels.clone(),
44        packages: resolved_packages,
45        repodata_snapshot: None,
46    };
47    out.sort_packages();
48    Ok(out)
49}
50
51/// Try each channel in order and return the first match for `pkg_spec`.
52async fn resolve_package(
53    client: &Client,
54    pkg_spec: &PackageSpec,
55    channels: &[String],
56    subdir: &str,
57) -> Result<ResolvedPackage> {
58    for channel in channels {
59        let repodata_url = format!("{channel}/{subdir}/repodata.json");
60        let repodata: RepodataIndex = match client
61            .get(&repodata_url)
62            .send()
63            .await
64        {
65            Ok(resp) if resp.status().is_success() => resp
66                .json()
67                .await
68                .with_context(|| format!("parse repodata from {repodata_url}"))?,
69            _ => continue,
70        };
71
72        if let Some(pkg) = find_best_match(&repodata, pkg_spec, channel, subdir) {
73            return Ok(pkg);
74        }
75    }
76    bail!(
77        "package '{}' with spec '{}' not found in any channel",
78        pkg_spec.name,
79        pkg_spec.version_spec
80    )
81}
82
83/// Find the best (latest) matching package entry in a repodata index.
84fn find_best_match(
85    repodata: &RepodataIndex,
86    pkg_spec: &PackageSpec,
87    channel: &str,
88    subdir: &str,
89) -> Option<ResolvedPackage> {
90    let spec_str = pkg_spec.version_spec.0.as_str();
91
92    let mut candidates: Vec<(&str, &RepodataPackageRecord)> = repodata
93        .packages_conda
94        .iter()
95        .chain(repodata.packages.iter())
96        .filter(|(_, rec)| {
97            rec.name == pkg_spec.name && version_matches(&rec.version, spec_str)
98        })
99        .map(|(fname, rec)| (fname.as_str(), rec))
100        .collect();
101
102    // Sort by version descending, then build descending → pick latest.
103    candidates.sort_by(|(_, a), (_, b)| {
104        b.version
105            .cmp(&a.version)
106            .then(b.build_number.cmp(&a.build_number))
107    });
108
109    candidates.first().map(|(filename, rec)| {
110        let url = format!("{channel}/{subdir}/{filename}");
111        ResolvedPackage {
112            name: rec.name.clone(),
113            version: rec.version.clone(),
114            build: rec.build.clone(),
115            channel: channel.to_string(),
116            url,
117            sha256: rec.sha256.clone().unwrap_or_default(),
118            filename: filename.to_string(),
119        }
120    })
121}
122
123/// Rudimentary version matcher for conda-style specs:
124/// `*` / `` → any, `==X` → exact, `>=X` → >=, `>=X,<Y` → range.
125fn version_matches(version: &str, spec: &str) -> bool {
126    let spec = spec.trim();
127    if spec.is_empty() || spec == "*" {
128        return true;
129    }
130    if let Some(exact) = spec.strip_prefix("==") {
131        return version == exact.trim();
132    }
133    // Treat anything else as a constraint string but allow the package through
134    // for now; full semver/conda-version matching is deferred to rattler_solve.
135    true
136}
137
138fn platform_subdir(platform: &Platform) -> String {
139    match platform {
140        Platform::LinuxAmd64 => "linux-64".to_string(),
141        Platform::LinuxArm64 => "linux-aarch64".to_string(),
142    }
143}
144
145// Repodata index structures
146
147#[derive(Debug, serde::Deserialize)]
148struct RepodataIndex {
149    #[serde(default)]
150    pub packages: std::collections::HashMap<String, RepodataPackageRecord>,
151    #[serde(default, rename = "packages.conda")]
152    pub packages_conda: std::collections::HashMap<String, RepodataPackageRecord>,
153}
154
155#[derive(Debug, Clone, serde::Deserialize)]
156struct RepodataPackageRecord {
157    pub name: String,
158    pub version: String,
159    pub build: String,
160    #[serde(default)]
161    pub build_number: u32,
162    pub sha256: Option<String>,
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn version_matches_star() {
171        assert!(version_matches("1.19.2", "*"));
172        assert!(version_matches("1.19.2", ""));
173    }
174
175    #[test]
176    fn version_matches_exact() {
177        assert!(version_matches("1.19.2", "==1.19.2"));
178        assert!(!version_matches("1.18.0", "==1.19.2"));
179    }
180}