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 YAML example:
77/// ```yaml
78/// name: samtools
79/// version: 1.19.2
80/// channels:
81///   - https://conda.anaconda.org/bioconda
82///   - https://conda.anaconda.org/conda-forge
83/// packages:
84///   - samtools ==1.19.2
85/// entrypoint:
86///   command: /opt/conda/envs/env/bin/samtools
87/// platform: linux/amd64
88/// ```
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct BuildSpec {
91    pub name: String,
92    pub version: String,
93    pub channels: Vec<String>,
94    /// Package requirements in `name ==version` form, or just `name`.
95    pub packages: Vec<String>,
96    pub entrypoint: EntrypointSpec,
97    pub platform: Platform,
98}
99
100impl BuildSpec {
101    pub fn package_specs(&self) -> anyhow::Result<Vec<PackageSpec>> {
102        self.packages.iter().map(|s| PackageSpec::parse(s)).collect()
103    }
104}
105
106// ResolvedPackage
107
108/// One fully-pinned conda package produced by `resolve()`.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ResolvedPackage {
111    pub name: String,
112    pub version: String,
113    pub build: String,
114    pub channel: String,
115    /// Direct download URL for the package archive.
116    pub url: String,
117    /// sha256 of the archive bytes (hex, no prefix).
118    pub sha256: String,
119    /// File name: `<name>-<version>-<build>.conda` or `.tar.bz2`.
120    pub filename: String,
121}
122
123// ResolvedSpec
124
125/// Output of `resolve()`: every package exactly pinned.
126///
127/// Two `resolve()` calls with the same `BuildSpec` pointing at the same
128/// frozen repodata snapshots must produce byte-identical `ResolvedSpec`
129/// instances (deterministic sort order by name+version+build).
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ResolvedSpec {
132    pub name: String,
133    pub version: String,
134    pub platform: Platform,
135    pub channels: Vec<String>,
136    pub packages: Vec<ResolvedPackage>,
137    /// Optional: path/URL to the repodata snapshot used during resolution.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub repodata_snapshot: Option<String>,
140}
141
142impl ResolvedSpec {
143    /// Sort packages deterministically: name → version → build.
144    pub fn sort_packages(&mut self) {
145        self.packages.sort_by(|a, b| {
146            a.name
147                .cmp(&b.name)
148                .then(a.version.cmp(&b.version))
149                .then(a.build.cmp(&b.build))
150        });
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn package_spec_parse_with_version() {
160        let ps = PackageSpec::parse("samtools ==1.19.2").unwrap();
161        assert_eq!(ps.name, "samtools");
162        assert_eq!(ps.version_spec.0, "==1.19.2");
163    }
164
165    #[test]
166    fn package_spec_parse_bare_name() {
167        let ps = PackageSpec::parse("openssl").unwrap();
168        assert_eq!(ps.name, "openssl");
169        assert_eq!(ps.version_spec.0, "*");
170    }
171
172    #[test]
173    fn resolved_spec_sort_is_deterministic() {
174        let mut spec = ResolvedSpec {
175            name: "test".into(),
176            version: "1.0".into(),
177            platform: Platform::LinuxAmd64,
178            channels: vec![],
179            packages: vec![
180                ResolvedPackage {
181                    name: "zlib".into(),
182                    version: "1.3.1".into(),
183                    build: "h0_0".into(),
184                    channel: "conda-forge".into(),
185                    url: "https://example.com/zlib.conda".into(),
186                    sha256: "abc".into(),
187                    filename: "zlib-1.3.1-h0_0.conda".into(),
188                },
189                ResolvedPackage {
190                    name: "openssl".into(),
191                    version: "3.2.1".into(),
192                    build: "h0_0".into(),
193                    channel: "conda-forge".into(),
194                    url: "https://example.com/openssl.conda".into(),
195                    sha256: "def".into(),
196                    filename: "openssl-3.2.1-h0_0.conda".into(),
197                },
198            ],
199            repodata_snapshot: None,
200        };
201        spec.sort_packages();
202        assert_eq!(spec.packages[0].name, "openssl");
203        assert_eq!(spec.packages[1].name, "zlib");
204    }
205}