1use std::collections::{HashSet, VecDeque};
2
3use anyhow::{Context, Result, bail};
4use reqwest::Client;
5
6use crate::spec::{BuildSpec, PackageSpec, Platform, ResolvedPackage, ResolvedSpec};
7
8pub 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 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 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
96fn is_virtual_package(name: &str) -> bool {
99 name.starts_with("__")
100 || matches!(name, "_libgcc_mutex" | "ca-certificates" | "certifi")
101}
102
103fn parse_dep_spec(dep: &str) -> Option<PackageSpec> {
105 let dep = dep.trim();
106 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
121async 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
158fn 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 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
195fn 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 }
231 true
232}
233
234fn 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
261fn 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#[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}