Skip to main content

bv_core/
lockfile.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::{BvError, Result};
7
8pub type BinaryIndex = HashMap<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)]
24pub struct LockfileEntry {
25    pub tool_id: String,
26    /// Version requirement as declared in `bv.toml` (e.g. `=2.14.0`, `^2`, or `*`).
27    #[serde(default, skip_serializing_if = "String::is_empty")]
28    pub declared_version_req: String,
29    /// Resolved semver (e.g. `2.14.0`).
30    pub version: String,
31    /// Canonical OCI reference from the manifest (e.g. `ncbi/blast:2.14.0`).
32    pub image_reference: String,
33    /// Content digest of the pulled image (e.g. `sha256:abc123...`).
34    pub image_digest: String,
35    /// SHA-256 of the manifest TOML at resolve time; used for drift detection.
36    #[serde(default, skip_serializing_if = "String::is_empty")]
37    pub manifest_sha256: String,
38    pub image_size_bytes: Option<u64>,
39    pub resolved_at: DateTime<Utc>,
40    #[serde(default)]
41    pub reference_data_pins: HashMap<String, ReferenceDataPin>,
42    /// Binary names this tool contributes to the binary index.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub binaries: Vec<String>,
45}
46
47impl LockfileEntry {
48    /// True when two entries represent the same resolved state.
49    /// Ignores timestamps, sizes, and declared_version_req.
50    pub fn is_equivalent(&self, other: &Self) -> bool {
51        self.tool_id == other.tool_id
52            && self.version == other.version
53            && self.image_digest == other.image_digest
54            && (self.manifest_sha256.is_empty()
55                || other.manifest_sha256.is_empty()
56                || self.manifest_sha256 == other.manifest_sha256)
57    }
58}
59
60/// Informational metadata written to `bv.lock` by `bv lock`.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct LockfileMetadata {
63    pub bv_version: String,
64    pub generated_at: DateTime<Utc>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub hardware_summary: Option<String>,
67}
68
69impl Default for LockfileMetadata {
70    fn default() -> Self {
71        Self {
72            bv_version: env!("CARGO_PKG_VERSION").to_string(),
73            generated_at: Utc::now(),
74            hardware_summary: None,
75        }
76    }
77}
78
79/// The full `bv.lock` file (schema version 1).
80///
81/// Format is stable: `bv lock --check` fails if the generated lockfile
82/// would differ from the on-disk one on any stability field.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Lockfile {
85    /// Schema version; currently always `1`.
86    pub version: u32,
87    #[serde(default)]
88    pub metadata: LockfileMetadata,
89    #[serde(default)]
90    pub tools: HashMap<String, LockfileEntry>,
91    /// Derived routing table: binary name -> tool id.
92    /// Rebuilt by `rebuild_binary_index` whenever tools change.
93    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
94    pub binary_index: BinaryIndex,
95}
96
97impl Lockfile {
98    pub fn new() -> Self {
99        Self {
100            version: 1,
101            metadata: LockfileMetadata::default(),
102            tools: HashMap::new(),
103            binary_index: HashMap::new(),
104        }
105    }
106
107    pub fn from_toml_str(s: &str) -> Result<Self> {
108        toml::from_str(s).map_err(|e| BvError::LockfileParse(e.to_string()))
109    }
110
111    pub fn to_toml_string(&self) -> Result<String> {
112        toml::to_string_pretty(self).map_err(|e| BvError::LockfileParse(e.to_string()))
113    }
114
115    /// Rebuild `binary_index` from each tool's `binaries` list.
116    ///
117    /// `overrides` maps binary name to the tool id that wins when two tools
118    /// expose the same name. Without an override, a collision returns `Err`.
119    pub fn rebuild_binary_index(
120        &mut self,
121        overrides: &HashMap<String, String>,
122    ) -> std::result::Result<(), String> {
123        let mut index: BinaryIndex = HashMap::new();
124        let mut collisions: Vec<String> = Vec::new();
125
126        let mut sorted: Vec<_> = self.tools.iter().collect();
127        sorted.sort_by_key(|(id, _)| id.as_str());
128
129        for (tool_id, entry) in &sorted {
130            for binary in &entry.binaries {
131                if let Some(winner) = overrides.get(binary) {
132                    index.insert(binary.clone(), winner.clone());
133                } else if let Some(existing) = index.insert(binary.clone(), tool_id.to_string())
134                    && existing != tool_id.as_str()
135                {
136                    collisions.push(format!(
137                        "'{binary}' exposed by both '{existing}' and '{tool_id}'"
138                    ));
139                    index.insert(binary.clone(), existing);
140                }
141            }
142        }
143
144        if !collisions.is_empty() {
145            return Err(collisions.join(", "));
146        }
147        self.binary_index = index;
148        Ok(())
149    }
150
151    /// True when both lockfiles describe the same set of tools at the same
152    /// resolved versions and digests.
153    pub fn is_equivalent_to(&self, other: &Self) -> bool {
154        if self.tools.len() != other.tools.len() {
155            return false;
156        }
157        for (id, entry) in &self.tools {
158            match other.tools.get(id) {
159                Some(other_entry) => {
160                    if !entry.is_equivalent(other_entry) {
161                        return false;
162                    }
163                }
164                None => return false,
165            }
166        }
167        true
168    }
169}
170
171impl Default for Lockfile {
172    fn default() -> Self {
173        Self::new()
174    }
175}