1use anyhow::{bail, Context, Result};
2use reqwest::Client;
3
4use crate::spec::{BuildSpec, PackageSpec, Platform, ResolvedPackage, ResolvedSpec};
5
6pub 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
51async 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
83fn 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 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
123fn 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 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#[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}