Skip to main content

bv_builder/
spec.rs

1use serde::{Deserialize, Serialize};
2
3// Platform
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum Platform {
8    #[serde(rename = "linux/amd64")]
9    LinuxAmd64,
10    #[serde(rename = "linux/arm64")]
11    LinuxArm64,
12}
13
14impl std::fmt::Display for Platform {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        match self {
17            Platform::LinuxAmd64 => write!(f, "linux/amd64"),
18            Platform::LinuxArm64 => write!(f, "linux/arm64"),
19        }
20    }
21}
22
23// VersionSpec
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct VersionSpec(pub String);
28
29impl std::fmt::Display for VersionSpec {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(f, "{}", self.0)
32    }
33}
34
35// PackageSpec
36
37/// One package requirement in a build spec.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PackageSpec {
40    pub name: String,
41    pub version_spec: VersionSpec,
42}
43
44impl PackageSpec {
45    /// Parse `samtools ==1.19.2` or `samtools` into a PackageSpec.
46    pub fn parse(s: &str) -> anyhow::Result<Self> {
47        let s = s.trim();
48        if let Some((name, spec)) = s.split_once(' ') {
49            Ok(Self {
50                name: name.trim().to_string(),
51                version_spec: VersionSpec(spec.trim().to_string()),
52            })
53        } else {
54            Ok(Self {
55                name: s.to_string(),
56                version_spec: VersionSpec("*".to_string()),
57            })
58        }
59    }
60}
61
62// EntrypointSpec
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EntrypointSpec {
66    pub command: String,
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub args: Vec<String>,
69}
70
71// BuildSpec
72
73/// Input spec for `bv-builder build`, mirroring apko's declarative format
74/// but adapted for conda packages.
75///
76/// Spec TOML example:
77/// ```toml
78/// name = "samtools"
79/// version = "1.19.2"
80/// channels = [
81///     "https://conda.anaconda.org/bioconda",
82///     "https://conda.anaconda.org/conda-forge",
83/// ]
84/// packages = ["samtools ==1.19.2"]
85/// platform = "linux/amd64"
86///
87/// [entrypoint]
88/// command = "/opt/conda/envs/env/bin/samtools"
89/// ```
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct BuildSpec {
92    pub name: String,
93    pub version: String,
94    pub channels: Vec<String>,
95    /// Package requirements in `name ==version` form, or just `name`.
96    pub packages: Vec<String>,
97    pub entrypoint: EntrypointSpec,
98    pub platform: Platform,
99    /// Optional base OCI image to pull layers from before the conda layers.
100    /// Defaults to `debian:12-slim` when not specified.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub base: Option<String>,
103}
104
105impl BuildSpec {
106    pub fn package_specs(&self) -> anyhow::Result<Vec<PackageSpec>> {
107        self.packages
108            .iter()
109            .map(|s| PackageSpec::parse(s))
110            .collect()
111    }
112}
113
114// ResolvedPackage
115
116/// One fully-pinned conda package produced by `resolve()`.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ResolvedPackage {
119    pub name: String,
120    pub version: String,
121    pub build: String,
122    pub channel: String,
123    /// Direct download URL for the package archive.
124    pub url: String,
125    /// sha256 of the archive bytes (hex, no prefix).
126    pub sha256: String,
127    /// File name: `<name>-<version>-<build>.conda` or `.tar.bz2`.
128    pub filename: String,
129    /// Runtime dependencies declared by this package (used during transitive resolution).
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub depends: Vec<String>,
132}
133
134// ResolvedSpec
135
136/// Output of `resolve()`: every package exactly pinned.
137///
138/// Two `resolve()` calls with the same `BuildSpec` pointing at the same
139/// frozen repodata snapshots must produce byte-identical `ResolvedSpec`
140/// instances (deterministic sort order by name+version+build).
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ResolvedSpec {
143    pub name: String,
144    pub version: String,
145    pub platform: Platform,
146    pub channels: Vec<String>,
147    pub packages: Vec<ResolvedPackage>,
148    /// Optional: path/URL to the repodata snapshot used during resolution.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub repodata_snapshot: Option<String>,
151    /// Base OCI image reference to include as the first layers.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub base: Option<String>,
154}
155
156impl ResolvedSpec {
157    /// Sort packages deterministically: name → version → build.
158    pub fn sort_packages(&mut self) {
159        self.packages.sort_by(|a, b| {
160            a.name
161                .cmp(&b.name)
162                .then(a.version.cmp(&b.version))
163                .then(a.build.cmp(&b.build))
164        });
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn package_spec_parse_with_version() {
174        let ps = PackageSpec::parse("samtools ==1.19.2").unwrap();
175        assert_eq!(ps.name, "samtools");
176        assert_eq!(ps.version_spec.0, "==1.19.2");
177    }
178
179    #[test]
180    fn package_spec_parse_bare_name() {
181        let ps = PackageSpec::parse("openssl").unwrap();
182        assert_eq!(ps.name, "openssl");
183        assert_eq!(ps.version_spec.0, "*");
184    }
185
186    #[test]
187    fn resolved_spec_sort_is_deterministic() {
188        let mut spec = ResolvedSpec {
189            name: "test".into(),
190            version: "1.0".into(),
191            platform: Platform::LinuxAmd64,
192            channels: vec![],
193            packages: vec![
194                ResolvedPackage {
195                    name: "zlib".into(),
196                    version: "1.3.1".into(),
197                    build: "h0_0".into(),
198                    channel: "conda-forge".into(),
199                    url: "https://example.com/zlib.conda".into(),
200                    sha256: "abc".into(),
201                    filename: "zlib-1.3.1-h0_0.conda".into(),
202                    depends: vec![],
203                },
204                ResolvedPackage {
205                    name: "openssl".into(),
206                    version: "3.2.1".into(),
207                    build: "h0_0".into(),
208                    channel: "conda-forge".into(),
209                    url: "https://example.com/openssl.conda".into(),
210                    sha256: "def".into(),
211                    filename: "openssl-3.2.1-h0_0.conda".into(),
212                    depends: vec![],
213                },
214            ],
215            repodata_snapshot: None,
216            base: None,
217        };
218        spec.sort_packages();
219        assert_eq!(spec.packages[0].name, "openssl");
220        assert_eq!(spec.packages[1].name, "zlib");
221    }
222}