Skip to main content

bv_core/
lockfile.rs

1use std::collections::BTreeMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::{BvError, Result};
7
8pub type BinaryIndex = BTreeMap<String, String>;
9
10/// Per-dataset pin stored inside a lockfile entry.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ReferenceDataPin {
13    pub id: String,
14    pub version: String,
15    pub sha256: String,
16}
17
18/// One resolved tool entry in `bv.lock`.
19///
20/// Stability fields used by `bv lock --check` to detect drift:
21/// `tool_id`, `version`, `image_digest`, `manifest_sha256`.
22/// Timestamps and sizes are informational only.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct LockfileEntry {
26    pub tool_id: String,
27    /// Version requirement as declared in `bv.toml` (e.g. `=2.14.0`, `^2`, or `*`).
28    #[serde(default, skip_serializing_if = "String::is_empty")]
29    pub declared_version_req: String,
30    /// Resolved semver (e.g. `2.14.0`).
31    pub version: String,
32    /// Canonical OCI reference from the manifest (e.g. `ncbi/blast:2.14.0`).
33    pub image_reference: String,
34    /// Content digest of the pulled image (e.g. `sha256:abc123...`).
35    pub image_digest: String,
36    /// SHA-256 of the manifest TOML at resolve time; used for drift detection.
37    #[serde(default, skip_serializing_if = "String::is_empty")]
38    pub manifest_sha256: String,
39    pub image_size_bytes: Option<u64>,
40    pub resolved_at: DateTime<Utc>,
41    #[serde(default)]
42    pub reference_data_pins: BTreeMap<String, ReferenceDataPin>,
43    /// Binary names this tool contributes to the binary index.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub binaries: Vec<String>,
46}
47
48impl LockfileEntry {
49    /// True when two entries represent the same resolved state.
50    /// Ignores timestamps, sizes, and declared_version_req.
51    pub fn is_equivalent(&self, other: &Self) -> bool {
52        self.tool_id == other.tool_id
53            && self.version == other.version
54            && self.image_digest == other.image_digest
55            && (self.manifest_sha256.is_empty()
56                || other.manifest_sha256.is_empty()
57                || self.manifest_sha256 == other.manifest_sha256)
58    }
59}
60
61/// Informational metadata written to `bv.lock` by `bv lock`.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct LockfileMetadata {
64    pub bv_version: String,
65    pub generated_at: DateTime<Utc>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub hardware_summary: Option<String>,
68}
69
70impl Default for LockfileMetadata {
71    fn default() -> Self {
72        Self {
73            bv_version: env!("CARGO_PKG_VERSION").to_string(),
74            generated_at: Utc::now(),
75            hardware_summary: None,
76        }
77    }
78}
79
80/// The full `bv.lock` file (schema version 1).
81///
82/// Format is stable: `bv lock --check` fails if the generated lockfile
83/// would differ from the on-disk one on any stability field.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Lockfile {
87    /// Schema version; currently always `1`.
88    pub version: u32,
89    #[serde(default)]
90    pub metadata: LockfileMetadata,
91    #[serde(default)]
92    pub tools: BTreeMap<String, LockfileEntry>,
93    /// Derived routing table: binary name -> tool id.
94    /// Rebuilt by `rebuild_binary_index` whenever tools change.
95    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96    pub binary_index: BinaryIndex,
97}
98
99impl Lockfile {
100    pub fn new() -> Self {
101        Self {
102            version: 1,
103            metadata: LockfileMetadata::default(),
104            tools: BTreeMap::new(),
105            binary_index: BTreeMap::new(),
106        }
107    }
108
109    pub fn from_toml_str(s: &str) -> Result<Self> {
110        toml::from_str(s).map_err(|e| BvError::LockfileParse(e.to_string()))
111    }
112
113    pub fn to_toml_string(&self) -> Result<String> {
114        toml::to_string_pretty(self).map_err(|e| BvError::LockfileParse(e.to_string()))
115    }
116
117    /// Rebuild `binary_index` from each tool's `binaries` list.
118    ///
119    /// `overrides` maps binary name to the tool id that wins when two tools
120    /// expose the same name. Without an override, a collision returns `Err`.
121    pub fn rebuild_binary_index(
122        &mut self,
123        overrides: &BTreeMap<String, String>,
124    ) -> std::result::Result<(), String> {
125        let mut index: BinaryIndex = BTreeMap::new();
126        let mut collisions: Vec<String> = Vec::new();
127
128        let mut sorted: Vec<_> = self.tools.iter().collect();
129        sorted.sort_by_key(|(id, _)| id.as_str());
130
131        for (tool_id, entry) in &sorted {
132            for binary in &entry.binaries {
133                if let Some(winner) = overrides.get(binary) {
134                    index.insert(binary.clone(), winner.clone());
135                } else if let Some(existing) = index.insert(binary.clone(), tool_id.to_string())
136                    && existing != tool_id.as_str()
137                {
138                    collisions.push(format!(
139                        "'{binary}' exposed by both '{existing}' and '{tool_id}'"
140                    ));
141                    index.insert(binary.clone(), existing);
142                }
143            }
144        }
145
146        if !collisions.is_empty() {
147            return Err(collisions.join(", "));
148        }
149        self.binary_index = index;
150        Ok(())
151    }
152
153    /// True when both lockfiles describe the same set of tools at the same
154    /// resolved versions and digests.
155    pub fn is_equivalent_to(&self, other: &Self) -> bool {
156        if self.tools.len() != other.tools.len() {
157            return false;
158        }
159        for (id, entry) in &self.tools {
160            match other.tools.get(id) {
161                Some(other_entry) => {
162                    if !entry.is_equivalent(other_entry) {
163                        return false;
164                    }
165                }
166                None => return false,
167            }
168        }
169        true
170    }
171}
172
173impl Default for Lockfile {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn entry(id: &str, version: &str, digest: &str) -> LockfileEntry {
184        LockfileEntry {
185            tool_id: id.to_string(),
186            declared_version_req: String::new(),
187            version: version.to_string(),
188            image_reference: format!("registry/{id}:{version}"),
189            image_digest: digest.to_string(),
190            manifest_sha256: format!("sha256:m-{id}"),
191            image_size_bytes: None,
192            resolved_at: chrono::DateTime::<chrono::Utc>::from_timestamp(1700000000, 0).unwrap(),
193            reference_data_pins: BTreeMap::new(),
194            binaries: vec![format!("{id}-bin")],
195        }
196    }
197
198    /// Regression: lockfile serialization must be byte-deterministic so
199    /// `bv lock --check` can compare against the on-disk file. HashMap
200    /// iteration order is randomized; BTreeMap is stable.
201    #[test]
202    fn to_toml_string_is_deterministic() {
203        let mut lock = Lockfile::new();
204        for id in ["zebra", "alpha", "mango", "beta", "tango"] {
205            lock.tools.insert(
206                id.to_string(),
207                entry(id, "1.0.0", &format!("sha256:d-{id}")),
208            );
209            lock.binary_index
210                .insert(format!("{id}-bin"), id.to_string());
211        }
212
213        let s1 = lock.to_toml_string().unwrap();
214        for _ in 0..32 {
215            assert_eq!(s1, lock.to_toml_string().unwrap(), "non-deterministic output");
216        }
217        // Tools must appear in lexicographic order.
218        let alpha = s1.find("\"alpha\"").unwrap();
219        let beta = s1.find("\"beta\"").unwrap();
220        let mango = s1.find("\"mango\"").unwrap();
221        let tango = s1.find("\"tango\"").unwrap();
222        let zebra = s1.find("\"zebra\"").unwrap();
223        assert!(alpha < beta && beta < mango && mango < tango && tango < zebra);
224    }
225}